Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
787a525
Return -70405 when reading a characteristic without paired read
RaHehl Nov 8, 2026
071f96d
Answer -70406 for event subscriptions without notify permission
RaHehl Nov 8, 2026
3fd5a1b
Return -70404 when writing a characteristic without paired write
RaHehl Nov 8, 2026
93d17d3
Ignore timed writes with an unknown or expired pid
RaHehl Nov 8, 2026
e09ecbd
Split building the characteristics write response into helpers
RaHehl Nov 8, 2026
1ef0721
Only send write responses for characteristics with wr permission
RaHehl Nov 8, 2026
9ef52f7
Check the timed write permission on writes
RaHehl Nov 8, 2026
3082bba
Await async characteristic setters before answering the write
RaHehl Nov 8, 2026
f946d1f
Only let the writing connection read back a write response
RaHehl Nov 8, 2026
aeaafaf
Merge branch 'enforce-permissions' into enforce-timed-write
RaHehl Nov 8, 2026
9b2c4d0
Merge branch 'enforce-timed-write' into async-setters
RaHehl Nov 8, 2026
c4c842b
Test write response privacy with async setters
RaHehl Nov 8, 2026
ab604de
Merge remote-tracking branch 'upstream/dev' into enforce-permissions
RaHehl Nov 10, 2026
8f298c6
Drop the redundant bool() around the write response check
RaHehl Nov 10, 2026
ff12fb6
Type the write response helpers with the HAP JSON value types
RaHehl Nov 10, 2026
c9ed715
Explain why a private write puts the former value back
RaHehl Nov 10, 2026
93abcd8
Merge branch 'enforce-permissions' into enforce-timed-write
RaHehl Nov 10, 2026
f76d810
Merge branch 'enforce-timed-write' into async-setters
RaHehl Nov 10, 2026
2e00188
Don't tell subscribers the request of a write with r
RaHehl Nov 10, 2026
0aa3907
Merge branch 'enforce-permissions' into enforce-timed-write
RaHehl Nov 10, 2026
d130cfb
Type the timed write pid and ignore a pid that is not an int
RaHehl Nov 10, 2026
81746e4
Merge branch 'enforce-timed-write' into async-setters
RaHehl Nov 10, 2026
3a593d7
Name the fields of a pending async write and keep write_response a bool
RaHehl Nov 10, 2026
770db17
Run test_start_from_sync on the driver's own loop
RaHehl Nov 10, 2026
83b3e66
Merge branch 'enforce-timed-write' into async-setters
RaHehl Nov 10, 2026
142ce33
Merge remote-tracking branch 'upstream/dev' into enforce-timed-write
RaHehl Nov 10, 2026
312dd18
Merge branch 'enforce-timed-write' into async-setters
RaHehl Nov 10, 2026
0688518
Refuse a prepare whose ttl or pid is not an integer
RaHehl Nov 10, 2026
71119dc
Check the prepare ttl and pid before the try and test the edges
RaHehl Nov 10, 2026
0964d0c
Merge branch 'enforce-timed-write' into async-setters
RaHehl Nov 10, 2026
fe8c4ed
Tell subscribers about an async write once its setter succeeded
RaHehl Nov 10, 2026
b1caf00
Keep client_update_value notifying at once unless the caller defers it
RaHehl Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
330 changes: 290 additions & 40 deletions pyhap/accessory_driver.py

Large diffs are not rendered by default.

