diff --git a/accessories/busy_lock.py b/accessories/busy_lock.py new file mode 100644 index 00000000..2234c0ab --- /dev/null +++ b/accessories/busy_lock.py @@ -0,0 +1,108 @@ +"""Starts a bridge with a lock that refuses commands while its bolt moves, +and a sensor that is offline, both answering with a HAP status. +""" +import logging +import signal +import time + +from pyhap.accessory import Accessory, Bridge +from pyhap.accessory_driver import AccessoryDriver +from pyhap.const import ( + CATEGORY_DOOR_LOCK, + CATEGORY_SENSOR, + HAP_SERVER_STATUS, +) +from pyhap.errors import HapStatusError + + +logging.basicConfig(level=logging.INFO, format="[%(module)s] %(message)s") + +LOCK_SECURED = 1 +LOCK_UNSECURED = 0 + + +class BusyLock(Accessory): + """A lock whose bolt takes a few seconds to reach its end. + + While it is moving, a controller's command to change the target state + is refused with a HAP status instead of the generic failure: the Home + app tells that the lock is busy, and does not pretend the command + worked. The refused target is put back, so the lock does not promise + a state it will not reach. + """ + + category = CATEGORY_DOOR_LOCK + + MOVE_SECONDS = 5 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self._moving_until = 0.0 + service = self.add_preload_service("LockMechanism") + self.char_current_state = service.configure_char( + "LockCurrentState", value=LOCK_SECURED) + self.char_target_state = service.configure_char( + "LockTargetState", value=LOCK_SECURED, + setter_callback=self.set_target_state) + self.set_info_service(serial_number="busy lock") + + @Accessory.run_at_interval(1) + async def run(self): + """End a move once the bolt reached its end.""" + if time.monotonic() >= self._moving_until: + if self.char_current_state.value != self.char_target_state.value: + logging.info("The bolt reached its end") + self.char_current_state.set_value(self.char_target_state.value) + + def set_target_state(self, value): + """Accept a command, or refuse it while the bolt is still moving.""" + if time.monotonic() < self._moving_until: + # The command came while the bolt has not reached its end. Answer + # with the resource busy status, and take the refused target back + # so the lock's own state stays consistent. + self.char_target_state.set_value( + self.char_current_state.value, should_notify=False) + logging.info("Refusing to move the bolt again, it is still moving") + raise HapStatusError(HAP_SERVER_STATUS.RESOURCE_BUSY) + + logging.info("Moving the bolt to %s", "secure" if value else "unsecure") + self._moving_until = time.monotonic() + self.MOVE_SECONDS + + +class OfflineSensor(Accessory): + """A temperature sensor that is currently unreachable. + + Its getter raises a HAP status, so reads answer with that status + instead of a value that would be a lie. When it is back online, + raise nothing and return the temperature. + """ + + category = CATEGORY_SENSOR + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + serv_temp = self.add_preload_service("TemperatureSensor") + self.char_temp = serv_temp.configure_char( + 'CurrentTemperature', getter_callback=self.get_temperature) + + def get_temperature(self): + """Answer with a status while the sensor cannot be reached.""" + logging.info("The temperature cannot be read right now") + raise HapStatusError(HAP_SERVER_STATUS.SERVICE_COMMUNICATION_FAILURE) + + +def get_bridge(driver): + bridge = Bridge(driver, 'Bridge') + + bridge.add_accessory(BusyLock(driver, 'Busy Lock')) + bridge.add_accessory(OfflineSensor(driver, 'Offline Sensor')) + + return bridge + + +driver = AccessoryDriver(port=51826, persist_file='busy_lock.state') +driver.add_accessory(accessory=get_bridge(driver)) +signal.signal(signal.SIGTERM, driver.signal_handler) +driver.start() diff --git a/docs/source/advanced/async.rst b/docs/source/advanced/async.rst index e0940647..75b91533 100644 --- a/docs/source/advanced/async.rst +++ b/docs/source/advanced/async.rst @@ -131,6 +131,13 @@ 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``. +Getters answer the same way: a read of the characteristic returns the +status instead of a value, while the other characteristics of the request +keep their own results. The accessories list carries no per-characteristic +status, so there a failing getter — whatever it raises — answers by listing +its characteristic without a value, and one broken accessory cannot break +another one's pairing. + 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/docs/source/intro/examples.rst b/docs/source/intro/examples.rst index de615ab9..688124ce 100644 --- a/docs/source/intro/examples.rst +++ b/docs/source/intro/examples.rst @@ -43,6 +43,11 @@ device. - An accessory that fetches its state over HTTP. * - ``busy_home.py`` - A bridge with many accessories of mixed kinds. + * - ``busy_lock.py`` + - Answering with a HAP status: a lock that refuses commands with + RESOURCE_BUSY while its bolt is moving, and an offline sensor whose + reads answer a status instead of a value + (:class:`pyhap.errors.HapStatusError`). To run one, install the repository and run the file with the project's virtualenv active — or copy it into your own script and adapt it. The diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index 0fdb90c5..20bc143b 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -1149,6 +1149,15 @@ def get_characteristics( else char.get_value() ) rep[HAP_REPR_STATUS] = HAP_SERVER_STATUS.SUCCESS + except HapStatusError as e: + # The getter answers the controller with its own HAP status. + logger.debug( + "%s: Characteristic %s answered status %s", + self.accessory.display_name, + (aid, iid), + e.status, + ) + rep[HAP_REPR_STATUS] = e.status except CharacteristicError: logger.error( "%s: Error getting value for characteristic %s.", diff --git a/pyhap/characteristic.py b/pyhap/characteristic.py index d72313dd..6c88e631 100644 --- a/pyhap/characteristic.py +++ b/pyhap/characteristic.py @@ -571,7 +571,15 @@ def to_HAP(self, include_value: bool = True) -> dict[str, Any]: hap_rep[HAP_REPR_MAX_LEN] = max_length if include_value and HAP_PERMISSION_READ in permissions: - hap_rep[HAP_REPR_VALUE] = self.get_value() + try: + hap_rep[HAP_REPR_VALUE] = self.get_value() + except Exception: + # One failing getter must not fail the whole accessories + # response: the characteristic is listed without a value. + logger.exception( + "%s: Error getting the value; answering without it", + self._display_name, + ) if not include_value: self._to_hap_cache = hap_rep diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index 4cad5947..c78e14d8 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -225,6 +225,56 @@ def test_get_characteristics_requires_read_permission( assert getter.call_count == reads +def _raise_out_of_resource() -> NoReturn: + raise HapStatusError(HAP_SERVER_STATUS.OUT_OF_RESOURCE) + + +def test_get_characteristics_hap_status_answers_request( + driver: AccessoryDriver, +) -> None: + """A getter's HapStatusError answers with its status, not -70402.""" + acc, char, _ = _add_bridged_or_standalone(driver, ["pr", "ev"], False) + char.getter_callback = _raise_out_of_resource + + iid = acc.iid_manager.get_iid(char) + result = driver.get_characteristics([f"{acc.aid}.{iid}"]) + + assert result == { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: iid, + HAP_REPR_STATUS: HAP_SERVER_STATUS.OUT_OF_RESOURCE, + } + ] + } + + +def test_get_accessories_isolates_a_failing_getter( + driver: AccessoryDriver, +) -> None: + """A failing getter omits its value, and does not fail the whole list.""" + acc = Accessory(driver, "Test Accessory") + service = Service(uuid1(), "Test Service") + good = Characteristic("Good", uuid1(), CHAR_PROPS) + good.getter_callback = lambda: 1 + bad = Characteristic("Bad", uuid1(), CHAR_PROPS) + bad.getter_callback = _raise_out_of_resource + service.add_characteristic(good, bad) + acc.add_service(service) + driver.add_accessory(acc) + + (acc_rep,) = driver.get_accessories()[HAP_REPR_ACCS] + reps = { + rep[HAP_REPR_IID]: rep for rep in acc_rep[HAP_REPR_SERVICES][-1][HAP_REPR_CHARS] + } + good_rep = reps[acc.iid_manager.get_iid(good)] + bad_rep = reps[acc.iid_manager.get_iid(bad)] + + assert good_rep[HAP_REPR_VALUE] == 1 + assert HAP_REPR_VALUE not in bad_rep + + @pytest.mark.parametrize( ("permissions", "values", "reads"), [