diff --git a/docs/source/advanced/async.rst b/docs/source/advanced/async.rst index 460f9be2..e0940647 100644 --- a/docs/source/advanced/async.rst +++ b/docs/source/advanced/async.rst @@ -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 diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index 36c943d2..0fdb90c5 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -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__) @@ -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", @@ -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", @@ -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", @@ -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", diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index 7ecd4312..4cad5947 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -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 @@ -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"), [