Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 19 additions & 0 deletions docs/source/advanced/async.rst
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,25 @@ parameter literally named ``sender_client_addr`` — useful when a write must
be tied to the connection that made it, e.g. Setup Endpoints. Any other
signature is called with just the value.

**Answering a HAP status.** A callback that fails does not have to answer
the generic ``-70402`` (service communication failure): raise
:class:`pyhap.errors.HapStatusError` with a status the controller can act
on, and the characteristic answers with it instead —

.. code-block:: python

from pyhap.const import HAP_SERVER_STATUS
from pyhap.errors import HapStatusError

def set_lock_target_state(value):
if not lock.is_ready():
raise HapStatusError(HAP_SERVER_STATUS.RESOURCE_BUSY)
lock.set_state(value)

The status set is curated — see :ref:`api-errors` for which statuses a
callback may decide and why the rest belong to the driver. Every other
exception keeps answering ``-70402``.

A setter's return value is used as the *write response* for characteristics
with the ``wr`` permission — the value the writing controller reads back in
the HTTP response. A write response, and the read back value of a
Expand Down
43 changes: 43 additions & 0 deletions pyhap/accessory_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
from pyhap.state import State

from .const import HAP_SERVER_STATUS
from .errors import HapStatusError
from .util import callback

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -124,6 +125,16 @@ def _wrap_char_setter(
response = char.client_update_value(
value, client_addr, notify=notify, defer_notify=True
)
except HapStatusError as e:
# The setter answers the controller with its own HAP status.
logger.debug(
"%s: Characteristic %s answered status %s for value %s",
client_addr,
char.display_name,
e.status,
value,
)
return e.status, None
except Exception:
logger.exception(
"%s: Error while setting characteristic %s to %s",
Expand Down Expand Up @@ -153,6 +164,17 @@ def _wrap_acc_setter(acc: Any, updates_by_service: Any, client_addr: Any) -> Any
_close_unsupported_coroutine(
acc.setter_callback(updates_by_service), acc.display_name
)
except HapStatusError as e:
# The callback answers the controller with its own HAP status,
# applied to every characteristic of the accessory in the request.
logger.debug(
"%s: The %s accessory answered status %s for %s",
client_addr,
acc.display_name,
e.status,
updates_by_service,
)
return e.status
except Exception:
logger.exception(
"%s: Error while setting characteristics to %s for the %s accessory",
Expand All @@ -174,6 +196,17 @@ def _wrap_service_setter(service: Any, chars: Any, client_addr: Any) -> Any:
_close_unsupported_coroutine(
service.setter_callback(service_chars), service.display_name
)
except HapStatusError as e:
# The callback answers the controller with its own HAP status,
# applied to every characteristic of the service in the request.
logger.debug(
"%s: The %s service answered status %s for %s",
client_addr,
service.display_name,
e.status,
service_chars,
)
return e.status
except Exception:
logger.exception(
"%s: Error while setting characteristics to %s for the %s service",
Expand Down Expand Up @@ -1524,6 +1557,16 @@ async def _async_finish_set_characteristics(
write = tasks[task]
try:
outcome = task.result()
except HapStatusError as e:
# The setter answers the controller with its own HAP status.
logger.debug(
"%s: Characteristic %s answered status %s",
client_addr,
write.char.display_name,
e.status,
)
write.result[HAP_REPR_STATUS] = e.status
continue
except (Exception, asyncio.CancelledError):
logger.exception(
"%s: Error while setting characteristic %s",
Expand Down
168 changes: 168 additions & 0 deletions tests/test_accessory_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
HAP_REPR_WRITE_RESPONSE,
HAP_SERVER_STATUS,
)
from pyhap.errors import HapStatusError
from pyhap.hap_crypto import hap_hkdf
from pyhap.hap_protocol import HAPServerProtocol
from pyhap.service import Service
Expand Down Expand Up @@ -845,6 +846,173 @@ async def test_async_setter_result(
assert char.value == stored


def _raise_resource_busy(value: Any) -> None:
raise HapStatusError(HAP_SERVER_STATUS.RESOURCE_BUSY)


async def _async_raise_out_of_resource(value: int) -> NoReturn:
await asyncio.sleep(0)
raise HapStatusError(HAP_SERVER_STATUS.OUT_OF_RESOURCE)


@pytest.mark.parametrize("bridged", [False, True], ids=["standalone", "bridged"])
async def test_sync_setter_hap_status_answers_request(
driver: AccessoryDriver, bridged: bool
) -> None:
"""A sync setter's HapStatusError answers with its status, not -70402."""
acc, char, _ = _add_bridged_or_standalone(driver, ["pr", "pw"], bridged)
char.setter_callback = _raise_resource_busy

result = driver.set_characteristics(
{
HAP_REPR_CHARS: [
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: acc.iid_manager.get_iid(char),
HAP_REPR_VALUE: 5,
}
]
},
"mock_addr", # ty: ignore[invalid-argument-type]
)

assert result == {
HAP_REPR_CHARS: [
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: acc.iid_manager.get_iid(char),
HAP_REPR_STATUS: HAP_SERVER_STATUS.RESOURCE_BUSY,
}
]
}


@pytest.mark.parametrize("bridged", [False, True], ids=["standalone", "bridged"])
async def test_async_setter_hap_status_answers_request(
driver: AccessoryDriver, bridged: bool
) -> None:
"""A coroutine setter's HapStatusError answers with its status."""
acc, char, _ = _add_bridged_or_standalone(driver, ["pr", "pw", "wr"], bridged)
char.setter_callback = _async_raise_out_of_resource

result = await _controller_write(
driver, acc, char, **{HAP_REPR_WRITE_RESPONSE: True}
)

assert result == {
HAP_REPR_CHARS: [
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: acc.iid_manager.get_iid(char),
HAP_REPR_STATUS: HAP_SERVER_STATUS.OUT_OF_RESOURCE,
}
]
}


def test_service_setter_hap_status_answers_all_its_chars(
driver: AccessoryDriver,
) -> None:
"""A service setter's HapStatusError answers its chars, not the others."""
acc = Accessory(driver, "TestAcc")
refused = Service(uuid1(), "Refused")
char_refused = Characteristic("RefusedValue", uuid1(), CHAR_PROPS)
refused.add_characteristic(char_refused)
other = Service(uuid1(), "Other")
char_other = Characteristic("OtherValue", uuid1(), CHAR_PROPS)
other.add_characteristic(char_other)
acc.add_service(refused, other)
driver.add_accessory(acc)
refused.setter_callback = _raise_resource_busy

response = driver.set_characteristics(
{
HAP_REPR_CHARS: [
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: acc.iid_manager.get_iid(char_refused),
HAP_REPR_VALUE: 3,
},
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: acc.iid_manager.get_iid(char_other),
HAP_REPR_VALUE: 4,
},
]
},
"mock_addr", # ty: ignore[invalid-argument-type]
)

assert response == {
HAP_REPR_CHARS: [
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: acc.iid_manager.get_iid(char_refused),
HAP_REPR_STATUS: HAP_SERVER_STATUS.RESOURCE_BUSY,
},
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: acc.iid_manager.get_iid(char_other),
HAP_REPR_STATUS: HAP_SERVER_STATUS.SUCCESS,
},
]
}


