Repository navigation
Add the delegate, context and connection APIs of the callback interface #562
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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})" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`. | ||
| """ |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When can this be None?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Documented in the constructor docstring now:
Noneonly for a call without a connection — a programmatic write/read through the legacy facade, e.g. tests callingclient_update_valuedirectly. A controller's request always has an address.