36 changes: 29 additions & 7 deletions pyhap/characteristic.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,8 @@ def __init__(
# Setters may take just the value or (value, sender_client_addr); see
# ``_setter_wants_addr``. The return value is sent back as the write
# response when the characteristic has the "wr" permission and the
# controller asked for one.
# controller asked for one. A setter may also be a coroutine function;
# a controller's write is answered once it is done.
self.setter_callback: Callable[..., Any] | None = None
# Cache of whether ``setter_callback`` opts into the client address,
# recomputed only when the setter object itself changes.
Expand Down Expand Up @@ -402,12 +403,17 @@ def client_update_value(
sender_client_addr: tuple[str, int] | None = None,
*,
notify: bool = True,
defer_notify: bool = False,
) -> Any:
"""Called from broker for value change in Home app.

Change self.value to value and call callback. With ``notify`` False,
subscribers are not told about the written value, e.g. for a request
that only the writing connection may read back.
Change self.value to value and call callback. Returns what the callback
returned. With ``notify`` False, subscribers are not told about the
written value, e.g. for a request that only the writing connection may
read back. With ``defer_notify``, a coroutine the callback returns is
for the caller to await, and the caller then calls
:meth:`client_update_finished`, so subscribers only learn the value
once the setter succeeded.
"""
original_value = value
if not self._always_null or original_value is not None:
Expand All @@ -429,12 +435,28 @@ def client_update_value(
response = self.setter_callback(value, sender_client_addr)
else:
response = self.setter_callback(value)
changed = self._value != previous_value
if changed and notify:
if not (defer_notify and inspect.iscoroutine(response)):
self.client_update_finished(
previous_value, sender_client_addr, notify=notify
)
return response

def client_update_finished(
self,
previous_value: Any,
sender_client_addr: tuple[str, int] | None,
*,
notify: bool = True,
) -> None:
"""Finish a client's write, e.g. after awaiting its coroutine setter.

Tells subscribers about the new value if it differs from
``previous_value``, see :meth:`client_update_value`.
"""
if notify and self._value != previous_value:
self.notify(sender_client_addr)
if self._always_null:
self.value = None
return response

def _setter_wants_addr(self) -> bool:
"""Whether the current setter opts into the sender's client address.
Expand Down
1 change: 1 addition & 0 deletions pyhap/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
HAP_PERMISSION_HIDDEN = "hd"
HAP_PERMISSION_NOTIFY = "ev"
HAP_PERMISSION_READ = "pr"
HAP_PERMISSION_TIMED_WRITE = "tw"
HAP_PERMISSION_WRITE = "pw"
HAP_PERMISSION_WRITE_RESPONSE = "wr"

Expand Down
39 changes: 32 additions & 7 deletions pyhap/hap_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import asyncio
from collections.abc import Awaitable
from http import HTTPStatus
import inspect
import logging
import sys
from typing import TYPE_CHECKING, Any, cast
Expand Down Expand Up @@ -711,16 +712,40 @@ def handle_set_characteristics(self) -> None:
"%s: Set characteristics content: %s", self.client_address, requested_chars
)

response = self.accessory_handler.set_characteristics(
requested_chars, cast("tuple[str, int]", self.client_address)
result = self.accessory_handler._set_characteristics_for_controller(
requested_chars,
cast("tuple[str, int]", self.client_address),
RESPONSE_TIMEOUT,
)
if response is None:
self.send_response(HTTPStatus.NO_CONTENT)
assert self.response is not None # noqa: S101
if inspect.isawaitable(result):
# A setter is still running, answer once it is done. The driver
# times out slow setters itself.
self.response.task = asyncio.ensure_future(
self._async_set_characteristics_body(self.response, result)
)
return
self._fill_set_characteristics_response(self.response, result)

self.send_response(HTTPStatus.MULTI_STATUS)
self.send_header("Content-Type", self.JSON_RESPONSE_TYPE)
self.end_response(to_hap_json(response))
def _fill_set_characteristics_response(
self, response: HAPResponse, result: dict[str, Any] | None
) -> None:
"""Fill in the response to a characteristics write."""
if result is None:
response.status_code = HTTPStatus.NO_CONTENT.value
response.reason = HTTPStatus.NO_CONTENT.phrase
return
response.status_code = HTTPStatus.MULTI_STATUS.value
response.reason = HTTPStatus.MULTI_STATUS.phrase
response.headers.append(("Content-Type", self.JSON_RESPONSE_TYPE))
response.body = to_hap_json(result)

async def _async_set_characteristics_body(
self, response: HAPResponse, result: Awaitable[dict[str, Any] | None]
) -> bytes:
"""Wait for the pending setters, then fill in the write response."""
self._fill_set_characteristics_response(response, await result)
return response.body

def handle_prepare(self) -> None:
"""Handles a client request to prepare to write."""
Expand Down
Loading
Loading