def test_accessory_setter_hap_status_answers_all_its_chars(
driver: AccessoryDriver,
) -> None:
"""An accessory setter's HapStatusError answers its chars, not the others."""
bridge = Bridge(driver, "mybridge")
acc = Accessory(driver, "TestAcc", aid=2)
acc2 = Accessory(driver, "TestAcc2", aid=3)
service = Service(uuid1(), "Service")
char_on = Characteristic("On", uuid1(), CHAR_PROPS)
service.add_characteristic(char_on)
acc.add_service(service)
service2 = Service(uuid1(), "Service")
char_on2 = Characteristic("On", uuid1(), CHAR_PROPS)
service2.add_characteristic(char_on2)
acc2.add_service(service2)
bridge.add_accessory(acc)
bridge.add_accessory(acc2)
driver.add_accessory(bridge)
acc.setter_callback = _raise_resource_busy

response = driver.set_characteristics(
{
HAP_REPR_CHARS: [
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: acc.iid_manager.get_iid(char_on),
HAP_REPR_VALUE: True,
},
{
HAP_REPR_AID: acc2.aid,
HAP_REPR_IID: acc2.iid_manager.get_iid(char_on2),
HAP_REPR_VALUE: True,
},
]
},
"mock_addr", # ty: ignore[invalid-argument-type]
)

assert response == {
HAP_REPR_CHARS: [
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: acc.iid_manager.get_iid(char_on),
HAP_REPR_STATUS: HAP_SERVER_STATUS.RESOURCE_BUSY,
},
{
HAP_REPR_AID: acc2.aid,
HAP_REPR_IID: acc2.iid_manager.get_iid(char_on2),
HAP_REPR_STATUS: HAP_SERVER_STATUS.SUCCESS,
},
]
}


@pytest.mark.parametrize(
("reader", "expected"),
[
Expand Down
Loading