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
22 changes: 22 additions & 0 deletions docs/source/api/connection.rst
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.
20 changes: 20 additions & 0 deletions docs/source/api/context.rst
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:
28 changes: 28 additions & 0 deletions docs/source/api/delegates.rst
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.
23 changes: 23 additions & 0 deletions docs/source/api/errors.rst
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.
76 changes: 76 additions & 0 deletions pyhap/connection.py
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:

Copy link
Copy Markdown
Owner Author

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?

Copy link
Copy Markdown
Owner Author

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: None only for a call without a connection — a programmatic write/read through the legacy facade, e.g. tests calling client_update_value directly. A controller's request always has an address.

"""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})"
61 changes: 61 additions & 0 deletions pyhap/context.py
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
103 changes: 103 additions & 0 deletions pyhap/delegates.py
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`.
"""
Loading
Loading