diff --git a/docs/source/api/connection.rst b/docs/source/api/connection.rst new file mode 100644 index 00000000..7761e5fc --- /dev/null +++ b/docs/source/api/connection.rst @@ -0,0 +1,22 @@ +.. _api-connection: + +========== +Connection +========== + +The identity of a controller connection. + +.. currentmodule:: pyhap.connection + +.. autoclass:: HAPConnectionHandle + :members: + +Why not just the client address +=============================== + +The ``(address, port)`` of a connection is unique only while it lives - a +later connection may reuse the address of a closed one. A handle compares +equal only for the same connection, so anything a callback keeps per +connection, a write response, a camera session, cannot leak to a +connection that reused the address. See the class docstring for the +phased migration of the driver's per-client state to the handle. diff --git a/docs/source/api/context.rst b/docs/source/api/context.rst new file mode 100644 index 00000000..2fca846f --- /dev/null +++ b/docs/source/api/context.rst @@ -0,0 +1,20 @@ +.. _api-context: + +======== +Contexts +======== + +The contexts passed to delegate and lifecycle calls. New input data +arrives as new fields of these classes; a callback's signature never +changes. + +.. currentmodule:: pyhap.context + +.. autoclass:: CharContext + :members: + +.. autoclass:: CharReadContext + :members: + +.. autoclass:: CharWriteContext + :members: diff --git a/docs/source/api/delegates.rst b/docs/source/api/delegates.rst new file mode 100644 index 00000000..31f4bf54 --- /dev/null +++ b/docs/source/api/delegates.rst @@ -0,0 +1,28 @@ +.. _api-delegates: + +========= +Delegates +========= + +The delegate interfaces of the callback interface: an object a +characteristic or service is wired up with, receiving a +:class:`~pyhap.context.CharContext` per call. Registration is when the driver +records what a delegate overrides; the read and write paths never +introspect it again. + +.. currentmodule:: pyhap.delegates + +.. autoclass:: CharacteristicDelegate + :members: + +.. autoclass:: ServiceDelegate + :members: + +Async by design +=============== + +Every delegate method is a coroutine function. The driver awaits every +call and enforces the context's deadline - there is no sync/async +detection. An overridden method that is not a coroutine function fails +registration with a :class:`TypeError`, so a wrong method is caught at +its source. diff --git a/docs/source/api/errors.rst b/docs/source/api/errors.rst new file mode 100644 index 00000000..d19ce2a4 --- /dev/null +++ b/docs/source/api/errors.rst @@ -0,0 +1,23 @@ +.. _api-errors: + +======= +Errors +======= + +Errors raised by accessory callbacks to answer a HAP request with a status. + +.. currentmodule:: pyhap.errors + +.. autoclass:: HapStatusError + :members: + +Which statuses a callback may answer with +========================================= + +.. data:: CALLBACK_STATUSES + + The frozenset of HAP statuses a callback may raise + :class:`HapStatusError` with. Everything else - privileges and + permission failures, read-only and write-only characteristics, + unsupported notifications and timeouts - is enforced or answered by + the accessory driver itself and reserved. diff --git a/pyhap/connection.py b/pyhap/connection.py new file mode 100644 index 00000000..fbc9dc0b --- /dev/null +++ b/pyhap/connection.py @@ -0,0 +1,76 @@ +"""The identity of a controller connection.""" + +from __future__ import annotations + + +class HAPConnectionHandle: + """The stable identity of one controller connection. + + Contexts pass this to delegate and lifecycle calls, so a callback can + tie what it does to the connection that caused it, e.g. a camera may + let only the connection that set a session up control it. Beyond + ``client_addr`` it is opaque on purpose: the address of a connection + is unique only while it lives, a later connection may reuse the same + ``(address, port)`` pair, so equality and hashing are by the + connection's identity - two handles are equal only for the same + connection. Anything kept per connection, a write response, a camera + session, cannot leak to a connection that reused the address of a + closed one. + + The driver's per-client state is keyed by the client address today, + with guards at the connection boundaries (e.g. a write response that + arrives after its connection closed is dropped). The refactor moves + it to the handle in two phases that do not change this class: + + 1. Handles appear in contexts, and camera session ownership uses + them; the address-keyed state keeps its guards. + 2. Event subscriptions, prepared writes, write responses and pending + writes are re-keyed by the handle, and the guards are removed. + """ + + __slots__ = ("_client_addr", "_generation") + + def __init__(self, client_addr: tuple[str, int] | None, generation: int) -> None: + """Initialise with the connection's address and its generation. + + :param client_addr: The connection's ``(address, port)``, or + ``None`` for a call without a connection, e.g. a + programmatic write through the legacy API. A controller's + request always has one. + :param generation: A driver-internal number that differs between + two connections with the same address; part of what makes + the handle unique, but not part of its interface. + """ + self._client_addr = client_addr + self._generation = generation + + @property + def client_addr(self) -> tuple[str, int] | None: + """The connection's ``(address, port)``, ``None`` without one.""" + return self._client_addr + + def __eq__(self, other: object) -> bool: + """Equal only for the same connection, never for a reused address.""" + if not isinstance(other, HAPConnectionHandle): + return NotImplemented + return self._key == other._key + + def __ne__(self, other: object) -> bool: + """Not equal for anything but the same connection.""" + result = self.__eq__(other) + if result is NotImplemented: + return NotImplemented + return not result + + @property + def _key(self) -> tuple[tuple[str, int] | None, int]: + """What identifies the handle, also for hashing.""" + return (self._client_addr, self._generation) + + def __hash__(self) -> int: + """Hash by the same key :meth:`__eq__` compares.""" + return hash(self._key) + + def __repr__(self) -> str: + """Show the connection's address and identity.""" + return f"{self.__class__.__name__}({self._client_addr!r}, {self._generation})" diff --git a/pyhap/context.py b/pyhap/context.py new file mode 100644 index 00000000..a7a2914b --- /dev/null +++ b/pyhap/context.py @@ -0,0 +1,61 @@ +"""The contexts passed to delegate and lifecycle calls.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING +from uuid import UUID + +from .connection import HAPConnectionHandle + +if TYPE_CHECKING: + from .characteristic import Characteristic + + +@dataclass(frozen=True) +class CharContext: + """What a characteristic callback knows about the controller it serves. + + Contexts are the input side of the callback interface: new input data + arrives as new fields of this class, so a callback's signature never + changes - watch this class instead of callback parameters. Instances + are frozen; a callback must not try to change one. + + :ivar client_addr: The controller's ``(address, port)``, ``None`` + without a connection. + :ivar client_uuid: The identity of the paired controller, ``None`` + before pair-verify completes. + :ivar connection: The stable identity of the connection, see + :class:`~pyhap.connection.HAPConnectionHandle`. + :ivar deadline: Monotonic time; the driver answers ``-70408`` for a + callback that is not done by it. + """ + + client_addr: tuple[str, int] | None + client_uuid: UUID | None + connection: HAPConnectionHandle + deadline: float + + +@dataclass(frozen=True) +class CharReadContext(CharContext): + """The context of a read, e.g. of ``on_get``. + + :ivar characteristic: The characteristic being read. + """ + + characteristic: Characteristic + + +@dataclass(frozen=True) +class CharWriteContext(CharContext): + """The context of a write, e.g. of ``on_set``. + + :ivar characteristic: The characteristic being written, or ``None`` + for a grouped call that carries several, e.g. a service's or an + accessory's ``on_set``. + :ivar is_admin: Whether the writing controller has admin privileges. + """ + + characteristic: Characteristic | None + is_admin: bool diff --git a/pyhap/delegates.py b/pyhap/delegates.py new file mode 100644 index 00000000..41c5b9ee --- /dev/null +++ b/pyhap/delegates.py @@ -0,0 +1,103 @@ +"""The delegate interfaces of the callback interface. + +A delegate is an object a characteristic or service is wired up with, +:class:`CharacteristicDelegate` for one characteristic or several shared +ones, :class:`ServiceDelegate` for a whole service. Unlike the legacy +``setter_callback``/``getter_callback`` attributes it replaces over time, +a delegate receives a context (:class:`~pyhap.context.CharContext`) that carries what a +callback needs to know about the request - and grows new fields instead of +new callback signatures. + +Registration, on e.g. ``char.delegate = ...``, is when everything about a +delegate is decided once: the driver records which methods are overridden +and rejects an overridden method that is not a coroutine function. The +read and write paths never introspect a delegate again. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any + +from .context import CharReadContext, CharWriteContext + +if TYPE_CHECKING: + from .characteristic import Characteristic + + +class CharacteristicDelegate: + """Delegate of a characteristic's reads, writes and subscriptions. + + Subclass and override what you need; the noop defaults do nothing. + One instance may serve many characteristics: the context carries + ``characteristic`` and identifies which one a call is about. + + Every method is a coroutine function BY DESIGN. The driver awaits + every call - there is no sync/async detection on this path - and + enforces the context's deadline: a call that is not done by it is + cancelled and answers ``-70408``. Heavy synchronous work still + blocks the loop and the request; keep such work in a task or an + executor and await it. + + An unoverridden method means the characteristic has no hook for it; + an overridden one that returns ``None`` is an answer - e.g. a getter + that answers the null value of a Programmable Switch Event. A + callback that fails answers with its status instead of the generic + ``-70402`` when it raises :class:`~pyhap.errors.HapStatusError`. + """ + + async def on_get(self, context: CharReadContext) -> Any: + """Answer a read of the characteristic with its value. + + An unoverridden ``on_get`` reads the characteristic's stored + value. An override runs under the context's deadline and answers + the read; return the value, which must fit the characteristic's + format like any other value. + """ + + async def on_set(self, value: Any, context: CharWriteContext) -> Any: + """Apply a controller's write of ``value``. + + Runs after the write passed its gate hooks, under the context's + deadline. The value is already stored and subscribers are told + once this call is done. A return value is the write response for + a characteristic with the ``wr`` permission, else it is dropped; + raise :class:`~pyhap.errors.HapStatusError` to answer a status. + """ + + async def on_subscribe(self, context: CharReadContext) -> None: + """A controller subscribed to the characteristic's events. + + Use this to start work only while someone listens, e.g. polling + a sensor that costs battery. Failures are logged and isolated; + the subscription itself always registers. + """ + + async def on_unsubscribe(self, context: CharReadContext) -> None: + """A controller unsubscribed from the characteristic's events. + + This also runs when its connection closes while subscribed. + Failures are logged and isolated. + """ + + +class ServiceDelegate: + """Delegate of a whole service. + + The service's ``on_set`` is the gate of its grouped writes: a request + that writes several characteristics of the service runs it before + any per-characteristic ``on_set``, with every value of the request - + so a thermostat sees target mode and target temperature together. + Raise :class:`~pyhap.errors.HapStatusError` to refuse the whole + request before anything is applied: values are restored, subscribers + are told nothing, and per-characteristic ``on_set`` calls do not run. + """ + + async def on_set( + self, values: Mapping[Characteristic, Any], context: CharWriteContext + ) -> None: + """Veto or accept a grouped write before it is applied. + + :param values: Every written value of the request, by + :class:`~pyhap.characteristic.Characteristic`. + """ diff --git a/pyhap/errors.py b/pyhap/errors.py new file mode 100644 index 00000000..0b3ba480 --- /dev/null +++ b/pyhap/errors.py @@ -0,0 +1,74 @@ +"""Errors raised by accessory callbacks to answer a HAP request.""" + +from .const import HAP_SERVER_STATUS + +# The statuses a callback may answer with, as HAP status codes. The rest are +# reserved for the accessory driver: privileges and read-only, write-only and +# notification permissions are enforced before any callback runs, and a timed +# out callback is answered by the driver's watchdog. Constructing a +# HapStatusError with anything else fails fast with a ValueError, so a wrong +# status is caught at its source instead of silently altering a request. +CALLBACK_STATUSES = frozenset( + { + HAP_SERVER_STATUS.SERVICE_COMMUNICATION_FAILURE, + HAP_SERVER_STATUS.RESOURCE_BUSY, + HAP_SERVER_STATUS.OUT_OF_RESOURCE, + HAP_SERVER_STATUS.RESOURCE_DOES_NOT_EXIST, + HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST, + HAP_SERVER_STATUS.INSUFFICIENT_AUTHORIZATION, + } +) + + +class HapStatusError(Exception): + """Raise from a callback to answer its request with a HAP status. + + A characteristic's setter or getter callback may raise this instead of + failing generically, and the controller receives the status for that + characteristic in the HAP response - so the Home app can tell a refusal + apart from a communication failure. Every other exception of a callback + keeps answering ``-70402`` (service communication failure). + + For example, the setter of a lock whose motor is busy:: + + 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) + + A callback may answer with one of these statuses: + + * ``-70402`` service communication failure - the default for any other + exception, raise it only to be explicit. + * ``-70403`` resource busy - the device refuses the request, try again. + * ``-70407`` out of resource - e.g. no free streaming session. + * ``-70409`` resource does not exist - e.g. an unknown session id. + * ``-70410`` invalid value in a request - a value that passed the + format checks but is semantically wrong. + * ``-70411`` insufficient authorization - the accessory refuses this + controller. + + The other HAP statuses are reserved for the accessory driver and cannot + be raised: privileges and permissions are enforced before a callback + runs, and a callback that is too slow is answered by the driver itself. + + :param status: One of the statuses above, as a + :class:`~pyhap.const.HAP_SERVER_STATUS` value. + :raises ValueError: If ``status`` is reserved for the driver or unknown. + """ + + __slots__ = ("status",) + + def __init__(self, status: int) -> None: + """Initialise with the HAP status to answer with.""" + if status not in CALLBACK_STATUSES: + raise ValueError( + f"HAP status {status} is reserved for the accessory driver or" + " unknown; a callback may answer with one of:" + f" {', '.join(str(code) for code in sorted(CALLBACK_STATUSES))}" + ) + super().__init__(f"HAP status {status}") + self.status = status diff --git a/tests/test_connection.py b/tests/test_connection.py new file mode 100644 index 00000000..9d8aa02e --- /dev/null +++ b/tests/test_connection.py @@ -0,0 +1,49 @@ +"""Tests for the connection handle.""" + +from pyhap.connection import HAPConnectionHandle + + +def test_same_connection_is_equal() -> None: + """Two handles of one connection, e.g. of a read and a write, are equal.""" + handle = HAPConnectionHandle(("1.2.3.4", 5), 3) + same = HAPConnectionHandle(("1.2.3.4", 5), 3) + + assert handle == same + assert hash(handle) == hash(same) + + +def test_reused_address_is_not_equal() -> None: + """A later connection reusing the address is a different identity.""" + handle = HAPConnectionHandle(("1.2.3.4", 5), 3) + reused = HAPConnectionHandle(("1.2.3.4", 5), 4) + other = HAPConnectionHandle(("1.2.3.4", 6), 3) + + assert handle != reused + assert handle != other + assert handle != ("1.2.3.4", 5) + assert not (handle == ("1.2.3.4", 5)) + + +def test_equal_handles_hash_equal() -> None: + """Equal handles hash equal, so they key a dict or set together.""" + handles = {HAPConnectionHandle(("1.2.3.4", 5), 3): "connection"} + assert handles[HAPConnectionHandle(("1.2.3.4", 5), 3)] == "connection" + assert HAPConnectionHandle(("1.2.3.4", 5), 4) not in handles + + +def test_exposes_client_addr() -> None: + """The handle's interface is the connection's address.""" + handle = HAPConnectionHandle(("1.2.3.4", 5), 7) + + assert handle.client_addr == ("1.2.3.4", 5) + assert not hasattr(handle, "generation") + assert "1.2.3.4" in repr(handle) + + +def test_unconnected_handle() -> None: + """A handle without a connection, e.g. of a direct call, is still a key.""" + handle = HAPConnectionHandle(None, 0) + + assert handle.client_addr is None + assert handle == HAPConnectionHandle(None, 0) + assert handle != HAPConnectionHandle(None, 1) diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 00000000..5992d6b8 --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,108 @@ +"""Tests for the callback contexts.""" + +from dataclasses import FrozenInstanceError +from uuid import UUID, uuid1, uuid4 + +import pytest + +from pyhap.characteristic import ( + HAP_FORMAT_INT, + HAP_PERMISSION_READ, + PROP_FORMAT, + PROP_PERMISSIONS, + Characteristic, +) +from pyhap.connection import HAPConnectionHandle +from pyhap.const import HAP_PERMISSION_WRITE +from pyhap.context import CharContext, CharReadContext, CharWriteContext + + +def _char() -> Characteristic: + return Characteristic( + "Value", + uuid1(), + { + PROP_FORMAT: HAP_FORMAT_INT, + PROP_PERMISSIONS: [HAP_PERMISSION_READ, HAP_PERMISSION_WRITE], + }, + ) + + +def _handle() -> HAPConnectionHandle: + return HAPConnectionHandle(("1.2.3.4", 5), 3) + + +def test_context_fields() -> None: + """The context carries what a callback knows about the controller.""" + client_uuid = uuid4() + context = CharContext( + client_addr=("1.2.3.4", 5), + client_uuid=client_uuid, + connection=_handle(), + deadline=123.5, + ) + + assert context.client_addr == ("1.2.3.4", 5) + assert context.client_uuid == client_uuid + assert context.connection.client_addr == ("1.2.3.4", 5) + assert context.deadline == 123.5 + + +def test_contexts_are_frozen() -> None: + """A callback cannot change its context.""" + context = CharReadContext( + client_addr=("1.2.3.4", 5), + client_uuid=None, + connection=_handle(), + deadline=1.0, + characteristic=_char(), + ) + + with pytest.raises(FrozenInstanceError): + context.deadline = 2.0 # ty: ignore[invalid-assignment] + + +def test_write_context_groups_and_admin() -> None: + """A grouped write carries no characteristic, and the admin flag.""" + context = CharWriteContext( + client_addr=("1.2.3.4", 5), + client_uuid=None, + connection=_handle(), + deadline=1.0, + characteristic=None, + is_admin=False, + ) + + assert context.characteristic is None + assert context.is_admin is False + + +def test_write_context_of_one_characteristic() -> None: + """A per-characteristic write carries its characteristic.""" + characteristic = _char() + context = CharWriteContext( + client_addr=("1.2.3.4", 5), + client_uuid=None, + connection=_handle(), + deadline=1.0, + characteristic=characteristic, + is_admin=True, + ) + + assert context.characteristic is characteristic + assert context.is_admin is True + + +def test_read_context_carries_the_characteristic() -> None: + """A read carries the characteristic it is about.""" + characteristic = _char() + context = CharReadContext( + client_addr=("1.2.3.4", 5), + client_uuid=UUID(int=0), + connection=_handle(), + deadline=1.0, + characteristic=characteristic, + ) + + assert context.characteristic is characteristic + assert not hasattr(context, "is_admin") diff --git a/tests/test_delegates.py b/tests/test_delegates.py new file mode 100644 index 00000000..3dc1c66d --- /dev/null +++ b/tests/test_delegates.py @@ -0,0 +1,104 @@ +"""Tests for the delegate base classes.""" + +import asyncio +from collections.abc import Awaitable, Mapping +import inspect +from typing import Any +from uuid import uuid1 + +from pyhap.characteristic import ( + HAP_FORMAT_INT, + HAP_PERMISSION_READ, + PROP_FORMAT, + PROP_PERMISSIONS, + Characteristic, +) +from pyhap.connection import HAPConnectionHandle +from pyhap.const import HAP_PERMISSION_WRITE +from pyhap.context import CharReadContext, CharWriteContext +from pyhap.delegates import CharacteristicDelegate, ServiceDelegate + + +def _char() -> Characteristic: + return Characteristic( + "Value", + uuid1(), + { + PROP_FORMAT: HAP_FORMAT_INT, + PROP_PERMISSIONS: [HAP_PERMISSION_READ, HAP_PERMISSION_WRITE], + }, + ) + + +def _read_context() -> CharReadContext: + return CharReadContext( + client_addr=("1.2.3.4", 5), + client_uuid=None, + connection=HAPConnectionHandle(("1.2.3.4", 5), 3), + deadline=1.0, + characteristic=_char(), + ) + + +def _write_context() -> CharWriteContext: + return CharWriteContext( + client_addr=("1.2.3.4", 5), + client_uuid=None, + connection=HAPConnectionHandle(("1.2.3.4", 5), 3), + deadline=1.0, + characteristic=None, + is_admin=True, + ) + + +async def _run(coro: Awaitable[object]) -> object: + return await coro + + +def test_noop_defaults_are_coroutine_functions() -> None: + """The noop defaults are awaited by the driver, so they are coroutines.""" + for delegate, method in ( + (CharacteristicDelegate, "on_get"), + (CharacteristicDelegate, "on_set"), + (CharacteristicDelegate, "on_subscribe"), + (CharacteristicDelegate, "on_unsubscribe"), + (ServiceDelegate, "on_set"), + ): + assert inspect.iscoroutinefunction(getattr(delegate, method)), method + + +async def test_noop_defaults_answer_none() -> None: + """An unoverridden delegate does nothing and answers None.""" + delegate = CharacteristicDelegate() + + assert await _run(delegate.on_get(_read_context())) is None + assert await _run(delegate.on_set(1, _write_context())) is None + assert await _run(delegate.on_subscribe(_read_context())) is None + assert await _run(delegate.on_unsubscribe(_read_context())) is None + service = ServiceDelegate() + assert await _run(service.on_set({}, _write_context())) is None + + +async def test_overrides_answer_values() -> None: + """A subclass overrides what it needs and awaits like the driver does.""" + value = object() + + class Getter(CharacteristicDelegate): + async def on_get(self, context: CharReadContext) -> object: + return value + + assert await _run(Getter().on_get(_read_context())) is value + + +async def test_an_override_may_await_inside() -> None: + """A delegate may await inside its methods, like a real device call.""" + done = asyncio.Event() + + class Setter(ServiceDelegate): + async def on_set( + self, values: Mapping[Characteristic, Any], context: CharWriteContext + ) -> None: + await done.wait() + + done.set() + assert await _run(Setter().on_set({}, _write_context())) is None diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 00000000..23b85b4e --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,59 @@ +"""Tests for the callback error protocol.""" + +import pytest + +from pyhap.const import HAP_SERVER_STATUS +from pyhap.errors import CALLBACK_STATUSES, HapStatusError + + +def test_allowed_status() -> None: + """Each callback status is kept as the status to answer with.""" + for status in CALLBACK_STATUSES: + error = HapStatusError(status) + assert error.status == status + assert str(status) in str(error) + + +def test_resource_busy_is_allowed() -> None: + """The common refusal status constructs and keeps its value.""" + error = HapStatusError(HAP_SERVER_STATUS.RESOURCE_BUSY) + assert error.status == HAP_SERVER_STATUS.RESOURCE_BUSY + + +def test_callback_statuses_are_curated() -> None: + """The curated set is exactly the codes a callback may decide.""" + assert CALLBACK_STATUSES == frozenset( + { + HAP_SERVER_STATUS.SERVICE_COMMUNICATION_FAILURE, + HAP_SERVER_STATUS.RESOURCE_BUSY, + HAP_SERVER_STATUS.OUT_OF_RESOURCE, + HAP_SERVER_STATUS.RESOURCE_DOES_NOT_EXIST, + HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST, + HAP_SERVER_STATUS.INSUFFICIENT_AUTHORIZATION, + } + ) + + +@pytest.mark.parametrize( + "status", + [ + HAP_SERVER_STATUS.INSUFFICIENT_PRIVILEGES, + HAP_SERVER_STATUS.READ_ONLY_CHARACTERISTIC, + HAP_SERVER_STATUS.WRITE_ONLY_CHARACTERISTIC, + HAP_SERVER_STATUS.NOTIFICATION_NOT_SUPPORTED, + HAP_SERVER_STATUS.OPERATION_TIMED_OUT, + HAP_SERVER_STATUS.SUCCESS, + 42, + ], +) +def test_reserved_status_raises_value_error(status: int) -> None: + """A driver-reserved or unknown status fails at construction.""" + with pytest.raises(ValueError, match="reserved for the accessory driver"): + HapStatusError(status) + + +def test_is_an_exception() -> None: + """The error can be raised and caught as an exception.""" + with pytest.raises(HapStatusError) as raised: + raise HapStatusError(HAP_SERVER_STATUS.OUT_OF_RESOURCE) + assert raised.value.status == HAP_SERVER_STATUS.OUT_OF_RESOURCE