Skip to content

Handle timed writes as the HAP spec describes - #536

Closed
RaHehl wants to merge 20 commits into
ikalchev:devfrom
RaHehl:enforce-timed-write
Closed

RaHehl wants to merge 20 commits into
ikalchev:devfrom
RaHehl:enforce-timed-write

Conversation

@RaHehl

@RaHehl RaHehl commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

A write whose pid was never prepared on the connection, was already used, or outlived its TTL is now ignored as a whole and every characteristic gets -70410, as the spec says and HAP-NodeJS does. Before, only characteristics without a value got -70410, and values were still written. The TTL now uses the monotonic clock, so a wall clock jump after boot can't expire a lock or garage door command.

A plain write to a tw characteristic is refused with -70410 only with AccessoryDriver(enforce_timed_writes=True). By default it is applied and logged at debug level. A Home hub wrote HomeKit Camera Active without a timed write in my tests, and the HomeKit Secure Video guide gives Streaming Enabled and Motion Enabled tw too, so enforcing it by default would break HKSV. Other controllers, like aiohomekit over IP, don't do timed writes either.

A prepare whose ttl or pid is not an integer is refused with -70410, as the spec defines both as integers.

test_start_from_sync now runs on the driver's own loop and executor, so stopping the driver no longer shuts down the executor of the shared test loop that later tests use.

AI use: written with the help of an AI coding assistant (Opus 5.5). I reviewed and tested the changes myself (pytest, ruff, ty).

GET /characteristics returned the stored value of every characteristic,
including write-only ones such as Identify or a camera key. The HAP spec
only allows reads of characteristics with the paired read (pr)
permission and answers everything else with -70405. HAP-NodeJS does the
same.

The getter is not called for such characteristics. The /accessories
database already omitted their value; this adds tests for it.
Any characteristic could be subscribed to, whether or not it has the
notify (ev) permission. A controller could then receive the values that
another controller wrote to a write-only characteristic. Per the HAP
spec, (un)subscribing a characteristic without ev now answers -70406
and leaves the subscriptions unchanged, the same as HAP-NodeJS. A value
in the same request entry is not written.

Some tests subscribed to Version, which has no ev permission. They now
use CurrentDoorState.
A value written to a characteristic without the pw permission still
reached its setter. Answer it with -70404 like HAP-NodeJS instead, without
calling the characteristic, service or accessory setters. A subscription
in the same entry and the other characteristics of the request are still
handled.
A write carrying a pid that was never prepared on this connection, was
already used or has outlived its TTL was still applied, and its event
subscriptions were changed. Only characteristics without a value got
-70410.

The HAP specification says such a request must be ignored and answered
with -70410. Do that for every characteristic in the request, as
HAP-NodeJS does. The TTL is now measured with the monotonic clock, so a
wall clock jump, e.g. from NTP right after boot, can't expire a timed
write.
A write with "r": true got the setter's return value back for any
characteristic. The HAP spec ties write responses to the write response
(wr) permission, and HAP-NodeJS ignores the request for other
characteristics. Do the same.

When a write response is sent, it becomes the characteristic's value
instead of the written one, which HAP-NodeJS does as well. The sent value
is the stored one, after the usual format checks. A response the
characteristic can't hold is still sent and the written value is kept.

The write response tests are now parametrized and also cover a wr
characteristic without paired read: neither the written value nor the
response can be read back.
The HAP specification requires a plain write to a characteristic with
the timed write ("tw") permission to be refused with -70410. HAP-NodeJS
does this.

A Home hub has been seen writing HomeKit Camera Active without a timed
write, and the HomeKit Secure Video guide gives Streaming Enabled and
Motion Enabled tw as well. Other controllers may not support timed
writes at all. So the check is opt-in: with enforce_timed_writes=True
the driver refuses such a write, otherwise it applies it and only logs
at debug level.
@codecov

codecov Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.57%. Comparing base (0e85dcc) to head (71119dc).

Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #536      +/-   ##
==========================================
+ Coverage   91.53%   91.57%   +0.03%     
==========================================
  Files          27       27              
  Lines        4219     4235      +16     
  Branches      532      537       +5     
==========================================
+ Hits         3862     3878      +16     
  Misses        250      250              
  Partials      107      107              
Files with missing lines Coverage Δ
pyhap/accessory_driver.py 93.01% <100.00%> (+0.19%) ⬆️
pyhap/const.py 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

A write response of a characteristic that also has pr was stored as its
value, so any paired controller could read it with GET /characteristics
or /accessories. Keep it per connection instead: the connection that
wrote reads its last response until it closes. Other connections read
the characteristic's own value, which the write leaves as it was, also
when the setter raises or returns nothing. Any new write of a connection
drops its former response. Characteristics without pr keep the response
as the value.
@RaHehl
RaHehl marked this pull request as ready for review November 10, 2026 13:13
client_update_value takes notify=False for a write response request on
a readable characteristic, so ev subscribers don't get a request that
only the writer may read, e.g. a WiFi configuration with a key.
HAPValue moves to pyhap.characteristic next to the formats.
A pid of another type, e.g. a list, raised a TypeError instead of
answering -70410.
It set a new default executor on the shared test loop and shut it down
when the driver stopped, so later tests on the same xdist worker failed
with "cannot schedule new futures after shutdown". The driver now makes
its own loop and executor, as a sync start does.
# Conflicts:
#	pyhap/accessory_driver.py
#	tests/test_accessory_driver.py
The write only accepts an integer pid, so a prepare with another pid
could never be used. A ttl too large for a float is refused too, and an
entry expires at the same moment for prepare and write. Tests cover a
rejected timed write with several characteristics and an explicit null
value on a tw characteristic.
A bool pid, a float ttl and the last moment of a TTL are covered now.

@ikalchev ikalchev left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just one type nit/question from my side, otherwise good.

Comment thread pyhap/accessory_driver.py
@@ -1060,6 +1075,23 @@ def set_characteristics(
"""
queries: list[dict[str, Any]] = chars_query[HAP_REPR_CHARS]

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Is the query mapping to Any really? Is it not a hap type here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, it's the HAP JSON of a single characteristic write, so a small TypedDict fits better than dict[str, Any]. Since this PR went in together with #537, I did that in #550.

@RaHehl

RaHehl commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

Closing, the changes are in dev with #537. The type nit is addressed in #550.

@RaHehl RaHehl closed this Nov 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants