diff --git a/majordom_integration_sdk/__init__.py b/majordom_integration_sdk/__init__.py index f040553..cd189a3 100644 --- a/majordom_integration_sdk/__init__.py +++ b/majordom_integration_sdk/__init__.py @@ -21,7 +21,7 @@ from importlib.metadata import version from majordom_integration_sdk.controller import AbstractController, ControllerOutput, IntegrationError -from majordom_integration_sdk.errors import MissingCredentialsError +from majordom_integration_sdk.errors import DeviceIdTakenError, DeviceUnreachableError, MissingCredentialsError from majordom_integration_sdk.peripherals import ( STABLE_RUN, ComposeService, @@ -65,6 +65,8 @@ "Event", "DeviceParameterChange", "MissingCredentialsError", + "DeviceIdTakenError", + "DeviceUnreachableError", "Need", "Endpoint", "Peripheral", diff --git a/majordom_integration_sdk/controller/abstract_controller.py b/majordom_integration_sdk/controller/abstract_controller.py index 8cfb6a8..c89f6e3 100644 --- a/majordom_integration_sdk/controller/abstract_controller.py +++ b/majordom_integration_sdk/controller/abstract_controller.py @@ -18,7 +18,7 @@ from majordom_integration_sdk.peripherals import Need, Peripheral, PeripheralChange from majordom_integration_sdk.platform import Platform from majordom_integration_sdk.schemas.command import DeviceCommand -from majordom_integration_sdk.schemas.device import Device, Discovery, Parameter, ProvidedCredentials +from majordom_integration_sdk.schemas.device import Device, DeviceState, Discovery, Parameter, ProvidedCredentials _NAME_HELP = ( "The integration name is auto-derived from your controller class name; set a class-level " @@ -39,9 +39,12 @@ def _titleize(class_name: str) -> str: return " ".join(words) or base -class AbstractController[TDevice: Device, TParameter: Parameter, TSettings: BaseModel = Any]( - Peripheral[TSettings], ABC -): +class AbstractController[ + TDevice: Device, + TParameter: Parameter, + TSettings: BaseModel = Any, + TDeviceState: DeviceState = DeviceState, +](Peripheral[TSettings], ABC): """ Base class for all integration controllers. Defines the interface the Hub uses to interact with an integration — its package's ``peripherals(platform)`` builds the subclass, the runtime starts and stops it, and the Hub drives it by calling these methods. @@ -62,7 +65,9 @@ def peripherals(platform: Platform) -> list[Peripheral]: - ``self.output``: what it reports (``controller_did_…``: discoveries, devices, events, errors, notifications, what it provides), which the runtime relays to the Hub; it logs them in library mode until you give yours; - ``self.settings``: its validated ``Settings``, declared as a nested ``class Settings(BaseModel)`` named as the - last type parameter, ``AbstractController[Device, Parameter, "HueController.Settings"]``; `NoSettings` without; + third type parameter, ``AbstractController[Device, Parameter, "HueController.Settings"]``; `NoSettings` without; + - its device-state model, the fourth type parameter (`DeviceState` by default), returned by ``device_state_type``: + what ``pair_device`` is given as its draft and returns; - ``self.peripherals``: the endpoints its needs are bound to (``endpoint()``, ``endpoints()``, ``instance()``). In library mode, with no runtime, give it any settings, hand the bound needs over yourself and, to hear what it @@ -161,6 +166,11 @@ def device_type(self) -> type[TDevice]: """Override to return a Device subclass. Hub will deserialize devices into this type before passing them to other controller methods.""" return cast(type[TDevice], Device) + @property + def device_state_type(self) -> type[TDeviceState]: + """Override to return a DeviceState subclass. Hub will validate the pairing draft into this type before passing it to ``pair_device``.""" + return cast(type[TDeviceState], DeviceState) + @property def parameter_type(self) -> type[TParameter]: """Override to return a Parameter subclass. Hub will deserialize parameters into this type before passing them to other controller methods.""" @@ -198,15 +208,54 @@ async def start_pairing_window(self, duration_sec: int, credentials: ProvidedCre pass @abstractmethod - async def pair_device(self, discovery: Discovery, credentials: ProvidedCredentials | None): + async def pair_device( + self, discovery: Discovery, draft: TDeviceState, credentials: ProvidedCredentials | None + ) -> TDeviceState: """ - Pairs a discovered device to the Hub. Called when the user initiates pairing from the UI. + Pairs a discovered device to the Hub and returns ``draft`` filled in. Called when the user initiates pairing + from the UI. + + The Hub builds ``draft`` (no record exists yet): the user's fields (name, room, icon, category, note), ``id`` + the Hub's own (read-only: never change it), ``physical_id`` the discovery's id as a placeholder, ``integration`` + this controller's key. When the user picked an existing record to pair onto, it carries that record's data, + its ``id``, ``physical_id`` and ``integration_data`` included: decide what in it is stale. + + Fill in what the device is: ``parameters``, ``integration_data``, its ``physical_id`` (``device_uuid``, what + you look the device up by from now on), ``available``, … and return it. The Hub then saves it to its database, + reports it connected if you set it ``available``, and calls ``fetch``. So: + + - don't use the device repository: nothing is saved during pairing; + - report nothing about this device (no ``controller_did_connect_device``, no events) until the Hub saved it; + - once the ``physical_id`` is known, call ``check_device_id`` before anything irreversible; + - keep what the device needs from now on (a key just set on it) in ``integration_data``, filled in *before* + each irreversible step: it is saved with the device, so no storage of your own is needed. + + On any failure but a refused id, and when the Hub fails to save what you return, it calls ``unpair(draft)`` + with the draft as far as you filled it, to roll back. + + Idempotent, like ``fetch``: called again after a failed or rolled-back attempt, bring the device to a + known-good paired state from whatever the last one left. Check each step and redo only what is missing or + stale (a key already set is checked, not set again), so a device found already paired is confirmed and + returned, not refused. + + Raise when it can't pair (`MissingCredentialsError` before touching the device when credentials are lacking). """ @abstractmethod async def unpair(self, device: TDevice): """ Unpairs a device from the Hub. Called when the user initiates removal from the UI. + + Also the rollback of a failed pairing: the Hub calls it with the draft as ``pair_device`` left it, at any + stage. Undo, in reverse order of pairing, only what would block a clean re-pair once the draft is lost (a key + set on the device, a pairing the accessory holds); what stays harmless (a node left on a fabric or network, + offered again as a discovery) needs no undo. Tolerate any partial state, never wait for the user, and go as + far as you can: it is best effort, within the Hub's timeout. + + Calling it again must work: on a device already unpaired, or never fully paired, it does nothing more. + + Raise `DeviceUnreachableError` when the device can't be reached to undo its pairing, after forgetting your own + state of it. """ @abstractmethod @@ -219,8 +268,12 @@ async def identify(self, device: TDevice): @abstractmethod async def fetch(self, device: TDevice): """ - Fetches and refreshes the current state of the device and all its parameters. - Called by the Hub when an up-to-date snapshot is needed. + Brings the device to a known-good state; called after pairing, on a manual refresh, and to revive a device + that is unavailable. Idempotent: + + - check the device answers and read its current values, reported through the normal events; + - check its event subscription and subscribe again only when it is gone, never a second one; + - finish in bounded time; a sleeping battery device that doesn't answer is not a failure. """ @abstractmethod @@ -256,14 +309,25 @@ def integration_uuid(self) -> UUID: return uuid5(self.namespace_uuid(), self.key) @final - def device_uuid(self, device_id: str) -> UUID: - """uuid of the device based on an arbitrary string identifier""" - return uuid5(self.integration_uuid(), device_id) + def device_uuid(self, raw: str | bytes, strip: str = ":-") -> UUID: + """The ``physical_id`` of a device from what is unique to it (MAC, serial number), seeded by the integration: + a name or an address two devices share gives them the same id, and the second can't be paired. A string is + normalised (lowercased, whitespace and every char in ``strip`` dropped) so "AA:BB-cc" and "aabbcc" agree; + bytes (a raw MAC or UUID) are taken as they are.""" + return uuid5(self.integration_uuid(), _normalised(raw, strip)) @final - def parameter_uuid(self, device_uuid: UUID, parameter_id: str) -> UUID: - """uuid of the parameter based on an arbitrary string identifier""" - return uuid5(device_uuid, parameter_id) + async def check_device_id(self, physical_id: UUID, draft: TDeviceState): + """Raises `DeviceIdTakenError` when another record holds ``physical_id``, the one ``pair_device`` gives the + device ``draft`` pairs. The record the user picked to pair onto (``draft.id``, its Hub id) is not another. + Optional: call it in ``pair_device`` as soon as the physical id is known, before anything irreversible.""" + await self.platform.check_device_id(physical_id, draft) + + @final + def parameter_uuid(self, physical_id: UUID, raw: str | bytes, strip: str = ":-") -> UUID: + """The id of a parameter from an identifier unique within its device, seeded by the device's ``physical_id`` + (stable and known from protocol data alone, unlike the Hub's ``id``); normalised as in ``device_uuid``.""" + return uuid5(physical_id, _normalised(raw, strip)) # def add_service_coroutine(self, coroutine: Callable[[], Awaitable]): ... # FUTURE @@ -272,3 +336,9 @@ def parameter_uuid(self, device_uuid: UUID, parameter_id: str) -> UUID: # def schedule_task(self, callback: Callable[[], Awaitable] | Callable[[], Any], interval: float): ... # FUTURE # def search_native_parameter(self, name: str) -> Parameter | None: ... # FUTURE + + +def _normalised(raw: str | bytes, strip: str) -> str | bytes: + if isinstance(raw, bytes): + return raw + return "".join(char for char in raw.lower() if not char.isspace() and char not in strip) diff --git a/majordom_integration_sdk/controller/output.py b/majordom_integration_sdk/controller/output.py index fa0a223..46f68dc 100644 --- a/majordom_integration_sdk/controller/output.py +++ b/majordom_integration_sdk/controller/output.py @@ -5,7 +5,7 @@ import logging from collections.abc import Iterable from dataclasses import dataclass -from typing import Protocol +from typing import Any, Protocol, overload from uuid import UUID from majordom_integration_sdk.schemas.device import Discovery @@ -34,21 +34,37 @@ class ControllerOutput(Protocol): every call (`LoggingControllerOutput`); give it yours: ``controller.output = MyOutput()``. """ - async def controller_did_receive_discovery(self, controller: AbstractController, discovery: Discovery): ... - async def controller_did_update_discovery(self, controller: AbstractController, discovery: Discovery): ... - async def controller_did_lose_discovery(self, controller: AbstractController, discovery_id: UUID): ... - - async def controller_did_connect_device(self, controller: AbstractController, device_id: UUID): ... + async def controller_did_receive_discovery(self, controller: AnyController, discovery: Discovery): ... + async def controller_did_update_discovery(self, controller: AnyController, discovery: Discovery): ... + async def controller_did_lose_discovery(self, controller: AnyController, discovery_id: UUID): ... + + @overload + async def controller_did_connect_device(self, controller: AnyController, *, physical_id: UUID): ... + @overload + async def controller_did_connect_device(self, controller: AnyController, *, device_id: UUID): ... + async def controller_did_connect_device( + self, controller: AnyController, *, physical_id: UUID | None = None, device_id: UUID | None = None + ): + """A paired device connected. Integrations pass its ``physical_id``; its ``device_id`` is accepted too, e.g. + from a relay passing it on under the Hub's id.""" + ... - async def controller_did_lose_device(self, controller: AbstractController, device_id: UUID): - """Paired device became unreachable while the Hub is running (not just at startup/reboot).""" + @overload + async def controller_did_lose_device(self, controller: AnyController, *, physical_id: UUID): ... + @overload + async def controller_did_lose_device(self, controller: AnyController, *, device_id: UUID): ... + async def controller_did_lose_device( + self, controller: AnyController, *, physical_id: UUID | None = None, device_id: UUID | None = None + ): + """Paired device became unreachable while the Hub is running (not just at startup/reboot). Ids as in + ``controller_did_connect_device``.""" ... - async def controller_did_receive_events(self, controller: AbstractController, events: Iterable[Event]): + async def controller_did_receive_events(self, controller: AnyController, events: Iterable[Event]): """Report device-domain events (e.g. DeviceParameterChange) observed by the controller.""" ... - async def controller_did_encounter_error(self, controller: AbstractController, error: IntegrationError): + async def controller_did_encounter_error(self, controller: AnyController, error: IntegrationError): """A user-facing problem the integration hit — surfaced to the user, not the developer. Use this for things the user should know about or can act on (a wrong setup, an unplugged @@ -68,11 +84,11 @@ async def controller_did_encounter_error(self, controller: AbstractController, e """ ... - async def controller_did_resolve_error(self, controller: AbstractController, error_id: str): + async def controller_did_resolve_error(self, controller: AnyController, error_id: str): """The problem reported with ``error_id`` is gone. Resolving an id that isn't active does nothing.""" ... - async def controller_did_emit_notification(self, controller: AbstractController, notification: Notification): + async def controller_did_emit_notification(self, controller: AnyController, notification: Notification): """Surface a general user-facing notification — a floating tile in the app. Unlike ``controller_did_encounter_error`` (specifically for failures/health), this is for anything informational or advisory: a firmware update is available, a device needs a physical action, @@ -81,7 +97,7 @@ async def controller_did_emit_notification(self, controller: AbstractController, """ ... - async def controller_did_change_provided(self, controller: AbstractController): + async def controller_did_change_provided(self, controller: AnyController): """What it ``provides`` changed on its own, e.g. ESPHome's Bluetooth proxies as nodes come and go: the runtime reads it again. The runtime's output takes this one itself: the Hub's needn't implement it.""" return @@ -90,38 +106,45 @@ async def controller_did_change_provided(self, controller: AbstractController): class LoggingControllerOutput(ControllerOutput): """A ``ControllerOutput`` that logs every callback: the Hub's stand-in for dev runs and library mode.""" - async def controller_did_receive_discovery(self, controller: AbstractController, discovery: Discovery): + async def controller_did_receive_discovery(self, controller: AnyController, discovery: Discovery): logger.info(f"discovered {discovery.device_name} ({discovery.transport}) id={discovery.id}") - async def controller_did_update_discovery(self, controller: AbstractController, discovery: Discovery): + async def controller_did_update_discovery(self, controller: AnyController, discovery: Discovery): logger.info(f"updated discovery {discovery.id}") - async def controller_did_lose_discovery(self, controller: AbstractController, discovery_id: UUID): + async def controller_did_lose_discovery(self, controller: AnyController, discovery_id: UUID): logger.info(f"lost discovery {discovery_id}") - async def controller_did_connect_device(self, controller: AbstractController, device_id: UUID): - logger.info(f"connected device {device_id}") + async def controller_did_connect_device( + self, controller: AnyController, *, physical_id: UUID | None = None, device_id: UUID | None = None + ): + logger.info(f"connected device {physical_id or device_id}") - async def controller_did_lose_device(self, controller: AbstractController, device_id: UUID): - logger.info(f"lost device {device_id}") + async def controller_did_lose_device( + self, controller: AnyController, *, physical_id: UUID | None = None, device_id: UUID | None = None + ): + logger.info(f"lost device {physical_id or device_id}") - async def controller_did_receive_events(self, controller: AbstractController, events: Iterable[Event]): + async def controller_did_receive_events(self, controller: AnyController, events: Iterable[Event]): for event in events: logger.info(f"event {type(event).__name__}: {event!r}") - async def controller_did_encounter_error(self, controller: AbstractController, error: IntegrationError): + async def controller_did_encounter_error(self, controller: AnyController, error: IntegrationError): stopped = "" if error.is_still_running else ", it can't go on" logger.error(f"integration error {error.id}{stopped}: {error.message}") - async def controller_did_resolve_error(self, controller: AbstractController, error_id: str): + async def controller_did_resolve_error(self, controller: AnyController, error_id: str): logger.info(f"integration error {error_id} resolved") - async def controller_did_emit_notification(self, controller: AbstractController, notification: Notification): + async def controller_did_emit_notification(self, controller: AnyController, notification: Notification): logger.info(f"notification [{notification.type.value}/{notification.priority.value}]: {notification.message}") - async def controller_did_change_provided(self, controller: AbstractController): + async def controller_did_change_provided(self, controller: AnyController): logger.info(f"{controller.key} changed what it provides") # Last, once the names the controller takes from here exist: the annotations above then evaluate from majordom_integration_sdk.controller.abstract_controller import AbstractController # noqa: E402 + +# any integration: its type parameters are invariant, so the bare generic would accept only the defaults +type AnyController = AbstractController[Any, Any, Any, Any] diff --git a/majordom_integration_sdk/dev/__init__.py b/majordom_integration_sdk/dev/__init__.py index 5483cee..9d08265 100644 --- a/majordom_integration_sdk/dev/__init__.py +++ b/majordom_integration_sdk/dev/__init__.py @@ -34,6 +34,7 @@ from majordom_integration_sdk.repository.sqlite import SqliteDeviceRepository if TYPE_CHECKING: + from majordom_integration_sdk.controller.output import AnyController from majordom_integration_sdk.runtime import PeripheralStatus, Runtime logger = logging.getLogger("majordom_integration_sdk.dev") @@ -101,7 +102,7 @@ class Runner: """Runs one controller standalone: under ``runtime``, which starts it once its needs are bound and hands moved peripherals, settings and platform facts over as under the Hub; or, without, with the needs bound by hand.""" - def __init__(self, controller: AbstractController, runtime: Runtime | None = None): + def __init__(self, controller: AnyController, runtime: Runtime | None = None): self.controller, self.runtime = controller, runtime self._is_started = False diff --git a/majordom_integration_sdk/dev/cli.py b/majordom_integration_sdk/dev/cli.py index ea8a948..d9e6995 100644 --- a/majordom_integration_sdk/dev/cli.py +++ b/majordom_integration_sdk/dev/cli.py @@ -29,12 +29,11 @@ from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING, Any -from uuid import UUID +from uuid import UUID, uuid4, uuid5 import rich from typer_shell import make_typer_shell -from ..controller.abstract_controller import AbstractController from ..controller.output import ControllerOutput from ..peripherals import Bound, Endpoint, Peripheral from ..schemas.command import DeviceCommand @@ -44,7 +43,11 @@ from . import Integration, _runner, build_platform if TYPE_CHECKING: + from majordom_integration_sdk.controller.output import AnyController + + from ..repository.protocol import DeviceRepositoryProtocol from ..runtime import Pinned, Runtime + from ..schemas.device import Device logger = logging.getLogger("majordom_integration_sdk.dev") @@ -98,6 +101,9 @@ def dump(self): builtins.print(self.format(record), file=sys.stderr, flush=True) +_CLI_ROOM = uuid5(UUID(int=0), "cli") # the dev runner has no rooms: every device it pairs is in this one + + # --- controller-driving core (testable without the REPL) --------------------------------------- @@ -107,7 +113,7 @@ class _CliSession: resolution (``repo.get`` + ``get_parameter_state`` + ``model_validate`` into the controller's ``device_type`` / ``parameter_type``).""" - def __init__(self, controller: AbstractController, runtime: Runtime | None = None): + def __init__(self, controller: AnyController, runtime: Runtime | None = None): self._controller = controller self._runtime = runtime @@ -120,7 +126,28 @@ async def pair(self, discovery_id: UUID, credentials_type: str = "none", credent if not discovery: return None credentials = ProvidedCredentials(type=CredentialsType(credentials_type), value=credentials_value or None) - return await self._controller.pair_device(discovery, credentials) + draft = self._controller.device_state_type( + id=uuid4(), # the Hub's own, which the integration never sees in lookups + physical_id=discovery.id, + name=discovery.device_name, + category=discovery.device_category, + icon=discovery.device_icon, + room_id=_CLI_ROOM, + transport=discovery.transport, + integration=self._controller.key, + manufacturer=discovery.device_manufacturer, + parameters=[], + ) + device = await self._controller.pair_device(discovery, draft, credentials) + async with self._controller.platform.device_repository(self._controller) as repo: + await repo.save(device) + # as the Hub does once it saved the device + if device.available: + await self._controller.output.controller_did_connect_device( + self._controller, physical_id=device.physical_id + ) + await self._controller.fetch(device) + return device.physical_id async def pair_window(self, seconds: int): await self._controller.start_pairing_window(seconds) @@ -129,42 +156,42 @@ async def list_devices(self): async with self._controller.platform.device_repository(self._controller) as repo: return await repo.get_all() - async def device_state(self, device_id: UUID): + async def device_state(self, physical_id: UUID): async with self._controller.platform.device_repository(self._controller) as repo: - return await repo.state(device_id) + return await repo.state(physical_id=physical_id) - async def _device(self, device_id: UUID): + async def _device(self, physical_id: UUID): async with self._controller.platform.device_repository(self._controller) as repo: - device = await repo.get(device_id) + device = await repo.get(physical_id=physical_id) return self._controller.device_type.model_validate(device) if device else None - async def identify(self, device_id: UUID) -> bool: - if not (device := await self._device(device_id)): + async def identify(self, physical_id: UUID) -> bool: + if not (device := await self._device(physical_id)): return False await self._controller.identify(device) return True - async def fetch(self, device_id: UUID) -> bool: - if not (device := await self._device(device_id)): + async def fetch(self, physical_id: UUID) -> bool: + if not (device := await self._device(physical_id)): return False await self._controller.fetch(device) return True - async def unpair(self, device_id: UUID) -> bool: - if not (device := await self._device(device_id)): + async def unpair(self, physical_id: UUID) -> bool: + if not (device := await self._device(physical_id)): return False await self._controller.unpair(device) return True - async def control(self, device_id: UUID, parameter_id: UUID, value: object) -> str: - command = DeviceCommand(device_id=device_id, parameter_id=parameter_id, value=value) + async def control(self, physical_id: UUID, parameter_id: UUID, value: object) -> str: async with self._controller.platform.device_repository(self._controller) as repo: - device = await repo.get(command.device_id) + device = await repo.get(physical_id=physical_id) if not device: - return f"No device {device_id}" - parameter = await repo.get_parameter_state(command.device_id, command.parameter_id) + return f"No device {physical_id}" + command = DeviceCommand(device_id=device.id, parameter_id=parameter_id, value=value) + parameter = await repo.get_parameter_state(physical_id=physical_id, parameter_id=parameter_id) if not parameter: - return f"No parameter {parameter_id} on device {device_id}" + return f"No parameter {parameter_id} on device {physical_id}" if parameter.role != ParameterRole.control: return f"Parameter {parameter_id} is not controllable (role={parameter.role.value})" await self._controller.send_command( @@ -234,18 +261,18 @@ async def controller_did_update_discovery(self, controller, discovery): async def controller_did_lose_discovery(self, controller, discovery_id): _print(f"[discovery lost] {discovery_id}") - async def controller_did_connect_device(self, controller, device_id): - _print(f"[connected] {device_id}") + async def controller_did_connect_device(self, controller, *, physical_id=None, device_id=None): + _print(f"[connected] {physical_id or device_id}") async with controller.platform.device_repository(controller) as repo: - if device := await repo.get(device_id): + if device := await _device(repo, physical_id, device_id): device.last_seen = datetime.now() device.available = True await repo.save(device) - async def controller_did_lose_device(self, controller, device_id): - _print(f"[lost] {device_id}") + async def controller_did_lose_device(self, controller, *, physical_id=None, device_id=None): + _print(f"[lost] {physical_id or device_id}") async with controller.platform.device_repository(controller) as repo: - if device := await repo.get(device_id): + if device := await _device(repo, physical_id, device_id): device.available = False device.last_error = f"Device is no longer connected to the {controller.name} network" await repo.save(device) @@ -256,12 +283,14 @@ async def controller_did_receive_events(self, controller, events): for event in events: if not isinstance(event, DeviceParameterChange): continue - if device := await repo.get(event.device_id): + if device := await repo.get(physical_id=event.physical_id): device.last_seen = datetime.now() await repo.save(device) - if state := await repo.get_parameter_state(event.device_id, event.parameter_id): + if state := await repo.get_parameter_state( + physical_id=event.physical_id, parameter_id=event.parameter_id + ): state.value = event.value - await repo.save_parameter_state(event.device_id, state) + await repo.save_parameter_state(physical_id=event.physical_id, parameter_state=state) for event in events: _print(f"[event] {type(event).__name__}: {event!r}") @@ -278,6 +307,12 @@ async def controller_did_emit_notification(self, controller, notification): # --- the REPL ---------------------------------------------------------------------------------- +async def _device(repo: DeviceRepositoryProtocol, physical_id: UUID | None, device_id: UUID | None) -> Device | None: + if physical_id: + return await repo.get(physical_id=physical_id) + return next((device for device in await repo.get_all() if device.id == device_id), None) + + async def run_cli( integration: Integration, *, @@ -359,12 +394,12 @@ def pair_window(seconds: int = 60): @app.command() def devices(): - """List paired devices (id, name, available).""" + """List paired devices (physical id, name, available).""" found = call(session.list_devices()) if not found: _print("(no paired devices)") for device in found: - _print(f"{device.id} {device.name} available={device.available}") + _print(f"{device.physical_id} {device.name} available={device.available}") @app.command() def device(device_id: str): diff --git a/majordom_integration_sdk/errors.py b/majordom_integration_sdk/errors.py index 7980e42..0662f3b 100644 --- a/majordom_integration_sdk/errors.py +++ b/majordom_integration_sdk/errors.py @@ -2,6 +2,7 @@ import re from typing import ClassVar +from uuid import UUID _TYPE = re.compile(r"[a-z][a-z0-9]*(?:_[a-z0-9]+)*") @@ -23,3 +24,30 @@ def __init__(self, type: str = WIFI): raise ValueError(f"Credentials type must be lower_snake_case, got {type!r}") self.type = type super().__init__(f"Missing credentials: {type}") + + +class DeviceIdTakenError(Exception): + """Another device record already holds ``physical_id``, the one a pairing would give its device. + + Raised by `AbstractController.check_device_id` before anything irreversible is done to the device; the Hub refuses + the pairing with it and does not roll it back (nothing ran). It carries the record found: its Hub ``device_id``, + ``name`` and whether it ``is_paired`` (an unpaired one is a record kept after its device was removed), so the app + can offer to pair onto that record instead. + """ + + def __init__(self, physical_id: UUID, device_id: UUID, name: str, is_paired: bool): + self.physical_id = physical_id + self.device_id = device_id + self.name = name + self.is_paired = is_paired + state = "paired" if is_paired else "unpaired" + super().__init__(f"Physical id {physical_id} is taken by {name!r} ({device_id}, {state})") + + +class DeviceUnreachableError(Exception): + """``unpair`` could not reach the device to undo its pairing, e.g. it is off or out of range. + + Raised after the controller forgot its own state of the device, so only the device keeps the pairing: the Hub + answers with it (``device_unreachable``), and the app offers to forget the device instead, which then needs a + manual factory reset to pair again. + """ diff --git a/majordom_integration_sdk/platform.py b/majordom_integration_sdk/platform.py index 75f948f..84b20b4 100644 --- a/majordom_integration_sdk/platform.py +++ b/majordom_integration_sdk/platform.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Awaitable, Callable from contextlib import AbstractAsyncContextManager from dataclasses import dataclass, field from pathlib import Path @@ -12,11 +12,13 @@ from majordom_integration_sdk.repository.memory import DeviceRepositoryMemory from majordom_integration_sdk.repository.protocol import DeviceRepositoryProtocol from majordom_integration_sdk.schemas.credentials import WifiCredentials +from majordom_integration_sdk.schemas.device import DeviceState if TYPE_CHECKING: from majordom_integration_sdk.peripherals import Peripheral type MakeDeviceRepository = Callable[[str], AbstractAsyncContextManager[DeviceRepositoryProtocol]] +type CheckDeviceId = Callable[[UUID, DeviceState], Awaitable[None]] def _in_memory() -> MakeDeviceRepository: @@ -24,6 +26,10 @@ def _in_memory() -> MakeDeviceRepository: return lambda key: DeviceRepositoryMemory(key, store).session() +async def _any_id_is_free(physical_id: UUID, draft: DeviceState): + pass + + @dataclass(frozen=True, kw_only=True) class Platform: """The host every peripheral runs on, the same object for all of them as ``self.platform``: a controller is given @@ -43,6 +49,9 @@ class Platform: documents_root: Path # One unit of work on the device repository, scoped to the devices of the peripheral keyed; in memory by default make_device_repository: MakeDeviceRepository = field(default_factory=_in_memory) + # Raises `DeviceIdTakenError` when a record other than the one ``draft`` pairs onto (``draft.id``) holds the + # physical id; the Hub's checks every record. Library mode has no other records to collide with: every id is free + check_device_id: CheckDeviceId = _any_id_is_free region: str | None = None # RF region for sub-GHz radios, a Z-Wave JS name: "Europe", "USA", … wifi: WifiCredentials | None = None # the network to provision Wi-Fi devices onto thread_dataset: str | None = None # active operational dataset of the hub's Thread network, hex TLVs diff --git a/majordom_integration_sdk/repository/memory.py b/majordom_integration_sdk/repository/memory.py index 6291e8b..d0ef400 100644 --- a/majordom_integration_sdk/repository/memory.py +++ b/majordom_integration_sdk/repository/memory.py @@ -35,23 +35,24 @@ async def session(self) -> AsyncGenerator[DeviceRepositoryMemory]: def _in_scope(self, record: Record) -> bool: return self.integration is None or record.get("integration") == self.integration - def _owned(self, device_id: UUID) -> Record | None: - record = self._store.get(device_id) - return record if record is not None and self._in_scope(record) else None + def _owned(self, physical_id: UUID) -> Record | None: + return next( + (r for r in self._store.values() if r.get("physical_id") == str(physical_id) and self._in_scope(r)), None + ) async def get_all[T: Device](self, as_: type[T] = Device) -> list[T]: return [as_.model_validate(r) for r in self._store.values() if self._in_scope(r)] - async def get[T: Device](self, device_id: UUID, as_: type[T] = Device) -> T | None: - record = self._owned(device_id) + async def get[T: Device](self, *, physical_id: UUID, as_: type[T] = Device) -> T | None: + record = self._owned(physical_id) return as_.model_validate(record) if record else None - async def state[T: DeviceState](self, device_id: UUID, as_: type[T] = DeviceState) -> T | None: - record = self._owned(device_id) + async def state[T: DeviceState](self, *, physical_id: UUID, as_: type[T] = DeviceState) -> T | None: + record = self._owned(physical_id) return as_.model_validate(record) if record else None - async def get_parameter_state(self, device_id: UUID, parameter_id: UUID) -> ParameterState | None: - record = self._owned(device_id) + async def get_parameter_state(self, *, physical_id: UUID, parameter_id: UUID) -> ParameterState | None: + record = self._owned(physical_id) if not record: return None return next( @@ -59,22 +60,27 @@ async def get_parameter_state(self, device_id: UUID, parameter_id: UUID) -> Para None, ) - async def save(self, device: Device | DeviceState, previous_id: UUID | None = None): + async def save(self, device: Device | DeviceState): if self.integration is not None and device.integration != self.integration: raise PermissionError(f"Device {device.id} is outside integration scope {self.integration!r}") - base = self._store.pop(previous_id, None) if previous_id is not None and previous_id != device.id else None - self._store[device.id] = merge(base if base is not None else self._store.get(device.id), device) + holder = next( + (i for i, r in self._store.items() if r.get("physical_id") == str(device.physical_id) and i != device.id), + None, + ) + if holder is not None: + raise ValueError(f"Physical id {device.physical_id} is held by device {holder}") + self._store[device.id] = merge(self._store.get(device.id), device) - async def save_parameter_state(self, device_id: UUID, parameter_state: ParameterState): - record = self._owned(device_id) + async def save_parameter_state(self, *, physical_id: UUID, parameter_state: ParameterState): + record = self._owned(physical_id) if not record: - raise KeyError(f"Unknown or out-of-scope device {device_id}") + raise KeyError(f"Unknown or out-of-scope device {physical_id}") updated = dump(parameter_state) record["parameters"] = [ updated if p["id"] == str(parameter_state.id) else p for p in record.get("parameters", []) ] - # Hub-internal (not on DeviceRepositoryProtocol); still scope-checked. + # Hub-internal (not on DeviceRepositoryProtocol), by the Hub's ids; still scope-checked. async def update_parameter_visibility(self, parameter_id: UUID, visibility: ParameterVisibility): for record in self._store.values(): @@ -86,8 +92,9 @@ async def update_parameter_visibility(self, parameter_id: UUID, visibility: Para return async def delete(self, device_id: UUID): - if self._owned(device_id): - self._store.pop(device_id, None) + record = self._store.get(device_id) + if record is not None and self._in_scope(record): + del self._store[device_id] __all__ = ["DeviceRepositoryMemory"] diff --git a/majordom_integration_sdk/repository/protocol.py b/majordom_integration_sdk/repository/protocol.py index 9bced83..7bd3c4c 100644 --- a/majordom_integration_sdk/repository/protocol.py +++ b/majordom_integration_sdk/repository/protocol.py @@ -13,12 +13,16 @@ carelessness, not a security boundary. Constructing with ``integration=None`` gives full access (the Hub's own higher-level use). +**Keyed by ``physical_id``.** An integration looks its devices up by the ``physical_id`` it gave them +(``device_uuid``), never by the Hub-owned ``id``: ``get``, ``state``, ``get_parameter_state`` and +``save_parameter_state`` take it as a keyword, ``save`` writes the record of ``device.id`` (read-only, set by the Hub). + **Typed reads.** ``as_`` deserializes into your own ``Device[MyData]`` / ``DeviceState[MyData, MyParameterData]`` (or a subclass), so ``integration_data`` comes back typed instead of a bare dict:: async with self.platform.device_repository(self) as repo: - device = await repo.get(device_id, as_=MyDevice) # class MyDevice(Device[MyData]) + device = await repo.get(physical_id=physical_id, as_=MyDevice) # class MyDevice(Device[MyData]) **Not here on purpose:** ``delete`` and ``update_parameter_visibility`` are Hub-internal lifecycle operations. They exist on the concrete implementations, but are kept off this @@ -42,31 +46,30 @@ async def get_all[T: Device](self, as_: type[T] = Device) -> list[T]: """Every paired device in scope, deserialized as ``as_``.""" ... - async def get[T: Device](self, device_id: UUID, as_: type[T] = Device) -> T | None: + async def get[T: Device](self, *, physical_id: UUID, as_: type[T] = Device) -> T | None: """The device (info + ``integration_data``) as ``as_``, or ``None`` if unknown/out of scope.""" ... - async def state[T: DeviceState](self, device_id: UUID, as_: type[T] = DeviceState) -> T | None: + async def state[T: DeviceState](self, *, physical_id: UUID, as_: type[T] = DeviceState) -> T | None: """The device with its parameter states as ``as_``, or ``None`` if unknown/out of scope.""" ... - async def get_parameter_state(self, device_id: UUID, parameter_id: UUID) -> ParameterState | None: + async def get_parameter_state(self, *, physical_id: UUID, parameter_id: UUID) -> ParameterState | None: """One parameter's current state, or ``None`` if the device/parameter is unknown/out of scope.""" ... - async def save(self, device: Device | DeviceState, previous_id: UUID | None = None): + async def save(self, device: Device | DeviceState): """Insert or update a device, merging into whatever is already stored. Saving a ``Device`` updates its info/``integration_data``; saving a ``DeviceState`` updates its info/``parameters``, and its ``integration_data`` only when it was given - one — neither clobbers the other's fields. ``previous_id`` - renames an existing record before the write (pairing turning a provisional discovery - id into the device's final id), carrying its stored data over. Rejects a device - outside the bound integration's scope. + one — neither clobbers the other's fields. The record is the one of ``device.id``; its + ``physical_id`` must not be another record's. Rejects a device outside the bound + integration's scope. """ ... - async def save_parameter_state(self, device_id: UUID, parameter_state: ParameterState): + async def save_parameter_state(self, *, physical_id: UUID, parameter_state: ParameterState): """Update a single parameter's value/state on an existing in-scope device.""" ... diff --git a/majordom_integration_sdk/repository/sqlite.py b/majordom_integration_sdk/repository/sqlite.py index 35a5406..41eccf2 100644 --- a/majordom_integration_sdk/repository/sqlite.py +++ b/majordom_integration_sdk/repository/sqlite.py @@ -37,7 +37,7 @@ def __init__(self, path: str | Path, integration: str | None = None): try: conn.execute( "CREATE TABLE IF NOT EXISTS devices (" - " id INTEGER PRIMARY KEY, uuid TEXT UNIQUE NOT NULL," + " id INTEGER PRIMARY KEY, uuid TEXT UNIQUE NOT NULL, physical_uuid TEXT UNIQUE NOT NULL," " integration TEXT NOT NULL, data TEXT NOT NULL)" ) conn.commit() @@ -67,16 +67,16 @@ def _cursor(self) -> sqlite3.Connection: raise RuntimeError("Use `async with repo.session():` before calling repository methods") return conn - def _record(self, device_id: UUID) -> Record | None: - """The stored record for an in-scope device, or None.""" + def _record(self, value: UUID, column: str = "physical_uuid") -> Record | None: + """The stored record for an in-scope device by its ``physical_uuid`` (or Hub ``uuid``), or None.""" if self.integration is None: - row = self._cursor().execute("SELECT data FROM devices WHERE uuid = ?", (str(device_id),)).fetchone() + row = self._cursor().execute(f"SELECT data FROM devices WHERE {column} = ?", (str(value),)).fetchone() else: row = ( self._cursor() .execute( - "SELECT data FROM devices WHERE uuid = ? AND integration = ?", - (str(device_id), self.integration), + f"SELECT data FROM devices WHERE {column} = ? AND integration = ?", + (str(value), self.integration), ) .fetchone() ) @@ -84,9 +84,10 @@ def _record(self, device_id: UUID) -> Record | None: def _write(self, record: Record): self._cursor().execute( - "INSERT INTO devices (uuid, integration, data) VALUES (?, ?, ?) " - "ON CONFLICT(uuid) DO UPDATE SET integration = excluded.integration, data = excluded.data", - (record["id"], record["integration"], json.dumps(record)), + "INSERT INTO devices (uuid, physical_uuid, integration, data) VALUES (?, ?, ?, ?) " + "ON CONFLICT(uuid) DO UPDATE SET physical_uuid = excluded.physical_uuid," + " integration = excluded.integration, data = excluded.data", + (record["id"], record["physical_id"], record["integration"], json.dumps(record)), ) async def get_all[T: Device](self, as_: type[T] = Device) -> list[T]: @@ -98,16 +99,16 @@ async def get_all[T: Device](self, as_: type[T] = Device) -> list[T]: ) return [as_.model_validate(json.loads(row["data"])) for row in rows] - async def get[T: Device](self, device_id: UUID, as_: type[T] = Device) -> T | None: - record = self._record(device_id) + async def get[T: Device](self, *, physical_id: UUID, as_: type[T] = Device) -> T | None: + record = self._record(physical_id) return as_.model_validate(record) if record else None - async def state[T: DeviceState](self, device_id: UUID, as_: type[T] = DeviceState) -> T | None: - record = self._record(device_id) + async def state[T: DeviceState](self, *, physical_id: UUID, as_: type[T] = DeviceState) -> T | None: + record = self._record(physical_id) return as_.model_validate(record) if record else None - async def get_parameter_state(self, device_id: UUID, parameter_id: UUID) -> ParameterState | None: - record = self._record(device_id) + async def get_parameter_state(self, *, physical_id: UUID, parameter_id: UUID) -> ParameterState | None: + record = self._record(physical_id) if not record: return None return next( @@ -115,30 +116,26 @@ async def get_parameter_state(self, device_id: UUID, parameter_id: UUID) -> Para None, ) - async def save(self, device: Device | DeviceState, previous_id: UUID | None = None): + async def save(self, device: Device | DeviceState): if self.integration is not None and device.integration != self.integration: raise PermissionError(f"Device {device.id} is outside integration scope {self.integration!r}") - base: Record | None = None - if previous_id is not None and previous_id != device.id: - base = self._record(previous_id) - self._cursor().execute("DELETE FROM devices WHERE uuid = ?", (str(previous_id),)) - self._write(merge(base if base is not None else self._record(device.id), device)) - - async def save_parameter_state(self, device_id: UUID, parameter_state: ParameterState): - record = self._record(device_id) + self._write(merge(self._record(device.id, "uuid"), device)) # another record's physical_uuid: IntegrityError + + async def save_parameter_state(self, *, physical_id: UUID, parameter_state: ParameterState): + record = self._record(physical_id) if not record: - raise KeyError(f"Unknown or out-of-scope device {device_id}") + raise KeyError(f"Unknown or out-of-scope device {physical_id}") updated = dump(parameter_state) record["parameters"] = [ updated if p["id"] == str(parameter_state.id) else p for p in record.get("parameters", []) ] self._write(record) - # Hub-internal (not on DeviceRepositoryProtocol); still scope-checked. + # Hub-internal (not on DeviceRepositoryProtocol), by the Hub's ids; still scope-checked. async def update_parameter_visibility(self, parameter_id: UUID, visibility: ParameterVisibility): for device in await self.get_all(): - record = self._record(device.id) + record = self._record(device.id, "uuid") if not record: continue if any(p["id"] == str(parameter_id) for p in record.get("parameters", [])): @@ -149,7 +146,7 @@ async def update_parameter_visibility(self, parameter_id: UUID, visibility: Para return async def delete(self, device_id: UUID): - if self._record(device_id) is None: + if self._record(device_id, "uuid") is None: return self._cursor().execute("DELETE FROM devices WHERE uuid = ?", (str(device_id),)) diff --git a/majordom_integration_sdk/runtime/output.py b/majordom_integration_sdk/runtime/output.py index 6677bba..3464e10 100644 --- a/majordom_integration_sdk/runtime/output.py +++ b/majordom_integration_sdk/runtime/output.py @@ -13,7 +13,7 @@ from majordom_integration_sdk.schemas.notification import Notification if TYPE_CHECKING: - from majordom_integration_sdk.controller.abstract_controller import AbstractController + from majordom_integration_sdk.controller.output import AnyController from majordom_integration_sdk.runtime.runtime import Runtime @@ -41,34 +41,44 @@ async def peripheral_did_fail(self, peripheral: Peripheral, reason: str): # ControllerOutput - async def controller_did_receive_discovery(self, controller: AbstractController, discovery: Discovery): + async def controller_did_receive_discovery(self, controller: AnyController, discovery: Discovery): await self.controller_output.controller_did_receive_discovery(controller, discovery) - async def controller_did_update_discovery(self, controller: AbstractController, discovery: Discovery): + async def controller_did_update_discovery(self, controller: AnyController, discovery: Discovery): await self.controller_output.controller_did_update_discovery(controller, discovery) - async def controller_did_lose_discovery(self, controller: AbstractController, discovery_id: UUID): + async def controller_did_lose_discovery(self, controller: AnyController, discovery_id: UUID): await self.controller_output.controller_did_lose_discovery(controller, discovery_id) - async def controller_did_connect_device(self, controller: AbstractController, device_id: UUID): - await self.controller_output.controller_did_connect_device(controller, device_id) - - async def controller_did_lose_device(self, controller: AbstractController, device_id: UUID): - await self.controller_output.controller_did_lose_device(controller, device_id) - - async def controller_did_receive_events(self, controller: AbstractController, events: Iterable[Event]): + async def controller_did_connect_device( + self, controller: AnyController, *, physical_id: UUID | None = None, device_id: UUID | None = None + ): + if physical_id: + await self.controller_output.controller_did_connect_device(controller, physical_id=physical_id) + elif device_id: + await self.controller_output.controller_did_connect_device(controller, device_id=device_id) + + async def controller_did_lose_device( + self, controller: AnyController, *, physical_id: UUID | None = None, device_id: UUID | None = None + ): + if physical_id: + await self.controller_output.controller_did_lose_device(controller, physical_id=physical_id) + elif device_id: + await self.controller_output.controller_did_lose_device(controller, device_id=device_id) + + async def controller_did_receive_events(self, controller: AnyController, events: Iterable[Event]): await self.controller_output.controller_did_receive_events(controller, events) - async def controller_did_encounter_error(self, controller: AbstractController, error: IntegrationError): + async def controller_did_encounter_error(self, controller: AnyController, error: IntegrationError): await self.controller_output.controller_did_encounter_error(controller, error) if not error.is_still_running: await self.runtime.peripheral_did_fail(controller, error.message) - async def controller_did_resolve_error(self, controller: AbstractController, error_id: str): + async def controller_did_resolve_error(self, controller: AnyController, error_id: str): await self.controller_output.controller_did_resolve_error(controller, error_id) - async def controller_did_emit_notification(self, controller: AbstractController, notification: Notification): + async def controller_did_emit_notification(self, controller: AnyController, notification: Notification): await self.controller_output.controller_did_emit_notification(controller, notification) - async def controller_did_change_provided(self, controller: AbstractController): + async def controller_did_change_provided(self, controller: AnyController): await self.runtime.peripheral_did_change_provided(controller) diff --git a/majordom_integration_sdk/schemas/base.py b/majordom_integration_sdk/schemas/base.py index 37f8471..221eb67 100644 --- a/majordom_integration_sdk/schemas/base.py +++ b/majordom_integration_sdk/schemas/base.py @@ -5,7 +5,7 @@ from uuid import UUID from pydantic import BaseModel as PydanticBaseModel -from pydantic import GetJsonSchemaHandler +from pydantic import Field, GetJsonSchemaHandler from pydantic_core import core_schema as cs @@ -46,7 +46,7 @@ def __get_pydantic_json_schema__( class UUIdentifable(Base): # TODO: review - id: UUID + id: UUID = Field(frozen=True) def __hash__(self): return hash(self.id) diff --git a/majordom_integration_sdk/schemas/device.py b/majordom_integration_sdk/schemas/device.py index 5389a61..6398a4d 100644 --- a/majordom_integration_sdk/schemas/device.py +++ b/majordom_integration_sdk/schemas/device.py @@ -80,7 +80,7 @@ class DevicePair(Base): class DeviceInfo(DevicePatch): - id: UUID # NOTE: moved from DeviceCreate, check whether it's correct + id: UUID = Field(frozen=True) # Hub-owned, set at the pairing write, never changes transport: str integration: str manufacturer: str | None @@ -104,6 +104,9 @@ class Device[TData = Any](DeviceInfo): # TODO: review models validated from ``{}`` (the Hub creates a device before its integration pairs it), and stays unset: a save keeps the stored one. For ``Device[HueData | None]`` that ``{}``, unset or stored, is None.""" + # The integration's lookup id (``device_uuid`` of what the protocol identifies the device by): unique, filled in + # by ``pair_device``; inbound protocol data maps to the record through it, and the repository is keyed by it + physical_id: UUID integration_data: SerializeAsAny[TData] = Field(default_factory=dict, validate_default=True) @field_validator("integration_data", mode="before") diff --git a/majordom_integration_sdk/schemas/event.py b/majordom_integration_sdk/schemas/event.py index 22fdd94..292a290 100644 --- a/majordom_integration_sdk/schemas/event.py +++ b/majordom_integration_sdk/schemas/event.py @@ -17,10 +17,10 @@ class Event(Base): class DeviceParameterChange(Event): """A device reported that one of its parameters changed to a new value. - Mirrors :class:`DeviceCommand`'s shape (``device_id``, ``parameter_id``, ``value``) but - flows the other direction — device → Hub. + Mirrors :class:`DeviceCommand`'s shape (``parameter_id``, ``value``) but flows the other direction — device → Hub, + so it names the device by its ``physical_id``: the integration knows no other. """ - device_id: UUID + physical_id: UUID parameter_id: UUID value: Any diff --git a/majordom_integration_sdk/testing/__init__.py b/majordom_integration_sdk/testing/__init__.py index 2e0bbfd..6499a9f 100644 --- a/majordom_integration_sdk/testing/__init__.py +++ b/majordom_integration_sdk/testing/__init__.py @@ -15,8 +15,8 @@ :class:`FakeZeroconf` (the ``discovery`` extra) and :class:`FakeSSDP`. Each provides itself as ``.endpoint``, to bind as the controller's peripherals: ``Peripherals({"ble": router.endpoint})``. - :func:`build_test_platform` — assembles a ``Platform`` with an in-memory device repository - (its ``store`` yours to read), temp-dir storage and the facts you give, so a test can drive a - controller (or any peripheral) exactly as the Hub would. + (its ``store`` yours to read), an id check over that store, temp-dir storage and the facts you give, + so a test can drive a controller (or any peripheral) exactly as the Hub would. """ from __future__ import annotations @@ -25,13 +25,14 @@ import tempfile from collections.abc import Callable, Iterable, Mapping, Sequence from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from uuid import UUID from pydantic import BaseModel from majordom_integration_sdk.capabilities.ble import BLEConnection, BLEDiscoveryListener, BLEMatcher, OnAdvertisement from majordom_integration_sdk.controller.output import ControllerOutput, IntegrationError +from majordom_integration_sdk.errors import DeviceIdTakenError from majordom_integration_sdk.peripherals import Endpoint, Need, NoSettings, Peripheral, PeripheralChange, Service from majordom_integration_sdk.platform import Platform from majordom_integration_sdk.repository.memory import DeviceRepositoryMemory @@ -39,12 +40,12 @@ from majordom_integration_sdk.runtime.peripherals.providers.ssdp import SSDPDiscoveryService from majordom_integration_sdk.runtime.peripherals.services.compose import Compose from majordom_integration_sdk.schemas.credentials import WifiCredentials -from majordom_integration_sdk.schemas.device import Discovery +from majordom_integration_sdk.schemas.device import DeviceState, Discovery from majordom_integration_sdk.schemas.event import Event from majordom_integration_sdk.schemas.notification import Notification if TYPE_CHECKING: - from majordom_integration_sdk.controller.abstract_controller import AbstractController as _Controller + from majordom_integration_sdk.controller.output import AnyController as _Controller from majordom_integration_sdk.runtime import PeripheralStatus @@ -55,8 +56,8 @@ def __init__(self): self.received_discoveries: list[Discovery] = [] self.updated_discoveries: list[Discovery] = [] self.lost_discoveries: list[UUID] = [] - self.connected_devices: list[UUID] = [] - self.lost_devices: list[UUID] = [] + self.connected_devices: list[UUID] = [] # the ids passed, physical ones from integrations + self.lost_devices: list[UUID] = [] # the ids passed, physical ones from integrations self.events: list[Event] = [] self.errors: dict[str, IntegrationError] = {} # the active ones, by id self.error_log: list[IntegrationError | str] = [] # every report, and the id of every resolve @@ -72,11 +73,15 @@ async def controller_did_update_discovery(self, controller: _Controller, discove async def controller_did_lose_discovery(self, controller: _Controller, discovery_id: UUID): self.lost_discoveries.append(discovery_id) - async def controller_did_connect_device(self, controller: _Controller, device_id: UUID): - self.connected_devices.append(device_id) + async def controller_did_connect_device( + self, controller: _Controller, *, physical_id: UUID | None = None, device_id: UUID | None = None + ): + self.connected_devices.append(cast(UUID, physical_id or device_id)) - async def controller_did_lose_device(self, controller: _Controller, device_id: UUID): - self.lost_devices.append(device_id) + async def controller_did_lose_device( + self, controller: _Controller, *, physical_id: UUID | None = None, device_id: UUID | None = None + ): + self.lost_devices.append(cast(UUID, physical_id or device_id)) async def controller_did_receive_events(self, controller: _Controller, events: Iterable[Event]): self.events.extend(events) @@ -327,11 +332,22 @@ def build_test_platform( Pass ``documents_root`` as the root of the peripherals' folders; a fresh temp folder otherwise, removed when the process ends. Pass ``store`` to see every device kept there: ``DeviceRepositoryMemory(store=store)``. + + Its ``check_device_id`` takes any record in ``store`` but the draft's own (``draft.id``) for another holder of the + physical id: a test seeds the store with the record a collision should hit (``"is_paired": False`` in it for an + unpaired one; paired otherwise). """ store = store if store is not None else {} + + async def check_device_id(physical_id: UUID, draft: DeviceState): + for device_id, record in store.items(): + if record.get("physical_id") == str(physical_id) and device_id != draft.id: + raise DeviceIdTakenError(physical_id, device_id, record.get("name", ""), record.get("is_paired", True)) + return Platform( documents_root=documents_root or Path(tempfile.mkdtemp(dir=_temp_root().name)), make_device_repository=lambda key: DeviceRepositoryMemory(key, store).session(), + check_device_id=check_device_id, region=region, wifi=wifi, thread_dataset=thread_dataset, diff --git a/pyproject.toml b/pyproject.toml index bfa6ec9..3d8ce02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "majordom-integration-sdk" -version = "0.3.1" +version = "0.4.0" description = "Models, protocols, tooling and the runtime for building and running MajorDom integrations." authors = [{ name = "Mark Parker", email = "mark@parker-programs.com" }] readme = "README.md" diff --git a/tests/factories.py b/tests/factories.py index 974b879..b63b953 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -35,12 +35,14 @@ def make_parameter_state( def make_device_state( *, id: UUID | None = None, + physical_id: UUID | None = None, integration: str = "example", name: str = "Lamp", parameters: list[ParameterState] | None = None, ) -> DeviceState: return DeviceState( id=id or uuid4(), + physical_id=physical_id or uuid4(), name=name, room_id=uuid4(), transport="wifi", diff --git a/tests/runtime/test_integration.py b/tests/runtime/test_integration.py index 3ec074e..781d929 100644 --- a/tests/runtime/test_integration.py +++ b/tests/runtime/test_integration.py @@ -54,8 +54,8 @@ async def stop(self): async def settings_did_change(self, before) -> bool: return True # it tells the server over its connection - async def pair_device(self, discovery, credentials): - pass + async def pair_device(self, discovery, draft, credentials): + return draft async def unpair(self, device): pass @@ -155,7 +155,7 @@ async def test_every_peripheral_shares_one_output_and_a_controllers_reports_reac runtime = await run(tmp_path, HostNetwork(), FakeCompose(), server, echo, controller_output=hub) assert echo.output is server.output and not hasattr(echo, "runtime") - await echo.output.controller_did_lose_device(echo, DEVICE) + await echo.output.controller_did_lose_device(echo, physical_id=DEVICE) await echo.output.controller_did_encounter_error(echo, IntegrationError("server-slow", "The server is slow.")) assert hub.lost_devices == [DEVICE] and list(hub.errors) == ["server-slow"] assert runtime.status(echo).state == PeripheralState.RUNNING # it goes on diff --git a/tests/test_cli.py b/tests/test_cli.py index 93d69af..dfaea47 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -70,30 +70,23 @@ async def start(self): async def stop(self): pass - async def pair_device(self, discovery: Discovery, credentials: ProvidedCredentials | None): - device_id = uuid4() - state = DeviceState( - id=device_id, - name=discovery.device_name, - room_id=uuid4(), - transport=discovery.transport, - integration=self.key, - manufacturer=None, - parameters=[_param(self._param_role)], - ) - async with self.platform.device_repository(self) as repo: - await repo.save(state) - self.paired.append(device_id) - return device_id + async def pair_device( + self, discovery: Discovery, draft: DeviceState, credentials: ProvidedCredentials | None + ) -> DeviceState: + draft.physical_id = uuid4() + draft.parameters = [_param(self._param_role)] + draft.available = True + self.paired.append(draft.physical_id) + return draft async def unpair(self, device: Device): - self.unpaired.append(device.id) + self.unpaired.append(device.physical_id) async def identify(self, device: Device): - self.identified.append(device.id) + self.identified.append(device.physical_id) async def fetch(self, device: Device): - self.fetched.append(device.id) + self.fetched.append(device.physical_id) async def send_command(self, command: DeviceCommand, device: Device, parameter): self.sent.append((command.device_id, command.parameter_id, command.value)) @@ -125,13 +118,14 @@ async def test_pair_list_control_unpair_flow(session_and_controller): discovery = _discovery() controller.discoveries[discovery.id] = discovery - # pair -> persists a device + # pair -> persists the filled draft, then reports it connected and fetches it, as the Hub does device_id = await session.pair(discovery.id, "none", "") assert device_id in controller.paired + assert controller.fetched == [device_id] # devices -> lists it devices = await session.list_devices() - assert [d.id for d in devices] == [device_id] + assert [d.physical_id for d in devices] == [device_id] # device -> shows its (single control) parameter state = await session.device_state(device_id) @@ -142,13 +136,13 @@ async def test_pair_list_control_unpair_flow(session_and_controller): # control -> resolves device+parameter and reaches the controller assert await session.control(device_id, parameter.id, True) == "Command sent" - assert controller.sent == [(device_id, parameter.id, True)] + assert controller.sent == [(devices[0].id, parameter.id, True)] # the command carries the Hub's id # identify / fetch reach the controller with the resolved device assert await session.identify(device_id) is True assert await session.fetch(device_id) is True assert controller.identified == [device_id] - assert controller.fetched == [device_id] + assert controller.fetched == [device_id, device_id] # unpair assert await session.unpair(device_id) is True @@ -197,11 +191,11 @@ async def test_output_delegate_persists_event_values(): output = _CliControllerOutput() await output.controller_did_receive_events( - controller, [DeviceParameterChange(device_id=device_id, parameter_id=parameter.id, value=True)] + controller, [DeviceParameterChange(physical_id=device_id, parameter_id=parameter.id, value=True)] ) async with controller.platform.device_repository(controller) as repo: - stored = await repo.get_parameter_state(device_id, parameter.id) + stored = await repo.get_parameter_state(physical_id=device_id, parameter_id=parameter.id) assert stored is not None assert stored.value # a (non-empty, encoded) value was written back by the event diff --git a/tests/test_controller.py b/tests/test_controller.py index 6e7d1e3..5d5084d 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -4,16 +4,18 @@ import subprocess import sys from pathlib import Path -from uuid import UUID +from uuid import UUID, uuid4, uuid5 import pytest from factories import make_device_state +from pydantic import ValidationError -from majordom_integration_sdk import NoSettings +from majordom_integration_sdk import DeviceIdTakenError, NoSettings, Platform from majordom_integration_sdk.controller import AbstractController, ControllerOutput, LoggingControllerOutput +from majordom_integration_sdk.controller.output import AnyController from majordom_integration_sdk.repository import DeviceRepositoryMemory from majordom_integration_sdk.schemas.command import DeviceCommand -from majordom_integration_sdk.schemas.device import Device, Discovery, Parameter, ProvidedCredentials +from majordom_integration_sdk.schemas.device import Device, DeviceState, Discovery, Parameter, ProvidedCredentials from majordom_integration_sdk.schemas.event import DeviceParameterChange from majordom_integration_sdk.testing import RecordingControllerOutput, build_test_platform @@ -31,8 +33,12 @@ async def start(self): async def stop(self): pass - async def pair_device(self, discovery: Discovery, credentials: ProvidedCredentials | None): - pass + async def pair_device( + self, discovery: Discovery, draft: DeviceState, credentials: ProvidedCredentials | None + ) -> DeviceState: + await self.check_device_id(self.device_uuid("serial-1"), draft) + draft.physical_id = self.device_uuid("serial-1") + return draft async def unpair(self, device: Device): pass @@ -56,6 +62,40 @@ def test_key_and_uuid_helpers(): assert controller.parameter_uuid(device_uuid, "p1") != device_uuid +@pytest.mark.parametrize("raw", ["AA:BB-cc", "aabbcc", " aa bb\tcc\n", "a-a:b-b:c-c"]) +def test_device_uuid_normalises_a_string(raw): + controller = _MinimalController(build_test_platform()) + assert controller.device_uuid(raw) == controller.device_uuid("aabbcc") + + +@pytest.mark.parametrize("other", ["aa.bb.cc", "aa_bb_cc", "aa/bb/cc"]) +def test_device_uuid_keeps_other_separators(other): + controller = _MinimalController(build_test_platform()) + assert controller.device_uuid(other) != controller.device_uuid("aabbcc") + + +def test_device_uuid_strips_only_the_chars_given(): + controller = _MinimalController(build_test_platform()) + assert controller.device_uuid("aa.bb", strip=".") == controller.device_uuid("aabb") + assert controller.device_uuid("aa:bb", strip="") != controller.device_uuid("aabb") + + +def test_device_uuid_takes_bytes_as_they_are(): + controller = _MinimalController(build_test_platform()) + mac = bytes.fromhex("aabbccddeeff") + assert controller.device_uuid(mac) == uuid5(controller.integration_uuid(), mac) + assert controller.device_uuid(b"AA") != controller.device_uuid(b"aa") + assert controller.device_uuid(mac) != controller.device_uuid("aabbccddeeff") + + +def test_parameter_uuid_is_seeded_by_the_physical_id_and_normalised(): + controller = _MinimalController(build_test_platform()) + physical_id = controller.device_uuid("aabbcc") + assert controller.parameter_uuid(physical_id, "On-Off") == controller.parameter_uuid(physical_id, "onoff") + assert controller.parameter_uuid(physical_id, "On-Off") == uuid5(physical_id, "onoff") + assert controller.parameter_uuid(physical_id, b"\x01") == uuid5(physical_id, b"\x01") + + def test_its_documents_folder_is_its_own_under_the_platforms(tmp_path): controller = _MinimalController(build_test_platform(documents_root=tmp_path)) assert controller.platform.documents_folder(controller) == tmp_path / "example-protocol" @@ -83,7 +123,7 @@ async def test_its_device_repository_holds_only_its_own_devices(): device = make_device_state(integration=controller.key) # its slug, "example-protocol" async with controller.platform.device_repository(controller) as repo: await repo.save(device) - stored = await repo.state(device.id) + stored = await repo.state(physical_id=device.physical_id) assert stored is not None and stored.id == device.id with pytest.raises(PermissionError): await repo.save(make_device_state(integration="Other")) @@ -94,11 +134,11 @@ async def test_recording_output_captures_reports(): output = controller.output = RecordingControllerOutput() device = make_device_state() - change = DeviceParameterChange(device_id=device.id, parameter_id=device.parameters[0].id, value=1) - await output.controller_did_connect_device(controller, device.id) + change = DeviceParameterChange(physical_id=device.physical_id, parameter_id=device.parameters[0].id, value=1) + await output.controller_did_connect_device(controller, physical_id=device.physical_id) await output.controller_did_receive_events(controller, [change]) - assert output.connected_devices == [device.id] + assert output.connected_devices == [device.physical_id] assert output.events == [change] @@ -146,4 +186,74 @@ def test_the_output_annotations_evaluate(): for output in (ControllerOutput, LoggingControllerOutput): for name, method in inspect.getmembers(output, inspect.isfunction): if name.startswith("controller_did_"): - assert inspect.get_annotations(method, eval_str=True)["controller"] is AbstractController, name + assert inspect.get_annotations(method, eval_str=True)["controller"] is AnyController, name + + +def _discovery(controller: AbstractController) -> Discovery: + return Discovery( + id=uuid4(), + integration=controller.key, + expected_credentials_options=[], + transport="wifi", + device_manufacturer=None, + device_name="Lamp", + device_category=None, + device_icon=None, + ) + + +async def test_pairing_fills_and_returns_the_draft_without_saving_it(): + store: dict = {} + controller = _MinimalController(build_test_platform(store=store)) + discovery = _discovery(controller) + draft = make_device_state(id=discovery.id, integration=controller.key) + + device = await controller.pair_device(discovery, draft, None) + + assert device is draft and device.physical_id == controller.device_uuid("serial-1") + assert store == {} + + +async def test_the_physical_id_held_by_another_record_is_taken(): + store: dict = {} + platform = build_test_platform(store=store) + controller = _MinimalController(platform) + taken = controller.device_uuid("serial-1") + async with platform.device_repository(controller) as repo: + old = make_device_state(physical_id=taken, integration=controller.key, name="Old lamp") + await repo.save(old) + store[old.id]["is_paired"] = False + discovery = _discovery(controller) + + with pytest.raises(DeviceIdTakenError) as raised: + await controller.pair_device(discovery, make_device_state(id=discovery.id, integration=controller.key), None) + + error = raised.value + assert (error.physical_id, error.device_id, error.name, error.is_paired) == (taken, old.id, "Old lamp", False) + + +async def test_the_record_paired_onto_is_not_another(): + store: dict = {} + platform = build_test_platform(store=store) + controller = _MinimalController(platform) + picked = make_device_state(physical_id=controller.device_uuid("serial-1"), integration=controller.key) + async with platform.device_repository(controller) as repo: + await repo.save(picked) + + device = await controller.pair_device(_discovery(controller), picked, None) + + assert device.id == picked.id and device.physical_id == picked.physical_id + + +def test_the_hub_id_is_read_only(device_state): + with pytest.raises(ValidationError): + device_state.id = uuid4() + + +async def test_library_mode_takes_every_id_for_free(): + controller = _MinimalController(Platform(documents_root=Path("."))) + await controller.check_device_id(uuid4(), make_device_state()) + + +def test_the_device_state_type_defaults_to_device_state(): + assert _MinimalController(build_test_platform()).device_state_type is DeviceState diff --git a/tests/test_dev.py b/tests/test_dev.py index b1ad36f..2d9a0fb 100644 --- a/tests/test_dev.py +++ b/tests/test_dev.py @@ -22,7 +22,7 @@ from majordom_integration_sdk.runtime import Pinned, Runtime from majordom_integration_sdk.runtime.output import HostOutput from majordom_integration_sdk.schemas.command import DeviceCommand -from majordom_integration_sdk.schemas.device import Device, Discovery, Parameter, ProvidedCredentials +from majordom_integration_sdk.schemas.device import Device, DeviceState, Discovery, Parameter, ProvidedCredentials from majordom_integration_sdk.testing import FakeBLERouter, FakePeripheral, FakeSSDP ZWAVE = Endpoint("zwave", "zwave-js-server", "ws://127.0.0.1:3000", is_shared=True) @@ -56,8 +56,10 @@ async def needed_peripheral_did_change(self, change: PeripheralChange) -> bool: self.changes.append(change) return self.handles_changes - async def pair_device(self, discovery: Discovery, credentials: ProvidedCredentials | None): - pass + async def pair_device( + self, discovery: Discovery, draft: DeviceState, credentials: ProvidedCredentials | None + ) -> DeviceState: + return draft async def unpair(self, device: Device): pass @@ -115,9 +117,9 @@ async def test_build_platform_memory_repository_round_trips_in_its_scope(tmp_pat async with platform.make_device_repository("example") as repo: await repo.save(device) async with platform.make_device_repository("example") as repo: - assert (await repo.state(device.id)) is not None + assert (await repo.state(physical_id=device.physical_id)) is not None async with platform.make_device_repository("other") as repo: - assert (await repo.state(device.id)) is None + assert (await repo.state(physical_id=device.physical_id)) is None async def test_build_platform_sqlite_persists_to_file(tmp_path): @@ -132,7 +134,7 @@ async def test_build_platform_sqlite_persists_to_file(tmp_path): # A fresh platform over the same file sees the persisted device (survives 'restarts'). again = build_platform(storage_root=tmp_path, db_path=db_path) async with again.make_device_repository("example") as repo: - assert (await repo.state(device.id)) is not None + assert (await repo.state(physical_id=device.physical_id)) is not None # --- the runtime -------------------------------------------------------------------------- diff --git a/tests/test_errors.py b/tests/test_errors.py index 742508a..f1d33cd 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,8 +1,10 @@ """MissingCredentialsError: what the Hub lacks to pair a device.""" +from uuid import UUID + import pytest -from majordom_integration_sdk import MissingCredentialsError +from majordom_integration_sdk import DeviceIdTakenError, MissingCredentialsError def test_the_type_defaults_to_wifi(): @@ -29,3 +31,10 @@ def test_the_type_is_lower_snake_case(type_: str): def test_the_hub_already_handles_it_as_a_lookup_error(): assert issubclass(MissingCredentialsError, LookupError) + + +def test_a_taken_device_id_carries_the_record_holding_it(): + physical_id, device_id = UUID(int=1), UUID(int=2) + error = DeviceIdTakenError(physical_id, device_id, "Lamp", is_paired=True) + assert (error.physical_id, error.device_id, error.name, error.is_paired) == (physical_id, device_id, "Lamp", True) + assert str(error) == f"Physical id {physical_id} is taken by 'Lamp' ({device_id}, paired)" diff --git a/tests/test_peripherals.py b/tests/test_peripherals.py index 5fbe51b..70e80ac 100644 --- a/tests/test_peripherals.py +++ b/tests/test_peripherals.py @@ -127,8 +127,8 @@ async def start(self): async def stop(self): pass - async def pair_device(self, discovery, credentials): - pass + async def pair_device(self, discovery, draft, credentials): + return draft async def unpair(self, device): pass diff --git a/tests/test_repository.py b/tests/test_repository.py index 47e2e94..4ac74d2 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -1,5 +1,6 @@ """Both first-class repositories must satisfy the same protocol behaviour.""" +import sqlite3 from uuid import uuid4 import pytest @@ -37,9 +38,10 @@ class _MyDevice(Device[_PairingData]): pass -def make_my_device(device_id=None, token: str = "secret", integration: str = "example") -> _MyDevice: +def make_my_device(device_id=None, token: str = "secret", integration: str = "example", physical_id=None) -> _MyDevice: return _MyDevice( id=device_id or uuid4(), + physical_id=physical_id or uuid4(), name="Lamp", room_id=uuid4(), transport="wifi", @@ -60,8 +62,8 @@ async def test_save_and_read_back(repository, device_state): async with repository.session() as repo: await repo.save(device_state) - state = await repo.state(device_state.id) - got = await repo.get(device_state.id) + state = await repo.state(physical_id=device_state.physical_id) + got = await repo.get(physical_id=device_state.physical_id) assert state is not None and state.id == device_state.id assert got is not None and got.id == device_state.id assert [d.id for d in await repo.get_all()] == [device_state.id] @@ -69,8 +71,8 @@ async def test_save_and_read_back(repository, device_state): async def test_get_unknown_is_none(repository): async with repository.session() as repo: - assert await repo.state(uuid4()) is None - assert await repo.get(uuid4()) is None + assert await repo.state(physical_id=uuid4()) is None + assert await repo.get(physical_id=uuid4()) is None async def test_parameter_state_round_trip(repository, device_state): @@ -78,12 +80,12 @@ async def test_parameter_state_round_trip(repository, device_state): async with repository.session() as repo: await repo.save(device_state) - fetched = await repo.get_parameter_state(device_state.id, parameter.id) + fetched = await repo.get_parameter_state(physical_id=device_state.physical_id, parameter_id=parameter.id) assert fetched is not None and fetched.id == parameter.id parameter.value = 7 - await repo.save_parameter_state(device_state.id, parameter) - updated = await repo.get_parameter_state(device_state.id, parameter.id) + await repo.save_parameter_state(physical_id=device_state.physical_id, parameter_state=parameter) + updated = await repo.get_parameter_state(physical_id=device_state.physical_id, parameter_id=parameter.id) assert updated is not None and updated.value == 7 @@ -92,28 +94,42 @@ async def test_update_visibility(repository, device_state): async with repository.session() as repo: await repo.save(device_state) await repo.update_parameter_visibility(parameter.id, ParameterVisibility.system) - fetched = await repo.get_parameter_state(device_state.id, parameter.id) + fetched = await repo.get_parameter_state(physical_id=device_state.physical_id, parameter_id=parameter.id) assert fetched is not None and fetched.visibility is ParameterVisibility.system -async def test_save_with_previous_id_renames_row(repository): - provisional = make_device_state(id=uuid4()) - final_id = uuid4() +async def test_reads_are_keyed_by_physical_id(repository, device_state): async with repository.session() as repo: - await repo.save(provisional) - renamed = provisional.model_copy(update={"id": final_id}) - await repo.save(renamed, previous_id=provisional.id) + await repo.save(device_state) + assert await repo.get(physical_id=device_state.id) is None + assert await repo.state(physical_id=device_state.id) is None + + +async def test_a_physical_id_held_by_another_record_is_refused(repository, device_state): + other = make_device_state(physical_id=device_state.physical_id) + async with repository.session() as repo: + await repo.save(device_state) + with pytest.raises((ValueError, sqlite3.IntegrityError)): + await repo.save(other) + - assert await repo.state(provisional.id) is None - renamed_state = await repo.state(final_id) - assert renamed_state is not None and renamed_state.id == final_id +async def test_a_new_physical_id_updates_the_same_record(repository, device_state): + """Pairing onto a kept record: its Hub id stays, its physical id moves.""" + async with repository.session() as repo: + await repo.save(device_state) + moved = device_state.model_copy(update={"physical_id": uuid4()}) + await repo.save(moved) + assert await repo.state(physical_id=device_state.physical_id) is None + state = await repo.state(physical_id=moved.physical_id) + assert state is not None and state.id == device_state.id + assert [d.id for d in await repo.get_all()] == [device_state.id] async def test_delete(repository, device_state): async with repository.session() as repo: await repo.save(device_state) await repo.delete(device_state.id) - assert await repo.state(device_state.id) is None + assert await repo.state(physical_id=device_state.physical_id) is None async def test_typed_read_returns_typed_integration_data(repository): @@ -121,7 +137,7 @@ async def test_typed_read_returns_typed_integration_data(repository): async with repository.session() as repo: await repo.save(device) - got = await repo.get(device.id, as_=_MyDevice) + got = await repo.get(physical_id=device.physical_id, as_=_MyDevice) assert got is not None assert isinstance(got.integration_data, _PairingData) assert got.integration_data.token == "secret" @@ -134,31 +150,37 @@ async def test_a_typed_state_reads_the_data_of_the_device_and_its_parameters(rep device_state.parameters[0].integration_data = {"endpoint": 1} async with repository.session() as repo: await repo.save(device_state) - await repo.save(make_my_device(device_id=device_state.id, integration=device_state.integration)) + await repo.save( + make_my_device( + device_id=device_state.id, physical_id=device_state.physical_id, integration=device_state.integration + ) + ) - state = await repo.state(device_state.id, as_=DeviceState[_PairingData, _Endpoint]) + state = await repo.state(physical_id=device_state.physical_id, as_=DeviceState[_PairingData, _Endpoint]) assert state is not None and state.integration_data.token == "secret" assert state.parameters[0].integration_data.endpoint == 1 - untyped = await repo.state(device_state.id) # a bare name means any data + untyped = await repo.state(physical_id=device_state.physical_id) # a bare name means any data assert untyped is not None and untyped.parameters[0].integration_data == {"endpoint": 1} async def test_device_and_state_views_merge(repository, device_state): """Device carries integration_data, DeviceState parameters too — saving one must not clobber the other's half of the record, nor a DeviceState that wasn't given integration_data the stored one.""" - device = make_my_device(device_id=device_state.id, token="t", integration=device_state.integration) + device = make_my_device( + device_id=device_state.id, physical_id=device_state.physical_id, token="t", integration=device_state.integration + ) async with repository.session() as repo: await repo.save(device_state) # info + parameters await repo.save(device) # info + integration_data - state = await repo.state(device_state.id) - typed = await repo.get(device_state.id, as_=_MyDevice) + state = await repo.state(physical_id=device_state.physical_id) + typed = await repo.get(physical_id=device_state.physical_id, as_=_MyDevice) assert state is not None and len(state.parameters) == 1, "parameters must survive a Device save" assert typed is not None and typed.integration_data.token == "t" # ...and the reverse order. await repo.save(device_state) - typed_again = await repo.get(device_state.id, as_=_MyDevice) + typed_again = await repo.get(physical_id=device_state.physical_id, as_=_MyDevice) assert typed_again is not None, "integration_data must survive a DeviceState save" assert typed_again.integration_data.token == "t" @@ -172,7 +194,7 @@ async def test_a_typed_state_without_data_holds_its_own_model_and_keeps_the_stor await repo.save(make_my_device(device_id=state.id, token="t", integration=state.integration)) await repo.save(state) - typed = await repo.get(state.id, as_=_MyDevice) + typed = await repo.get(physical_id=state.physical_id, as_=_MyDevice) assert typed is not None and typed.integration_data.token == "t" @@ -180,16 +202,17 @@ async def test_data_filled_in_place_on_a_device_saved_without_any_is_kept(reposi """Read back from a record without data, a typed device holds its own model, unset; filled in place (a pairing on the record the Hub seeded), it is saved: the record has no data to keep.""" seeded = Device( - id=uuid4(), name="Lamp", room_id=uuid4(), transport="wifi", integration="example", manufacturer=None - ) + id=uuid4(), physical_id=uuid4(), name="Lamp", room_id=uuid4(), transport="wifi", integration="example", + manufacturer=None, + ) # fmt: skip async with repository.session() as repo: await repo.save(seeded) - device = await repo.get(seeded.id, as_=Device[_Learned]) + device = await repo.get(physical_id=seeded.physical_id, as_=Device[_Learned]) assert device is not None device.integration_data.firmware = "1.2" await repo.save(device) - kept = await repo.get(seeded.id, as_=Device[_Learned]) + kept = await repo.get(physical_id=seeded.physical_id, as_=Device[_Learned]) assert kept is not None and kept.integration_data.firmware == "1.2" @@ -198,8 +221,8 @@ async def test_data_filled_in_place_on_a_state_saved_onto_a_record_holding_none_ """The `{}` (or null) the Hub stores a device with before pairing is no data to keep: a typed state built without any and filled in place (a pairing) is saved, as the Hub's own repository does.""" seeded = Device( - id=uuid4(), name="Lamp", room_id=uuid4(), transport="wifi", integration="example", manufacturer=None, - integration_data=stored, + id=uuid4(), physical_id=uuid4(), name="Lamp", room_id=uuid4(), transport="wifi", integration="example", + manufacturer=None, integration_data=stored, ) # fmt: skip async with repository.session() as repo: await repo.save(seeded) @@ -207,7 +230,7 @@ async def test_data_filled_in_place_on_a_state_saved_onto_a_record_holding_none_ state.integration_data.firmware = "1.2" await repo.save(state) - kept = await repo.get(seeded.id, as_=Device[_Learned]) + kept = await repo.get(physical_id=seeded.physical_id, as_=Device[_Learned]) assert kept is not None and kept.integration_data.firmware == "1.2" @@ -255,9 +278,12 @@ async def test_scope_hides_foreign_reads(make_scoped): async with make_scoped(None).session() as repo: await repo.save(foreign) async with make_scoped("mine").session() as repo: - assert await repo.state(foreign.id) is None - assert await repo.get(foreign.id) is None - assert await repo.get_parameter_state(foreign.id, foreign.parameters[0].id) is None + assert await repo.state(physical_id=foreign.physical_id) is None + assert await repo.get(physical_id=foreign.physical_id) is None + assert ( + await repo.get_parameter_state(physical_id=foreign.physical_id, parameter_id=foreign.parameters[0].id) + is None + ) async def test_sqlite_persists_across_sessions(tmp_path, device_state): @@ -267,5 +293,11 @@ async def test_sqlite_persists_across_sessions(tmp_path, device_state): # A brand-new repository object over the same file still sees the device. reopened = SqliteDeviceRepository(tmp_path / "devices.db") async with reopened.session() as session: - persisted = await session.state(device_state.id) + persisted = await session.state(physical_id=device_state.physical_id) assert persisted is not None and persisted.id == device_state.id + + +async def test_lookups_are_keyword_only(repository, device_state): + async with repository.session() as repo: + with pytest.raises(TypeError): + await repo.get(device_state.physical_id)