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
108 changes: 108 additions & 0 deletions accessories/busy_lock.py
Original file line number Diff line number Diff line change
@@ -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()
7 changes: 7 additions & 0 deletions docs/source/advanced/async.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/source/intro/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions pyhap/accessory_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
10 changes: 9 additions & 1 deletion pyhap/characteristic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions tests/test_accessory_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
[
Expand Down
Loading