# MajorDom Docs > MajorDom documentation MajorDom is a self-hosted smart-home hub. This documentation covers building device integrations (Matter, Zigbee, HomeKit, etc.) and the Hub/Bridge/Cloud HTTP/WebSocket APIs. # Device Integration # MajorDom Device Integration Guide Warning MajorDom is still under development.\ The integration structure is now stable, but implementation details are still subject to change. An **integration** is a standalone Python package that bridges MajorDom Hub with IoT devices of a specific protocol or vendor (e.g. HomeKit, Zigbee, Z-Wave). It depends only on the published [`majordom-integration-sdk`](https://pypi.org/project/majordom-integration-sdk/) — not on the Hub source — so it can be developed, tested, and even run on its own. ### Getting started Don't start from a blank folder. Every integration is scaffolded from the **[`integration-template`](https://github.com/MajorDom-Systems/integration-template)** repository: click **Use this template → Create a new repository**, name it `integration-`, and follow the template README's checklist (rename the placeholder package to `majordom_`, `poe install`, wire up CI and PyPI publishing). The template ships a working example controller, tests, and CI, so you replace pieces rather than assemble them from scratch. This guide explains the concepts behind that code. ### Licensing The SDK and the official integrations are released under [PolyForm Noncommercial 1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0/) — free for noncommercial use. For commercial licensing or partnership, see . ______________________________________________________________________ ### Concepts | Term | Meaning | | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | **Hub** | The MajorDom Hub core software | | **Integration** | A protocol/vendor-specific plugin | | **Discovery Service** | A Hub-provided transport-level service (Zeroconf/mDNS, SSDP, or BLE) that fires raw discovery events. Injected via `self.dependencies`. | | **Controller** (capitalized) | The class your integration must implement (`AbstractController` subclass) | | **a controller** (lowercase) | Any third-party device that can control IoT devices (smartphone, Alexa, etc.) | | **Discovery** | A detected, unpaired device that is available to be paired | | **Device** | A paired and controllable device saved in the Hub's database | | **Parameter** | A single controllable or observable property of a device (e.g. brightness, temperature) | A device moves through these states: ```text Invisible → Discoverable (Discovery) → Paired (Device) ``` ______________________________________________________________________ ## Suggested Module Structure Your integration is its own package, `majordom_/` (the template starts you with this — `integration_template/` renamed). An integration will typically need more than just a controller. Recommended minimal layout: ```text majordom_myintegration/ ├── __init__.py # exports your controller class ├── controller.py # AbstractController subclass — the only required implementation; discovery callbacks and cancel closures are typically stored here too ├── models.py # Typed integration_data schemas for Device and Parameter subclasses, see Storing Data ├── mapper.py # Protocol ↔ MajorDom domain model conversions, isolated from the controller for readability └── parameters_map.py # Supplemental metadata for parameters that the API does not expose, usually in a form of a static dictionary. For example, device might expose min/max limits for a number via device's API, but the unit is only defined in pdf specification. ``` **`controller.py`** is the only required implementation. The rest are a template for keeping the controller clean — separate models, pull out conversion logic into a mapper, add metadata dictionary, etc. Of course, other files can be added as needed. ______________________________________________________________________ ## Implementation Checklist Track it in your repo: the [integration template](https://github.com/MajorDom-Systems/integration-template#progress) README ships this and the [Quality Checklist](https://docs.majordom.io/device-integration/quality/index.md) as a fillable copy — tick items there as you implement them. The list below is the reference. - [ ] Discovery service listeners fire when devices are found, and the controller calls `self.dependencies.output.controller_did_receive_discovery` - [ ] Discovery services registered via `self.dependencies.zeroconf_discovery_service`, `ssdp_discovery_service`, and/or `ble_discovery_service` as appropriate; cancel closures saved and called in `stop` - [ ] Discovery of devices already paired to the Hub on reconnect, e.g. after a reboot (`self.dependencies.output.controller_did_connect_device` is called) - [ ] `start_pairing_window` is implemented if the protocol requires an explicit scan mode - [ ] Device pairing (`pair_device` is implemented, and validates the incoming `ProvidedCredentials.type` against `discovery.expected_credentials_options` before using it) - [ ] Device schema is properly mapped: device info, parameter list, and each parameter's metadata are translated to MajorDom's domain model - [ ] Hub → Device control (`send_command` is implemented) - [ ] Device → Hub event subscription (`self.dependencies.output.controller_did_receive_events` is called on incoming events) - [ ] `identify`, `unpair`, and `fetch` are implemented - [ ] Paired devices going offline/coming back online *while the Hub is running* (not just on reboot) — set `device.available` accordingly, and clear/set `last_error` to match - [ ] Graceful shutdown in `stop` - [ ] **Quality:** once it's functional, the integration meets the [Quality Checklist](https://docs.majordom.io/device-integration/quality/index.md) — reliability, tests, and maintainability — before release See [Implementing a Controller](https://docs.majordom.io/device-integration/controller/index.md) for details, or [Example Integration](https://docs.majordom.io/device-integration/example-integration/index.md) for a narrative walkthrough of the template's controller. The template README mirrors this checklist as its **Progress** list, so you can track your integration against it directly in your repo. ## Running standalone You don't need a running Hub to develop an integration. The SDK ships a dev runner that wires your controller to real discovery services and a local repository, starts it, and logs everything it discovers: ```python import asyncio from majordom_integration_sdk.dev import run_controller from majordom_myintegration import MyController asyncio.run(run_controller(MyController)) ``` Pass `db_path=...` to persist devices across restarts (a file-backed SQLite repository) instead of the default in-memory one. See `majordom_integration_sdk.dev` for `build_dependencies`, which returns the same dependency set for your own scripts. ## Testing Integrations run their tests against a virtual/simulated device where possible, so CI doesn't need physical hardware. The SDK's `majordom_integration_sdk.testing` module provides the doubles: `build_test_dependencies()` wires a `RecordingControllerOutput`, an in-memory repository, and fake discovery services, so a test can drive your controller and assert on what it reported. The template's `tests/` show the pattern. (Real-hardware validation lives in the Hub, not in the integration package.) ## Notes ### For IP Devices - **Handle IP changes.** DHCP can reassign addresses. Identify devices by a stable ID (MAC, serial, mDNS hostname, domain-provided id) rather than IP. Monitor ip address regularly and keep it up to date. - **Use the Hub's provided discovery.** Register your mDNS service types via `self.dependencies.zeroconf_discovery_service.register(...)`, and similar for SSDP and BLE. See [Discovery Services](https://docs.majordom.io/device-integration/discovery/index.md) for details. Do not spin up your own discovery stack unless absolutely needed. # Implementing a Controller Start from the template Scaffold your integration from the [`integration-template`](https://github.com/MajorDom-Systems/integration-template) (**Use this template → Create a new repository**), and read the [Example Integration](https://docs.majordom.io/device-integration/example-integration/index.md) walkthrough alongside this page — it puts every piece below together in one worked controller. ## AbstractController Overview Your integration's controller subclasses [`AbstractController`](https://github.com/MajorDom-Systems/integration-sdk/tree/master/majordom_integration_sdk/controller/abstract_controller.py) from the SDK. It is generic over your `Device` and `Parameter` subclasses: ```python from majordom_integration_sdk.controller import AbstractController from majordom_integration_sdk.schemas import Device, Parameter class MyController(AbstractController[Device, Parameter]): name = "My Protocol" # optional — auto-derived from the class name ("My") otherwise ... ``` ### The `name` and `slug()` identity Declare `name` as a **class attribute** (not a property): ```python class MyController(AbstractController[Device, Parameter]): name = "Hue" ``` The Hub reads `name` — and the derived `slug()` (a `@final` classmethod returning the URL-safe slug, e.g. `"hue"`) — off the *class*, before it ever constructs the controller, to wire that integration's documents folder and scoped repository. Because of that, `name` must be a class attribute; the SDK raises a `TypeError` pointing here if it's missing. Read `name_slug`/`slug()` anywhere you need the stable identifier. ### Hub → Device Implement all abstract methods defined in [`AbstractController`](https://github.com/MajorDom-Systems/integration-sdk/tree/master/majordom_integration_sdk/controller/abstract_controller.py) — `start`, `stop`, `pair_device`, `unpair`, `identify`, `fetch`, `send_command`, and the `discoveries` property. The Hub calls these to drive your integration. ### Device → Hub Use `self.dependencies.output` (a `ControllerOutput`), injected by the Hub: ```python # New unpaired device found during discovery await self.dependencies.output.controller_did_receive_discovery(self, discovery) # An already-discovered (unpaired) device changed its advertisement await self.dependencies.output.controller_did_update_discovery(self, discovery) # A previously-discovered device is no longer visible await self.dependencies.output.controller_did_lose_discovery(self, discovery_id) # Device successfully paired and connected (also call on reconnect after reboot) await self.dependencies.output.controller_did_connect_device(self, device_id) # Device reported new parameter values or events await self.dependencies.output.controller_did_receive_events(self, events) # A user-facing problem — a CTA, or (still_running=False) a failure that leaves the # integration inactive. Plain language for the user; technical detail goes to the logs. await self.dependencies.output.controller_did_encounter_error( self, "The Zigbee stick is unplugged — re-plug it and restart the integration", still_running=False ) ``` ### `last_error` Both `Discovery` and `Device` carry a `last_error: str | None` field. **The integration owns this field entirely** — the Hub only stores and exposes it. | Situation | Action | | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Connection attempt fails | Set `last_error` to a human-readable message and call the appropriate output method (`controller_did_receive_discovery`, `controller_did_update_discovery`, etc.) | | Device fetch or command fails | Set `discovery.last_error` / `device.last_error` accordingly | | Error is resolved | Set `last_error = None` explicitly — the Hub never clears it automatically | ```python # On failure — surface the error to the user discovery.last_error = "Connection timed out" await self.dependencies.output.controller_did_update_discovery(self, discovery) # On recovery — clear it explicitly discovery.last_error = None await self.dependencies.output.controller_did_update_discovery(self, discovery) ``` `Device.last_error` is persisted in the database, so stale errors survive restarts. Make sure to clear it whenever your integration successfully recovers, otherwise the error remains visible until explicitly resolved. The natural places to clear it depend on the error kind — for connection errors, clear on successful connection or successful fetch; for discovery errors, clear when the device re-appears or its advertisement updates successfully. In general: wherever you can confirm the condition that caused the error no longer holds, that's where the `None` assignment belongs. `last_error` is not limited to connection failures — use it for any condition worth surfacing to the user: authentication failures, unsupported firmware versions, misconfiguration, rate limiting, or any other integration-level problem. If the user should see it, put it here. ### `controller_did_encounter_error` `last_error` is *persistent per-device/discovery state* — a flag on one device that stays until you clear it. `controller_did_encounter_error` is complementary: a **one-off, user-facing notification**, and the way to report a **controller-level** problem that isn't tied to a single device. ```python async def controller_did_encounter_error( self, message: str, still_running: bool, ): ... ``` - **`message`** — plain language for the user, never a traceback. Put technical detail in the logs; put here only what the user should see, and fold any call-to-action right into the text. - **`still_running`** — `False` when the controller can no longer run. The Hub marks the integration **inactive** and tells the user it failed (and what to do, if actionable) — no technical dump. Use `True` for a problem you've handled and recovered from but still want the user to know about (e.g. a user-fixable misconfiguration). #### Which one do I use? There are three places an error can go. Pick by asking who it's for and how long it should stay: | Surface | Use it for | How long it lives | | -------------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------- | | `Device.last_error` / `Discovery.last_error` | a problem with one device | stays until *you* clear it | | `controller_did_encounter_error` | the whole integration has a problem; `still_running=False` = it failed and stopped | one-off notification | | logs (`logger.*`) | technical detail, for you the developer | log retention | **What about a parameter failing?** Parameters don't have an error field, on purpose. If one parameter fails (a rejected command, an unreadable value), tell the user at the *device* level and name the parameter in the message: `device.last_error = "Brightness could not be set — the device rejected the command"`. Status codes and tracebacks go to the logs. Two habits: - **User surfaces get plain language** — never a traceback or a protocol code. - **One incident usually goes to two places**: a detailed log line for you, plus one user-facing surface. Logs-only is fine only when the user can't see or fix anything. ```python # Recoverable, but the user must act: await self.dependencies.output.controller_did_encounter_error( self, "Matter server unreachable — check that the matter-server add-on is running", still_running=True ) # Fatal — the integration can't continue: await self.dependencies.output.controller_did_encounter_error( self, "The Zigbee coordinator is not responding and the integration has stopped.", still_running=False ) ``` ### The `discoveries` property Return your current in-memory cache of unpaired, visible devices. The Hub polls this — do not trigger scanning here. ```python @property def discoveries(self) -> dict[UUID, Discovery]: return self._discoveries # maintained elsewhere, e.g. in a discovery event callback ``` The `UUID` for a given physical device must remain stable as long as it is visible. ### `start_pairing_window(duration_sec)` Optional. Override only if your protocol requires an explicit short-lived scan mode to surface new devices (e.g. Zigbee permit-join, BLE burst scan). Always-on discovery (mDNS, SSDP) does not need this. Default is a no-op. ## Injected Dependencies The Hub populates `self.dependencies` (see [`AbstractController.Dependencies`](https://github.com/MajorDom-Systems/integration-sdk/tree/master/majordom_integration_sdk/controller/abstract_controller.py)) before calling `start()`. | Field | Type | Description | | ---------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------ | | `output` | `ControllerOutput` | Callback object for pushing events to the Hub | | `make_device_repository` | `Callable[[], AsyncContextManager[DeviceRepository]]` | Factory for the device repository | | `zeroconf_discovery_service` | `ZeroconfDiscoveryService` | Shared mDNS/Zeroconf discovery service | | `ssdp_discovery_service` | `SSDPDiscoveryService` | Shared SSDP discovery service | | `ble_discovery_service` | `BLEDiscoveryService` | Shared BLE scanner service | | `hardware_interfaces` | `list[str]` | OS-level hardware interface paths assigned to this integration (e.g. `/dev/ttyACM0`) | ## Helper Methods `AbstractController` provides these `@final` helpers. Do not override them. ```python @property def name_slug(self) -> str: ... # URL-safe slug of self.name; stable integration identifier def integration_uuid(self) -> UUID: ... # Stable UUID for this integration, derived from name_slug def device_uuid(self, device_id: str) -> UUID: ... # Stable UUID for a device given any unique string (MAC, serial, etc.) def parameter_uuid(self, device_uuid: UUID, parameter_id: str) -> UUID: ... # Stable UUID for a parameter scoped to its device @property def documents_folder(self) -> Path: ... # Path to this integration's dedicated file storage directory ``` For example ```python path = self.documents_folder / "zigbee.db" ``` Use the built-in uuid generators to make deterministic UUIDs — no DB lookup needed, IDs stay consistent across restarts and re-pairings. Discovery `id` isn't required to match Device `id`, but this is recommended. ```python device_id = self.device_uuid(device_mac) param_id = self.parameter_uuid(device_id, f"{accessory_id}.{characteristic_id}") ``` ## See Also - [`integration-template`](https://github.com/MajorDom-Systems/integration-template) — scaffold your repo with **Use this template**, then fill in the controller - [Storing Data](https://docs.majordom.io/device-integration/storing_data/index.md) — `integration_data`, typed schemas, file storage, and the device repository - [Data Models Reference](https://docs.majordom.io/device-integration/data_models/index.md) — `Discovery`, `Device`, `Parameter`, and related types - [Example Integration](https://docs.majordom.io/device-integration/example-integration/index.md) — a full pseudo-code controller putting all of the above together, to copy as a starting point # Storing Data ## integration_data MajorDom's Device and Parameter schemas expose an `integration_data` field for storing protocol-specific state (pairing tokens, internal IDs, etc.). By default it is an untyped dict persisted as JSON. Integrations can subclass Device/DeviceState and Parameter/ParameterState to declare a typed schema for that field — Hub will then handle (de-)serialization automatically before passing Device instance to Controller's methods or when saving to the database. ```python # majordom_myintegration/models.py from majordom_integration_sdk.schemas.base import Base from majordom_integration_sdk.schemas.device import Device, DeviceState from majordom_integration_sdk.schemas.parameter import Parameter, ParameterState class MyDeviceIntegrationData(Base): pairing_token: str | None = None class MyDevice(Device): integration_data: MyDeviceIntegrationData class MyParameterState(ParameterState): integration_data: MyParameterIntegrationData ``` To make the Hub use these custom types, the integration's Controller must override `device_type` and `parameter_type`: ```python @property def device_type(self) -> type[MyDevice]: return MyDevice @property def parameter_type(self) -> type[MyParameter]: return MyParameter ``` The Hub will deserialize objects into these types before passing them to your implemented methods. ### Repository Use `self.dependencies.make_device_repository` to read or persist devices: ```python async with self.dependencies.make_device_repository() as repo: device = await repo.get(device_id, as_=MyDevice) # `as_=MyDevice` is optional but provides hassle-free deserialization async with self.dependencies.make_device_repository() as repo: device.integration_data.some_field = new_value # assuming `MyDevice.integration_data` uses custom class with `some_field` present await repo.save(device) ``` ## File Storage (`documents_folder`) For files that cannot be stored in the database (e.g. a protocol's own SQLite DB, certificates, binary blobs), use `self.documents_folder`: ```python path = self.documents_folder / "zigbee.db" ``` Resolves to a dedicated directory for this integration under the Hub's data root, created automatically on first write. # Data Models Reference ## Pairing ```python class CredentialsType(str, Enum): code = "code" # pin, e.g. 123-45-678 (HomeKit) or 1234-123-1234 (Matter) secret = "secret" # e.g. AES key like in ESPHome qr = "qr" # raw QR data none = "none" def with_mask(self, code_mask: str) -> CredentialsType: """ mask format: D as digit placeholder, other symbols like dashes remain unchanged, e.g. "DDD-DD-DDD" for "123-45-678" """ self.code_mask = code_mask return self type CredentialsValue = str class ProvidedCredentials(Base): type: CredentialsType value: CredentialsValue | None = None class Discovery(Base): # technical id: UUID integration: NonEmptyStr expected_credentials_options: list[CredentialsType] expiration: datetime | None = None # for UX transport: NonEmptyStr device_manufacturer: str | None device_name: NonEmptyStr device_category: str | None device_icon: str | None last_error: str | None = None # set by the integration on failure, cleared (None) on recovery ``` A discovery advertises every credentials type it can actually be paired with — `expected_credentials_options` — since a device can support more than one simultaneously (e.g. a Matter device with both a QR code and a manual pairing code). List every option the device supports, in the order you'd like the app to prefer them. Pairing requests then send back `ProvidedCredentials`, pairing the actual value with which type it is, instead of a bare string. **Your `pair_device()` must validate `credentials.type` is one of `discovery.expected_credentials_options` before using it** — don't trust `discovery.credentials` from whatever your own discovery-time heuristic guessed; validate against what the caller explicitly asserts. ## Device ```python class DeviceInfo(DevicePatch): id: UUID name: str note: str = "" icon: str | None = None category: str | None = None room_id: UUID transport: str integration: str manufacturer: str | None last_seen: datetime | None = None available: bool = False last_error: str | None = None # persisted; set by the integration on failure, cleared (None) on recovery main_parameter: UUID | None = None # for the tap action on the room view, toggle in most cases class Device(DeviceInfo): integration_data: SerializeAsAny[dict | Base] = Field(default_factory=Base) class DeviceState(DeviceInfo): parameters: list[ParameterState] ``` ## Parameter ```python class ParameterDataType(StrEnum): none = "none" # e.g. button bool = "bool" integer = "integer" decimal = "decimal" # python float enum = "enum" # integer with string representation string = "string" struct = "struct" # multi-field object (e.g. command arguments); see "Commands with arguments" data = "data" # binary data, base64 encoded at high level class ParameterUnit(StrEnum): plain = "plain" percentage = "percentage" second = "second" hertz = "hertz" kilogram = "kilogram" arcdegree = "arcdegree" meters = "meters" mps = "mps" # meters per second mps2 = "mps2" # meters per second squared rpm = "rpm" newton = "newton" joule = "joule" watt = "watt" celsius = "celsius" kelvin = "kelvin" volt = "volt" ampere = "ampere" lux = "lux" pascal = "pascal" ppm = "ppm" # parts per million, air quality bytes = "bytes" bps = "bps" # bytes per second class ParameterRole(StrEnum): sensor = 'sensor' # read-only control = 'control' # read-write event = 'event' # fire-and-forget class ParameterVisibility(StrEnum): user = "user" # main, everyday interaction, device screen widgets (on/off, brightness, volume) setting = "setting" # user-configurable but behind am extra "settings"/"advanced" tap: configured once and rarely touched again; or diagnostic readings (RSSI, firmware version) system = "system" # hidden under-the-hood wirings; not visible to the user # Generic over its value type V — value, valid_values keys, and default_value all share V # (an int parameter has int labels and an int default; a str parameter is str throughout). class Parameter[V](UUIdentifable): id: UUID name: str description: str | None = None # manufacturer-provided, read-only note: str | None = None # user-editable, MajorDom-side only data_type: ParameterDataType unit: ParameterUnit = ParameterUnit.plain role: ParameterRole visibility: ParameterVisibility min_value: int | float | None = None max_value: int | float | None = None min_step: int | float | None = None # smallest increment for a numeric parameter valid_values: dict[V, str] | None = None # allowed values → labels; for enums (numbers: use min/max/step) fields: list["Parameter"] | None = None # sub-parameters for data_type=struct (e.g. command args) default_value: set[V] | V | None = None # main-parameter tap value(s): one = button, a set = cycle integration_data: Any # protocol-specific payload, opaque to the Hub @property def can_be_main_parameter(self) -> bool: return self.visibility == ParameterVisibility.user and bool( self.data_type in (ParameterDataType.bool, ParameterDataType.none) or self.default_value is not None or self.valid_values ) class ParameterState[V](Parameter[V]): value: V | None = None # pythonic value (int/bool/str/float/dict/...), not bytes ``` ### `main_parameter` and `default_value` `Device.main_parameter` points at the one `Parameter` behind the room-tile tap (a toggle in most cases — e.g. `OnOff` for a light, not `Brightness`). A parameter is eligible (`can_be_main_parameter`) when it's `user`-visible and a tap does something meaningful: a `bool` or `none` command inherently, an `enum` through its `valid_values`, or — for any data type — a `default_value`. `default_value` is what makes an arbitrary parameter tappable (any data type) and curates what a tap sends — set it to one value for a button, or a set for a cycle (e.g. brightness `{0, 80}` so a dimmer taps like an on/off switch). It shares the parameter's value type `V`. (`valid_values` only *describes* a parameter's allowed values and their labels — it doesn't carry the tap value.) See [Parameter UX](https://docs.majordom.io/device-integration/parameter-ux/#the-one-tap-main-parameter) for how each case behaves on tap and how to pick the main parameter and good visibility. ### Commands with arguments Some integrations (e.g. Matter) expose commands that take a list of arguments — for those, the command itself is modeled as a `Parameter` and each of its arguments as a nested `Parameter` in `fields`: **command = parameter, argument = sub-parameter**. This is a convention on top of the generic schema, not a separate concept. If your integration's commands don't take structured arguments, you leave `fields` unset. # Example Integration This page is a **narrative walkthrough** of the example controller that ships in the [`integration-template`](https://github.com/MajorDom-Systems/integration-template) repository. The template is the source of truth for the actual, CI-verified code — start there with **Use this template**, then read this page to understand *why* each piece looks the way it does. The example isn't real protocol code — there's no such thing as "AcmeProtocol" or "AcmeClient". Every piece that would normally come from a real device library is marked `# pseudo` so you know it's made up. Everything else — module layout, method names, signatures, how the pieces call into each other — matches the real SDK interfaces described in [Implementing a Controller](https://docs.majordom.io/device-integration/controller/index.md), [Storing Data](https://docs.majordom.io/device-integration/storing_data/index.md), and [Data Models Reference](https://docs.majordom.io/device-integration/data_models/index.md). Read this page *alongside* those, not instead of them. If this is your first integration, don't worry about understanding every line the first time through. Scaffold from the template, rename `Acme` to your protocol, and start replacing the `# pseudo` bits with calls into your actual device library one at a time. More examples This page is a *pedagogical* walkthrough. For real, production integrations to learn from — HomeKit, Zigbee, Matter, and more — browse the official MajorDom integrations at [github.com/orgs/MajorDom-Systems/repositories?q=integration-](https://github.com/orgs/MajorDom-Systems/repositories?q=integration-). ## The controller at a glance Before the details, here's the whole shape of a finished controller — every method you implement, in one place. Bodies are elided (`...`); the sections group them by direction of data flow. If you're new to OOP or SDKs, read this as the "table of contents" for the rest of the page. ```python class MyController(AbstractController[MyDevice, MyParameter]): name = "My Protocol" # optional — auto-derived from the class name otherwise # ── Lifecycle ──────────────────────────────────────────────────────────── async def start(self) -> None: ... # connect, start discovery, restore already-paired devices async def stop(self) -> None: ... # cancel tasks, stop discovery, close connections # ── Discovery (Device → Hub) ──────────────────────────────────────────── @property def discoveries(self) -> dict[UUID, Discovery]: ... # cached snapshot of unpaired devices # when a device appears, call: # await self.dependencies.output.controller_did_receive_discovery(self, discovery) # ── Pairing ────────────────────────────────────────────────────────────── async def start_pairing_window(self, duration_sec: int) -> None: ... # optional (e.g. Zigbee) async def pair_device(self, discovery, credentials) -> UUID: ... # commission + map schema async def unpair(self, device: MyDevice) -> None: ... # ── Control & read (Hub → Device) ─────────────────────────────────────── async def send_command(self, command, device, parameter) -> None: ... # set a value / run command async def fetch(self, device: MyDevice) -> None: ... # re-read all parameters async def identify(self, device: MyDevice) -> None: ... # blink / beep the device # ── Events (Device → Hub) ─────────────────────────────────────────────── # on an incoming device update, call: # await self.dependencies.output.controller_did_receive_events(self, [DeviceParameterChange(...)]) ``` ## Module Layout Your integration is a standalone package (`majordom_/`), scaffolded from the template: ```text majordom_acme/ ├── __init__.py # exports AcmeController ├── controller.py # AbstractController subclass — the only file the Hub actually calls into ├── models.py # typed integration_data schemas — what MajorDom persists to disk for you └── mapper.py # protocol <-> MajorDom conversions — pure functions, no I/O ``` ## `models.py` — typed `integration_data` `integration_data` is a free-form field MajorDom lets every integration attach to its own `Device`/`Parameter` records, for protocol-specific bookkeeping (an auth token, an internal id, etc.). Subclass the SDK schemas to give it a typed shape — the Hub then (de)serializes it for you. Here we remember the device's network **hostname** (not IP — see the discovery note below) and an auth token the Acme protocol handed us during pairing: ```python from majordom_integration_sdk.schemas.base import Base from majordom_integration_sdk.schemas.device import Device, DeviceState from majordom_integration_sdk.schemas.parameter import Parameter, ParameterState class AcmeDeviceIntegrationData(Base): device_hostname: str # a hostname, resolved to the current IP by the OS auth_token: str | None = None class AcmeParameterIntegrationData(Base): acme_property_name: str # the name Acme's own API uses for this property # Device/DeviceState and Parameter/ParameterState are pairs: the "State" version carries the # same fields plus the actual live value(s). You need both — see Storing Data for why. class AcmeDevice(Device): integration_data: AcmeDeviceIntegrationData class AcmeDeviceState(DeviceState): integration_data: AcmeDeviceIntegrationData class AcmeParameter(Parameter): integration_data: AcmeParameterIntegrationData class AcmeParameterState(ParameterState): integration_data: AcmeParameterIntegrationData ``` To make the Hub hand you these subclasses (with `integration_data` already parsed), the controller overrides `device_type`/`parameter_type` — see [Storing Data](https://docs.majordom.io/device-integration/storing_data/#integration_data). ## `mapper.py` — pure conversions Keep protocol↔domain conversions here: pure functions that turn "whatever your device library gives you" into MajorDom's `Parameter`/`Device` types. No I/O, no `self.dependencies`, nothing that touches the network or database — so you (or a test) can call them directly without a real device. A property's `kind` maps to a `ParameterDataType`; whether it's `writable` maps to `ParameterRole.control` vs `ParameterRole.sensor`: ```python from majordom_integration_sdk.schemas.parameter import ParameterDataType, ParameterRole, ParameterVisibility from .models import AcmeParameter, AcmeParameterIntegrationData class AcmeMapper: def parameter_from_acme_property(self, parameter_id, acme_property: dict) -> AcmeParameter: data_type = ParameterDataType.bool if acme_property["kind"] == "bool" else ParameterDataType.integer role = ParameterRole.control if acme_property["writable"] else ParameterRole.sensor return AcmeParameter( id=parameter_id, name=acme_property["name"], data_type=data_type, role=role, visibility=ParameterVisibility.user, # show on the device's main screen; see Data Models for setting/system integration_data=AcmeParameterIntegrationData(acme_property_name=acme_property["name"]), ) ``` `device_uuid`/`parameter_uuid` are helpers on `AbstractController` ([Helper Methods](https://docs.majordom.io/device-integration/controller/#helper-methods)) — call them from the controller and pass the resulting ids into the mapper, since the mapper has no access to `self`. ## `controller.py` — the walkthrough The controller is the only file the Hub instantiates. It declares its identity as a **class attribute** and subclasses the generic `AbstractController`: ```python from majordom_integration_sdk.controller import AbstractController from .models import AcmeDevice, AcmeParameter class AcmeController(AbstractController[AcmeDevice, AcmeParameter]): name = "Acme" # class-level identity — the Hub reads it before constructing you _mapper = AcmeMapper() # stateless, so one shared instance is fine ``` ### Lifecycle: `start` / `stop` `start()` runs once when the Hub boots (or when the integration is enabled); `stop()` on shutdown. Two things happen in `start()`: register for discovery (keep the returned **cancel closure** for `stop`), and — crucially — **reconnect to already-paired devices**. On every Hub restart, `start()` is your only chance to do that; skip it and paired devices look "stuck" until re-paired. `controller_did_connect_device` isn't just for "just finished pairing" — call it any time a device becomes reachable, including here at boot, to flip `device.available` back to `True`: ```python async def start(self): self._discoveries = {} self._client = AcmeClient() # pseudo self._cancel_discovery = self.dependencies.zeroconf_discovery_service.register(self, {"_acme._tcp.local."}) async with self.dependencies.make_device_repository() as repo: for device in await repo.get_all(self.name, AcmeDevice): self._subscribe(device) await self.dependencies.output.controller_did_connect_device(self, device.id) async def stop(self): self._cancel_discovery() await self._client.close() # pseudo ``` ### Discovery callbacks: identify by hostname, never by IP The Zeroconf service calls three listener methods (`zeroconf_did_discover_service`, `..._update_service`, `..._remove_service`) because you passed `self` to `register()`. Derive the **stable** discovery id from `info.name` (the mDNS instance name), which stays constant across discover/update/remove for the same physical device — **not** from its IP address: > If you keyed the id off the IP, a routine DHCP lease renewal would change the address and the Hub would think a *brand-new* device appeared instead of recognizing the one it already knew. Storing a **hostname** in `integration_data` (rather than an IP) sidesteps this entirely: the OS resolves the hostname to the current IP for you, so a paired device changing IP requires no work at all. If you must store an IP, you'd have to update it *and* re-verify reachability on every update event. `_update_service` only reports a device still in your `discoveries` dict (an unpaired device whose advertisement changed); `_remove_service` gives you only `name`, so pop by the same derived id and call `controller_did_lose_discovery`. ### `pair_device`: validate credentials, then transition the id **Always validate** that the credentials actually sent match the discovery's advertised options — never trust your own discovery-time guess: ```python async def pair_device(self, discovery: Discovery, credentials: ProvidedCredentials | None): if not credentials or credentials.type not in discovery.expected_credentials_options: raise ValueError("Unsupported or missing credentials for this discovery") acme_device = await self._client.pair(discovery.id, code=credentials.value) # pseudo device_id = self.device_uuid(acme_device.serial) # permanent, hardware-level id ... ``` The device now has a permanent id, distinct from its (temporary) discovery id. Fetch the placeholder row the Hub created under `discovery.id`, set the real `device.id` and `integration_data`, map its parameters, then `await repo.save(device, discovery.id)` — passing the old id **renames** the row from the discovery id to the new device id in one call. Finally drop it from `discoveries`, `_subscribe`, and report `controller_did_connect_device`. ### `unpair` / `identify` / `fetch` Thin wrappers over your client: `unpair` tells the device/your bookkeeping to forget the pairing (the Hub removes its own DB row); `identify` makes the device blink/beep so the user can find it; `fetch` pulls a fresh snapshot of every parameter and reports them together as a batch of `DeviceParameterChange` events via `controller_did_receive_events`. ### `send_command`: Hub → device Called when the user changes something in the app. `command.value` is the new value; `parameter` tells you which property — look up the protocol's own name from your `integration_data`: ```python async def send_command(self, command: DeviceCommand, device: AcmeDevice, parameter: AcmeParameter): await self._client.set_property( device.integration_data.device_hostname, parameter.integration_data.acme_property_name, command.value, ) # pseudo ``` ### `_subscribe`: device → Hub Called once per device — after pairing and once per already-paired device at boot. Whenever the device's own state changes, report it. Because protocol callbacks are usually plain (non-async), you schedule the async report as a task: ```python from majordom_integration_sdk.schemas.event import DeviceParameterChange def _subscribe(self, device: AcmeDevice): def on_property_changed(acme_property_name: str, value): event = DeviceParameterChange( device_id=device.id, parameter_id=self.parameter_uuid(device.id, acme_property_name), value=value, ) asyncio.create_task(self.dependencies.output.controller_did_receive_events(self, [event])) def on_availability_changed(is_available: bool): # Optional mid-session availability: report only if your protocol can detect a *paired* # device dropping/returning while the Hub runs. If it can't, omit this entirely. cb = (self.dependencies.output.controller_did_connect_device if is_available else self.dependencies.output.controller_did_lose_device) asyncio.create_task(cb(self, device.id)) self._client.subscribe(device.integration_data.device_hostname, on_property_changed=on_property_changed, on_availability_changed=on_availability_changed) # pseudo ``` ## What's Deliberately Left Out The example skips a few things your real integration will likely need, so it doesn't obscure the shape of the interface itself: - **Error handling** — set `discovery.last_error` / `device.last_error` on failures (see [Implementing a Controller: `last_error`](https://docs.majordom.io/device-integration/controller/#last_error)). - **`start_pairing_window`** — only implement it if your protocol needs an explicit, short-lived scan mode (Zigbee permit-join, BLE burst scan). Always-on discovery (mDNS, SSDP) doesn't need it. - **Retry/backoff, connection pooling, rate limiting** — whatever your protocol's client needs; the SDK prescribes nothing here. - **Implementing the protocol yourself** — the example assumes `AcmeClient` already exists as a high-level SDK (`pair`, `get_properties`, `set_property`, ...). Real protocols don't always have one; if yours doesn't, you may implement the transport, connection management, and serialization directly inside your integration. That's expected. The framework (`AbstractController`, the discovery services, the `Device`/`Parameter` models) is deliberately independent of any protocol's implementation details — and, discovery services and data models in particular being plain public SDK, can be reused standalone (e.g. to build a protocol library outside MajorDom entirely). ## Testing Test your controller against simulated devices with the SDK's `majordom_integration_sdk.testing` doubles — `build_test_dependencies()` plus `RecordingControllerOutput` — rather than real hardware. See [Testing](https://docs.majordom.io/device-integration/#testing) and the template's own `tests/` for the pattern. # Discovery Services Discovery services are injected into every controller via `self.dependencies`. They abstract transport-level device advertisement into a unified listener pattern, so your integration never needs to manage its own mDNS, SSDP, or BLE scanner. Three services are available: | Service | Protocol | Dependency field | | -------------------------- | ---------------------------- | ---------------------------------------------- | | `ZeroconfDiscoveryService` | mDNS / DNS-SD | `self.dependencies.zeroconf_discovery_service` | | `SSDPDiscoveryService` | UPnP SSDP | `self.dependencies.ssdp_discovery_service` | | `BLEDiscoveryService` | Bluetooth Low Energy (Bleak) | `self.dependencies.ble_discovery_service` | ## Common Pattern All three services share the same usage contract: - Call `register(...)` to subscribe. It returns a **cancel closure**. - Store the cancel closure and call it in your controller's `stop()`. - The services are already started and stopped by the Hub — do not call `start()` or `stop()` on them yourself. ______________________________________________________________________ ## Combining Multiple Discovery Services A controller can register with multiple discovery protocols simultaneously, or with a few service types using the same protocol. Usage doesn't change. ```python async def start(self): self._cancels: set[Callable[[], None]] = set() self._cancels.add(self.dependencies.zeroconf_discovery_service.register( self, {"_myprotocol._tcp.local."} )) self._cancels.add(self.dependencies.ble_discovery_service.register( self, {MY_SERVICE_UUID} )) async def stop(self): for cancel in self._cancels: cancel() ``` # BLE (Bluetooth Low Energy) Discovery ## `BLEDiscoveryService` Available as `self.dependencies.ble_discovery_service`. The scanner runs persistently via Bleak's `BleakScanner`. You only need to register listeners; no explicit scanning calls are needed during normal operation. ### `register` ```python def register( listener: BLEDiscoveryListener, service_ids: set[UUID], ) -> Callable[[], None]: ... ``` | Arg | Type | Description | | ------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------- | | `listener` | `BLEDiscoveryListener` | Receives discovery events. | | `service_ids` | `set[UUID]` | BLE service UUIDs to filter on. Only advertisements containing at least one of these UUIDs trigger callbacks. | Returns a cancel closure — call it in `stop()` to deregister. ______________________________________________________________________ ## `BLEDiscoveryListener` (Protocol) ```python class BLEDiscoveryListener(Protocol): async def ble_did_discover_device(self, ble: BLEDiscoveryService, info: BLEDiscoveryInfo): ... async def ble_did_update_device(self, ble: BLEDiscoveryService, info: BLEDiscoveryInfo): ... async def ble_did_remove_device(self, ble: BLEDiscoveryService, info: BLEDiscoveryInfo): ... ``` `ble_did_update_device` fires when the advertisement signature changes (local name, service UUIDs, manufacturer data, or service data). RSSI changes alone do not trigger updates. `ble_did_remove_device` fires when no advertisement is received within the removal grace window (~11 s by default). ______________________________________________________________________ ## `BLEDiscoveryInfo` ```python @dataclass class BLEDiscoveryInfo: device: BLEDevice advertisement: AdvertisementData ``` | Field | Type | Description | | --------------- | ------------------- | -------------------------------------------------------------------------------------- | | `device` | `BLEDevice` | Bleak device object (`.address`, `.name`, `.details`) | | `advertisement` | `AdvertisementData` | Latest advertisement data (RSSI, manufacturer data, service data, service UUIDs, etc.) | ______________________________________________________________________ ## Example ```python _MY_SERVICE_UUIDS = { UUID("cba20d00-224d-11e6-9fb8-0002a5d5c51b"), } # in Controller ... async def start(self): self._cancels: set[Callable[[], None]] = set() cancel = self.dependencies.ble_discovery_service.register( self, # self implements BLEDiscoveryListener _MY_SERVICE_UUIDS, ) self._cancels.add(cancel) async def stop(self): for cancel in self._cancels: cancel() # BLEDiscoveryListener Implementation async def ble_did_discover_device(self, ble: BLEDiscoveryService, info: BLEDiscoveryInfo): if discovery := self._mapper.map_to_discovery(info): self._discoveries[discovery.id] = discovery await self.dependencies.output.controller_did_receive_discovery(self, discovery) async def ble_did_update_device(self, ble: BLEDiscoveryService, info: BLEDiscoveryInfo): if discovery := self._mapper.map_to_discovery(info): self._discoveries[discovery.id] = discovery await self.dependencies.output.controller_did_update_discovery(self, discovery) async def ble_did_remove_device(self, ble: BLEDiscoveryService, info: BLEDiscoveryInfo): discovery_id = self.device_uuid(info.device.address) if discovery_id in self._discoveries: self._discoveries.pop(discovery_id) await self.dependencies.output.controller_did_lose_discovery(self, discovery_id) ... ``` # SSDP / UPnP Discovery ## `SSDPDiscoveryService` Available as `self.dependencies.ssdp_discovery_service`. ### `register` ```python def register( self, listener: SSDPDiscoveryListener, search_target: str, mcast_group: str = "239.255.255.250", port: int = 1900, ) -> Callable[[], None]: ... ``` | Arg | Type | Default | Description | | --------------- | ----------------------- | ------------------- | ------------------------------------------ | | `listener` | `SSDPDiscoveryListener` | required | Receives discovery events. | | `search_target` | `str` | required | SSDP ST field. See common templates below. | | `mcast_group` | `str` | `"239.255.255.250"` | Multicast group. Change only if necessary. | | `port` | `int` | `1900` | SSDP port. Change only if necessary. | Returns a cancel closure — call it in `stop()` to deregister. **Common `search_target` values:** | ST | Discovers | | --------------------------------------------- | ----------------------------- | | `"upnp:rootdevice"` | All UPnP root devices | | `"ssdp:all"` | All UPnP devices and services | | `"urn:schemas-upnp-org:device:MediaServer:1"` | UPnP MediaServer devices | | `"uuid:"` | A specific device by UUID | ______________________________________________________________________ ## `SSDPDiscoveryListener` (Protocol) ```python class SSDPDiscoveryListener(Protocol): async def ssdp_did_discover_service(self, ssdp: SSDPDiscoveryService, info: SSDPDiscoveryInfo): ... async def ssdp_did_update_service(self, ssdp: SSDPDiscoveryService, info: SSDPDiscoveryInfo): ... async def ssdp_did_remove_service(self, ssdp: SSDPDiscoveryService, info: SSDPDiscoveryInfo): ... ``` `ssdp_did_remove_service` fires when a device sends a `ssdp:byebye` NOTIFY, or when a device fails to respond to several consecutive M-SEARCH bursts (controlled by `_missed_scans_evict`, default 3 missed scans). ______________________________________________________________________ ## `SSDPDiscoveryInfo` ```python @dataclass class SSDPDiscoveryInfo: addr: str host: str | None search_target: str | None service_name: str | None # USN (Unique Service Name) server: str | None cache_control: str | None location: str | None # URL to the device description XML response: dict[str, str] # full raw response headers ``` ______________________________________________________________________ ## Example ```python # in Controller ... async def start(self): self._cancels: set[Callable[[], None]] = set() cancel = self.dependencies.ssdp_discovery_service.register( self, # self implements SSDPDiscoveryListener "urn:schemas-upnp-org:device:MediaServer:1", ) self._cancels.add(cancel) async def stop(self): for cancel in self._cancels: cancel() # SSDPDiscoveryListener Implementation async def ssdp_did_discover_service(self, ssdp: SSDPDiscoveryService, info: SSDPDiscoveryInfo): if discovery := self._mapper.map_to_discovery(info): self._discoveries[discovery.id] = discovery await self.dependencies.output.controller_did_receive_discovery(self, discovery) async def ssdp_did_update_service(self, ssdp: SSDPDiscoveryService, info: SSDPDiscoveryInfo): if discovery := self._mapper.map_to_discovery(info): self._discoveries[discovery.id] = discovery await self.dependencies.output.controller_did_update_discovery(self, discovery) async def ssdp_did_remove_service(self, ssdp: SSDPDiscoveryService, info: SSDPDiscoveryInfo): discovery_id = self.device_uuid(info.service_name or info.addr) if discovery_id in self._discoveries: self._discoveries.pop(discovery_id) await self.dependencies.output.controller_did_lose_discovery(self, discovery_id) ... ``` # Zeroconf (mDNS / DNS-SD / Bonjour) Discovery ## `ZeroconfDiscoveryService` Available as `self.dependencies.zeroconf_discovery_service`. ### `register` ```python def register( listener: ZeroconfDiscoveryListener, services: set[str], ) -> Callable[[], None]: ... ``` | Arg | Type | Description | | ---------- | --------------------------- | -------------------------------------------------------------------- | | `listener` | `ZeroconfDiscoveryListener` | Receives discovery events | | `services` | `set[str]` | Fully-qualified mDNS service type strings, e.g. `"_hap._tcp.local."` | Returns a cancel closure — call it in `stop()` to deregister. ### `async_zeroconf` ```python async_zeroconf: AsyncZeroconf | None ``` The underlying `AsyncZeroconf` instance, available after the service starts. Use only when you need low-level zeroconf access. ______________________________________________________________________ ## `ZeroconfDiscoveryListener` (Protocol) ```python class ZeroconfDiscoveryListener(Protocol): async def zeroconf_did_discover_service( self, zeroconf: ZeroconfDiscoveryService, info: ZeroconfDiscoveryInfo, ): ... async def zeroconf_did_update_service( self, zeroconf: ZeroconfDiscoveryService, info: ZeroconfDiscoveryInfo, ): ... async def zeroconf_did_remove_service( self, zeroconf: ZeroconfDiscoveryService, type_: str, name: str, ): ... ``` Note: `zeroconf_did_remove_service` fires on mDNS goodbye packets (TTL=0) or when a silent departure is confirmed: A/AAAA records expired and a follow-up probe got no response. Typical latency for silent departures is ~120 s (A/AAAA TTL). For faster detection, implement liveness checks at the pairing/connection layer. ______________________________________________________________________ ## `ZeroconfDiscoveryInfo` ```python @dataclass class ZeroconfDiscoveryInfo: type_: str name: str server: str | None port: int | None addresses: list[bytes] | None parsed_addresses: list[str] | None weight: int | None priority: int | None properties: dict[bytes, bytes | None] decoded_properties: dict[str, str | None] text: bytes host_ttl: int | None other_ttl: int | None interface_index: int | None ``` Key fields: | Field | Type | Description | | -------------------- | ---------------------------- | --------------------------------------------------------------------------- | | `type_` | `str` | Fully qualified service type (e.g. `_hap._tcp.local.`) | | `name` | `str` | Fully qualified service name | | `server` | `str \| None` | Hostname of the service | | `port` | `int \| None` | Port the service listens on | | `parsed_addresses` | `list[str] \| None` | IP addresses as strings | | `decoded_properties` | `dict[str, str \| None]` | TXT record key-value pairs decoded to `str`; use this for most integrations | | `properties` | `dict[bytes, bytes \| None]` | Raw TXT record bytes | | `text` | `bytes` | Raw TXT record bytes (full) | | `interface_index` | `int \| None` | IPv6 zone id / scope id | `__eq__` excludes TTL fields — safe for change detection. ______________________________________________________________________ ## Example ```python # in Controller ... async def start(self): self._cancels: set[Callable[[], None]] = set() cancel = self.dependencies.zeroconf_discovery_service.register( self, # self implements ZeroconfDiscoveryListener {"_hap._tcp.local."}, ) self._cancels.add(cancel) async def stop(self): for cancel in self._cancels: cancel() # ZeroconfDiscoveryListener Implementation async def zeroconf_did_discover_service(self, zeroconf: ZeroconfDiscoveryService, info: ZeroconfDiscoveryInfo): if discovery := self._mapper._map_to_discovery(info): self._discoveries[discovery.id] = discovery await self.dependencies.output.controller_did_receive_discovery(self, discovery) async def zeroconf_did_update_service(self, zeroconf: ZeroconfDiscoveryService, info: ZeroconfDiscoveryInfo): if discovery := self._mapper._map_to_discovery(info): self._discoveries[discovery.id] = discovery await self.dependencies.output.controller_did_update_discovery(self, discovery) async def zeroconf_did_remove_service(self, zeroconf: ZeroconfDiscoveryService, type_: str, name: str): if discovery_id := self._mapper._name_to_id(name) and discovery_id in self._discoveries: self._discoveries.pop(discovery_id, None) await self.dependencies.output.controller_did_lose_discovery(self, discovery_id) ... ``` # API ### [Open Full Page](https://virtualhub.majordom-dev.parker-programs.com/api/docs) ### [Open Full Page](https://bridge.majordom.parker-programs.com/docs) ### [Open Full Page](https://api.majordom.parker-programs.com/docs) # integration-sdk — full source # repo: https://github.com/MajorDom-Systems/integration-sdk branch: master commit: 96938cc6ffe0a4e81ad415834941b464c0e2bb92 # generated: 2026-07-24 — AUTO-GENERATED, do not edit by hand ## File tree .gitignore LICENSE README.md majordom_integration_sdk/__init__.py majordom_integration_sdk/controller/__init__.py majordom_integration_sdk/controller/abstract_controller.py majordom_integration_sdk/dev/__init__.py majordom_integration_sdk/dev/cli.py majordom_integration_sdk/discovery/__init__.py majordom_integration_sdk/discovery/ble_discovery.py majordom_integration_sdk/discovery/ssdp_discovery.py majordom_integration_sdk/discovery/zeroconf_discovery.py majordom_integration_sdk/parameter_audit.py majordom_integration_sdk/repository/__init__.py majordom_integration_sdk/repository/_record.py majordom_integration_sdk/repository/memory.py majordom_integration_sdk/repository/protocol.py majordom_integration_sdk/repository/sqlite.py majordom_integration_sdk/schemas/__init__.py majordom_integration_sdk/schemas/base.py majordom_integration_sdk/schemas/command.py majordom_integration_sdk/schemas/device.py majordom_integration_sdk/schemas/event.py majordom_integration_sdk/schemas/notification.py majordom_integration_sdk/schemas/parameter.py majordom_integration_sdk/spec_drift.py majordom_integration_sdk/testing/__init__.py pyproject.toml tests/conftest.py tests/test_cli.py tests/test_controller.py tests/test_dev.py tests/test_main_parameter.py tests/test_notification.py tests/test_parameter_audit.py tests/test_repository.py tests/test_schemas.py tests/test_spec_drift.py ================================================================================ # FILE: .gitignore ================================================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] *$py.class # C extensions *.so # Distribution / packaging build/ dist/ *.egg-info/ *.egg # Unit test / coverage reports .coverage .coverage.* coverage.xml htmlcov/ .pytest_cache/ .hypothesis/ # Environments .env .envrc .venv env/ venv/ # Poetry # poetry.lock # uncomment for applications, keep commented for libraries # Type checkers .mypy_cache/ .dmypy.json .pyre/ .pytype/ # Tools .ruff_cache/ .ropeproject # mkdocs (only relevant if you kept mkdocs.yml + docs/) /site .cache/ # IDEs .idea/ .vscode/ # OS .DS_Store Thumbs.db ================================================================================ # FILE: LICENSE ================================================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================================================ # FILE: README.md ================================================================================ # majordom-integration-sdk Models, protocols, and tooling for building [MajorDom](https://majordom.io) integrations. An integration bridges an external protocol or platform (Matter, Zigbee, HomeKit, a vendor cloud, …) into the MajorDom language. This SDK is the contract: the schemas, the `AbstractController` framework, the device-repository protocol, discovery services, and a standalone dev runner so you can build and test an integration **without a running Hub**. The Hub itself depends on this same package — first-party integrations use no private shortcut. ## Install ```sh pip install majordom-integration-sdk ``` Requires Python 3.12+. ## Also a standalone IoT toolkit You don't need MajorDom to get value from this package. `majordom_integration_sdk.discovery` ships three fully working, standardized device-discovery services you can drop into **any** async Python project: - **Zeroconf / mDNS** (`ZeroconfDiscoveryService`) — browse Bonjour/mDNS services with a clean listener protocol, and — unusually — **reliable disappearance tracking**: it augments raw zeroconf with an active cache-poll + confirm-evict loop so you get accurate "device went away" events, not just arrivals. - **SSDP / UPnP** (`SSDPDiscoveryService`) — flexible, per-listener **configurable** search targets and refresh behavior over raw sockets, without dragging in a full UPnP stack. - **BLE** (`BLEDiscoveryService`) — a uniform listener interface over Bleak for advertisement scanning. All three share one `register(listener, …) → cancel` shape, run standalone (each module has a runnable `__main__` demo), and are a solid reference for implementing **standardized interfaces over messy IoT protocols** even if you never touch MajorDom. The `AbstractController` framework and the `DeviceRepositoryProtocol` + in-memory/SQLite implementations are equally reusable as a small, typed device-modelling toolkit. ## Documentation Full integration-author docs — models, the controller lifecycle, storing data, discovery, and a worked example — live at **[docs.majordom.io](https://docs.majordom.io/device-integration)**. The fastest way to start a new integration is the [integration-template](https://github.com/MajorDom-Systems/integration-template) repository (**Use this template → Create a new repository**). ## License See [LICENSE](LICENSE). For commercial licensing or partnership inquiries, contact us via [parker-industries.org/partnership](https://parker-industries.org/partnership). --- ## Development Setup: ```sh poetry install && poetry run poe install ``` This installs dependencies and the pre-commit hook that runs `poe check`. | Task | Description | |------|-------------| | `poe install` | Install deps and register the pre-commit hook | | `poe check` | Full quality pipeline (ruff, ty, pytest, poetry build/check) | | `poe check --ci` | Same, plus `git diff --exit-code` | Two long-lived branches: `develop` (integration branch, all work lands here) and `master` (protected, released). Releases are cut from `develop` via **Actions → Release**; see [ParkerIndustries/workflows](https://github.com/ParkerIndustries/workflows) for the CI/CD. ================================================================================ # FILE: majordom_integration_sdk/__init__.py ================================================================================ """MajorDom Integration SDK — models, protocols, and tooling for building integrations. Public submodules: - ``majordom_integration_sdk.schemas`` — the wire protocol (pydantic models shared with the Hub) - ``majordom_integration_sdk.controller`` — the ``AbstractController`` framework + ``ControllerOutput`` - ``majordom_integration_sdk.repository`` — ``DeviceRepositoryProtocol`` + in-memory / SQLite implementations - ``majordom_integration_sdk.discovery`` — zeroconf / SSDP / BLE discovery services - ``majordom_integration_sdk.testing`` — shared test doubles for integration test suites - ``majordom_integration_sdk.dev`` — standalone runner for developing an integration without the Hub The names an integration author reaches for most are re-exported here for convenience, e.g. ``from majordom_integration_sdk import AbstractController, Device``. """ from majordom_integration_sdk.controller import AbstractController, ControllerOutput from majordom_integration_sdk.repository import ( DeviceRepositoryMemory, DeviceRepositoryProtocol, SqliteDeviceRepository, ) from majordom_integration_sdk.schemas.command import DeviceCommand from majordom_integration_sdk.schemas.device import Device, Discovery, Parameter, ProvidedCredentials from majordom_integration_sdk.schemas.event import DeviceParameterChange, Event __version__ = "0.1.0" __all__ = [ "AbstractController", "ControllerOutput", "DeviceRepositoryProtocol", "DeviceRepositoryMemory", "SqliteDeviceRepository", "DeviceCommand", "Device", "Discovery", "Parameter", "ProvidedCredentials", "Event", "DeviceParameterChange", ] ================================================================================ # FILE: majordom_integration_sdk/controller/__init__.py ================================================================================ """The integration controller framework — the interface the Hub drives.""" from .abstract_controller import AbstractController, ControllerOutput __all__ = ["AbstractController", "ControllerOutput"] ================================================================================ # FILE: majordom_integration_sdk/controller/abstract_controller.py ================================================================================ """ Entrypoint and lifecycle boundary for an integration's Controller. See docs.majordom.io/device-integration for the full integration guide. """ from __future__ import annotations import re from abc import ABC, abstractmethod from collections.abc import Callable, Iterable from contextlib import AbstractAsyncContextManager from dataclasses import dataclass, field, replace from pathlib import Path from typing import ClassVar, Protocol, cast, final from uuid import UUID, uuid5 from slugify import slugify from majordom_integration_sdk.discovery.ble_discovery import BLEDiscoveryService from majordom_integration_sdk.discovery.ssdp_discovery import SSDPDiscoveryService from majordom_integration_sdk.discovery.zeroconf_discovery import ZeroconfDiscoveryService from majordom_integration_sdk.repository.protocol import DeviceRepositoryProtocol from majordom_integration_sdk.schemas.command import DeviceCommand from majordom_integration_sdk.schemas.device import Device, Discovery, Parameter, ProvidedCredentials from majordom_integration_sdk.schemas.event import Event from majordom_integration_sdk.schemas.notification import Notification _NAME_HELP = ( "The integration name is auto-derived from your controller class name; set a class-level " '`name` only to override it (e.g. `name = "ZigBee"`). ' "See the example integration at https://docs.majordom.io/device-integration" ) def _titleize(class_name: str) -> str: """Human-readable integration name derived from a controller class name. Strips a trailing "Controller" and splits CamelCase/PascalCase into spaced words: ``HueController`` -> ``"Hue"``, ``ZigBeeController`` -> ``"Zig Bee"``. Set an explicit ``name`` on the subclass to override when you want different casing (e.g. ``"ZigBee"``). """ base = class_name[: -len("Controller")] if class_name.endswith("Controller") else class_name words = re.findall(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+", base) return " ".join(words) or base class ControllerOutput(Protocol): """ Defines the callback interface for passing data from an integration back to the Hub. An instance is injected by Hub into every controller via Dependencies. """ 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_lose_device(self, controller: AbstractController, device_id: UUID): """Paired device became unreachable while the Hub is running (not just at startup/reboot).""" ... async def controller_did_receive_events(self, controller: AbstractController, events: Iterable[Event]): """Report device-domain events (e.g. DeviceParameterChange) observed by the controller.""" ... async def controller_did_encounter_error( self, controller: AbstractController, message: str, still_running: bool, ): """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 radio); developer/technical detail goes to the logs instead. ``message`` is plain language — include any call-to-action right in the text. Problems scoped to one device belong on that device's ``last_error``, not here. ``still_running`` is ``False`` when the controller can no longer run and is now inactive — the Hub reflects it as a failed integration; the user is told it failed (and why, if actionable) without a technical dump. """ ... async def controller_did_emit_notification(self, controller: AbstractController, 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, a routine finished. The ``Notification`` carries the message plus its callout ``type`` (info/warning/error), delivery ``priority``, and ``ttl`` (seconds before auto-dismiss). """ ... class AbstractController[TDevice: Device, TParameter: Parameter](ABC): """ Base class for all integration controllers. Defines the interface the Hub uses to interact with an integration — the Hub instantiates the subclass and drives it by calling these methods. """ def __init_subclass__(cls, **kwargs) -> None: super().__init_subclass__(**kwargs) # Auto-derive `name` from the class name unless the subclass set one explicitly. if "name" not in cls.__dict__: cls.name = _titleize(cls.__name__) @dataclass(frozen=True) class Dependencies: """ Standardized dependencies injected by the Hub into every controller on construction. """ output: ControllerOutput make_device_repository: Callable[[], AbstractAsyncContextManager[DeviceRepositoryProtocol]] # The directory this integration may write files into that don't belong in the # device database (certificates, protocol caches, …). The Hub computes and injects # it per integration; the standalone dev runner points it at a local folder. documents_folder: Path # FUTURE: consider DI container zeroconf_discovery_service: ZeroconfDiscoveryService ssdp_discovery_service: SSDPDiscoveryService ble_discovery_service: BLEDiscoveryService hardware_interfaces: list[str] = field(default_factory=list) def copy(self, **kwargs) -> AbstractController.Dependencies: return replace(self, **kwargs) def __init__(self, dependencies: Dependencies): """ Called by the Hub with the injected dependencies. Do not change this signature. """ self.dependencies = dependencies # Abstract @property @abstractmethod def discoveries(self) -> dict[UUID, Discovery]: """ Returns the current set of unpaired, discoverable devices as a cached snapshot. The ID for a given physical device must remain stable. Do not trigger any scanning here — only return already-cached data. """ # TODO: save datetime and remove expired return {} name: ClassVar[str] """ Human-readable name of the integration (e.g. "HomeKit", "ZigBee"). Auto-derived from the controller class name by default (``HueController`` -> "Hue", ``ZigBeeController`` -> "Zig Bee"), so you usually don't set it. Set it on your subclass only to override the casing/wording: class ZigBeeController(AbstractController[Device, Parameter]): name = "ZigBee" It identifies the integration, so it's a constant of the class rather than per-instance state — the Hub reads it (and `slug()`) off the class to wire an integration's dependencies *before* the controller exists. """ # TODO: review if these two are needed, test the implementation @property 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 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.""" return cast(type[TParameter], Parameter) # Lifecycle @abstractmethod async def start(self): """ Starts the integration. Called once on Hub startup, or when the integration is manually enabled. Register discovery services, subscribe to events, and check the status of already-paired devices here. """ @abstractmethod async def stop(self): """ Stops the integration. Called once on Hub shutdown, or when the integration is manually disabled. Cancel running tasks and release all held resources. """ # Hub -> device async def start_pairing_window(self, duration_sec: int): # noqa: B027 (optional no-op hook, not abstract) """ Temporarily enables protocol-level discovery mechanisms that are not continuously active. Must only be used when default continuous discovery options aren't available or are insufficient. Does not affect, nor does use always-on discovery channels such as mDNS or SSDP. Used to trigger short-lived scan/inquiry modes for transports that require explicit activation (e.g. Zigbee permit-join style discovery, BLE scan bursts, proprietary radios). Devices discovered during this window may be eligible for onboarding via the pairing API. If devices are not successfully paired within the window, they must be disconnected. """ pass @abstractmethod async def pair_device(self, discovery: Discovery, credentials: ProvidedCredentials | None): """ Pairs a discovered device to the Hub. Called when the user initiates pairing from the UI. """ @abstractmethod async def unpair(self, device: TDevice): """ Unpairs a device from the Hub. Called when the user initiates removal from the UI. """ @abstractmethod async def identify(self, device: TDevice): """ Asks the device to produce an identifying signal (e.g. blink a light, play a sound). Called when the user triggers identification from the UI. """ @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. """ @abstractmethod async def send_command(self, command: DeviceCommand, device: TDevice, parameter: TParameter): """ Sends a control command targeting a specific device parameter. Called by the Hub when the user or an automation changes a parameter value. """ # helpers / services @final @classmethod def slug(cls) -> str: """Slugified name of the integration. Class-level, so the Hub can key an integration's storage on it before constructing the controller. """ slug = slugify(cls.name) if not slug: raise ValueError( f"{cls.__name__}'s name {cls.name!r} has no letters or digits to build a slug from. {_NAME_HELP}" ) return slug @final @property def name_slug(self) -> str: """Slugified name of the integration""" return type(self).slug() @final def namespace_uuid(self) -> UUID: """Namespace UUID for the integration - used to generate device and parameter UUIDs""" return UUID(int=0) # TODO: consider basing it on hub's mac address @final def integration_uuid(self) -> UUID: """Unique identifier of the integration""" return uuid5(self.namespace_uuid(), self.name_slug) @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) @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) @final @property def documents_folder(self) -> Path: """Folder for storing files related to this integration that don't belong in the device database.""" return self.dependencies.documents_folder # def add_service_coroutine(self, coroutine: Callable[[], Awaitable]): ... # FUTURE # def add_service_thread(self, thread: Thread): ... # FUTURE # def schedule_task(self, callback: Callable[[], Awaitable] | Callable[[], Any], interval: float): ... # FUTURE # def search_native_parameter(self, name: str) -> Parameter | None: ... # FUTURE ================================================================================ # FILE: majordom_integration_sdk/dev/__init__.py ================================================================================ """Run one integration controller standalone — no Hub process. This is the "run standalone, then bridge into the MajorDom language" half of the integration workflow: wire a single controller with real discovery services and a local repository, start it, and watch what it discovers/reports on the console. Modeled on the discovery services' own ``__main__`` demos rather than the Hub's multi-controller CLI. import asyncio from majordom_integration_sdk.dev import run_controller from majordom_hue import HueController # your integration asyncio.run(run_controller(HueController)) """ from __future__ import annotations import asyncio import logging from collections.abc import Iterable from pathlib import Path from uuid import UUID from majordom_integration_sdk.controller.abstract_controller import AbstractController, ControllerOutput from majordom_integration_sdk.discovery.ble_discovery import BLEDiscoveryService from majordom_integration_sdk.discovery.ssdp_discovery import SSDPDiscoveryService from majordom_integration_sdk.discovery.zeroconf_discovery import ZeroconfDiscoveryService from majordom_integration_sdk.repository.memory import DeviceRepositoryMemory from majordom_integration_sdk.repository.protocol import DeviceRepositoryProtocol from majordom_integration_sdk.repository.sqlite import SqliteDeviceRepository from majordom_integration_sdk.schemas.device import Discovery from majordom_integration_sdk.schemas.event import Event from majordom_integration_sdk.schemas.notification import Notification logger = logging.getLogger("majordom_integration_sdk.dev") class LoggingControllerOutput(ControllerOutput): """A ``ControllerOutput`` that logs every callback — the Hub's stand-in for dev runs.""" async def controller_did_receive_discovery(self, controller: AbstractController, discovery: Discovery): logger.info("discovered %s (%s) id=%s", discovery.device_name, discovery.transport, discovery.id) async def controller_did_update_discovery(self, controller: AbstractController, discovery: Discovery): logger.info("updated discovery %s", discovery.id) async def controller_did_lose_discovery(self, controller: AbstractController, discovery_id: UUID): logger.info("lost discovery %s", discovery_id) async def controller_did_connect_device(self, controller: AbstractController, device_id: UUID): logger.info("connected device %s", device_id) async def controller_did_lose_device(self, controller: AbstractController, device_id: UUID): logger.info("lost device %s", device_id) async def controller_did_receive_events(self, controller: AbstractController, events: Iterable[Event]): for event in events: logger.info("event %s: %r", type(event).__name__, event) async def controller_did_encounter_error( self, controller: AbstractController, message: str, still_running: bool, ): state = "still running" if still_running else "STOPPED — integration inactive" logger.error("integration error (%s): %s", state, message) async def controller_did_emit_notification(self, controller: AbstractController, notification: Notification): logger.info( "notification [%s/%s]: %s", notification.type.value, notification.priority.value, notification.message ) def build_dependencies( *, storage_root: Path | None = None, db_path: str | Path | None = None, integration: str | None = None, slug: str | None = None, output: ControllerOutput | None = None, ) -> AbstractController.Dependencies: """Assemble real (network-live) dependencies for a standalone run. ``db_path`` selects a file-backed :class:`SqliteDeviceRepository` (state survives restarts); omit it for an in-memory repository. ``integration`` scopes the repository to that integration's devices and gives it its own ``documents_folder`` subtree. The storage root defaults to ``./.majordom-dev``. """ repository: DeviceRepositoryProtocol repository = ( SqliteDeviceRepository(db_path, integration=integration) if db_path is not None else DeviceRepositoryMemory(integration=integration) ) root = Path(storage_root) if storage_root is not None else Path(".majordom-dev") subfolder = slug or integration folder = root / subfolder if subfolder else root folder.mkdir(parents=True, exist_ok=True) return AbstractController.Dependencies( output=output or LoggingControllerOutput(), make_device_repository=repository.session, documents_folder=folder, zeroconf_discovery_service=ZeroconfDiscoveryService(), ssdp_discovery_service=SSDPDiscoveryService(), ble_discovery_service=BLEDiscoveryService(), ) async def run_controller( controller_type: type[AbstractController], *, storage_root: Path | None = None, db_path: str | Path | None = None, ) -> None: """Instantiate ``controller_type``, start it and its discovery services, and run until interrupted (Ctrl-C), then stop everything cleanly.""" logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") # The integration's identity is class-level, so its repository and documents folder can be # scoped before it's constructed. deps = build_dependencies( storage_root=storage_root, db_path=db_path, integration=controller_type.name, slug=controller_type.slug(), ) controller = controller_type(deps) await deps.zeroconf_discovery_service.start() await deps.ssdp_discovery_service.start() await deps.ble_discovery_service.start() await controller.start() logger.info("%s started — watching for devices. Ctrl-C to stop.", controller.name) try: await asyncio.Event().wait() # run forever until cancelled except (KeyboardInterrupt, asyncio.CancelledError): pass finally: await controller.stop() await deps.ble_discovery_service.stop() await deps.ssdp_discovery_service.stop() await deps.zeroconf_discovery_service.stop() logger.info("%s stopped.", controller.name) def __getattr__(name: str): # `run_cli` (interactive REPL) needs the optional `cli` extra — typer-shell + rich. Import it # lazily so `dev` (and `run_controller`) stay usable without those dependencies installed. if name == "run_cli": try: from .cli import run_cli except ImportError as exc: # pragma: no cover - only when the extra is missing raise ImportError( "The interactive CLI needs extra dependencies — install them with:\n" ' pip install "majordom-integration-sdk[cli]"' ) from exc return run_cli raise AttributeError(f"module {__name__!r} has no attribute {name!r}") __all__ = ["run_controller", "run_cli", "build_dependencies", "LoggingControllerOutput"] ================================================================================ # FILE: majordom_integration_sdk/dev/cli.py ================================================================================ """Interactive REPL for running one integration standalone — the ``majordom-integration-sdk[cli]`` extra. Ported from the MajorDom Hub's operator CLI and scoped to a single controller: start it with real discovery services and a local repository, then pair / control / inspect devices from a prompt. Where :func:`run_controller` just *watches*, this lets you *drive* the integration by hand. Needs the ``cli`` extra (typer-shell + rich):: pip install "majordom-integration-sdk[cli]" import asyncio from majordom_integration_sdk.dev import run_cli from majordom_matter import MatterController # your integration asyncio.run(run_cli(MatterController, db_path="devices.db")) """ from __future__ import annotations import asyncio import builtins import json import logging import shutil import sys from datetime import datetime from pathlib import Path from uuid import UUID import rich from typer_shell import make_typer_shell from ..controller.abstract_controller import AbstractController, ControllerOutput from ..schemas.command import DeviceCommand from ..schemas.device import CredentialsType, ProvidedCredentials from ..schemas.event import DeviceParameterChange from ..schemas.parameter import ParameterRole from . import build_dependencies logger = logging.getLogger("majordom_integration_sdk.dev") # --- prompt-safe output ------------------------------------------------------------------------ # The catch with an interactive REPL: the shell is blocked reading a line in a worker thread, so # anything printed from elsewhere (discoveries/events on the event loop, or a stray log line) lands # *on the prompt line* and eats what the user is typing. Print above the prompt and restore the # cursor instead. Ported from the Hub CLI's `println`; falls back to a plain print when stdout # isn't a TTY (pipes, tests, logs to a file) where the escape codes would just be noise. def _println(msg: object) -> None: if not sys.stdout.isatty(): rich.print(msg) return builtins.print("\x1b[s\x1b[1A\x1b[999D\x1b[1S\x1b[L", end="", flush=True) # save, up, home, scroll, insert-line rich.print(msg, end="", flush=True) builtins.print("\x1b[u", end="", flush=True) # restore cursor to the prompt def _print(*values: object, sep: str = " ") -> None: """Prompt-safe print: wrap to the terminal width and emit each line via :func:`_println`.""" columns, _ = shutil.get_terminal_size((80, 20)) for line in sep.join(str(v) for v in values).split("\n"): if not line: _println("") for i in range(0, len(line), columns or 80): _println(line[i : i + (columns or 80)]) class _CapturingHandler(logging.Handler): """Buffer every log record instead of emitting it, so chatty controllers / discovery / matter-server logs (and warnings, via ``logging.captureWarnings``) don't disrupt the interactive prompt. The whole buffer is dumped to stderr once on exit or crash — quiet TUI during the session, full logs for debugging after.""" def __init__(self): super().__init__() self.records: list[logging.LogRecord] = [] def emit(self, record: logging.LogRecord) -> None: self.records.append(record) def dump(self) -> None: if not self.records: return builtins.print("\n--- captured logs (CLI session) ---", file=sys.stderr, flush=True) for record in self.records: builtins.print(self.format(record), file=sys.stderr, flush=True) # --- controller-driving core (testable without the REPL) --------------------------------------- class _CliSession: """The id-based logic behind the CLI commands. Kept separate from the typer-shell wiring so it can be exercised directly against a fake controller in tests. Mirrors the Hub relay's object resolution (``repo.get`` + ``get_parameter_state`` + ``model_validate`` into the controller's ``device_type`` / ``parameter_type``).""" def __init__(self, controller: AbstractController): self._controller = controller self._deps = controller.dependencies @property def discoveries(self): return self._controller.discoveries async def pair(self, discovery_id: UUID, credentials_type: str = "none", credentials_value: str = ""): discovery = self._controller.discoveries.get(discovery_id) if not discovery: return None credentials = ProvidedCredentials(type=CredentialsType(credentials_type), value=credentials_value or None) return await self._controller.pair_device(discovery, credentials) async def pair_window(self, seconds: int) -> None: await self._controller.start_pairing_window(seconds) async def list_devices(self): async with self._deps.make_device_repository() as repo: return await repo.get_all() async def device_state(self, device_id: UUID): async with self._deps.make_device_repository() as repo: return await repo.state(device_id) async def _device(self, device_id: UUID): async with self._deps.make_device_repository() as repo: device = await repo.get(device_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)): 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)): 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)): 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 with self._deps.make_device_repository() as repo: device = await repo.get(command.device_id) if not device: return f"No device {device_id}" parameter = await repo.get_parameter_state(command.device_id, command.parameter_id) if not parameter: return f"No parameter {parameter_id} on device {device_id}" if parameter.role != ParameterRole.control: return f"Parameter {parameter_id} is not controllable (role={parameter.role.value})" await self._controller.send_command( command, self._controller.device_type.model_validate(device), self._controller.parameter_type.model_validate(parameter), ) return "Command sent" class _CliControllerOutput(ControllerOutput): """The CLI's own output delegate — a different approach from the logging one: it shows each callback through the prompt-safe printer (intentional TUI content, not logs) *and* persists state to the repository, so ``devices`` / ``device`` reflect live availability and values. A single-controller stand-in for the Hub relay's output side.""" def __init__(self, make_device_repository): self._make_device_repository = make_device_repository async def controller_did_receive_discovery(self, controller, discovery): _print(f"[discovered] {discovery.id} {discovery.device_name} ({discovery.transport})") async def controller_did_update_discovery(self, controller, discovery): _print(f"[discovery updated] {discovery.id}") 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 with self._make_device_repository() as repo: if device := await repo.get(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 with self._make_device_repository() as repo: if device := await repo.get(device_id): device.available = False device.last_error = f"Device is no longer connected to the {controller.name} network" await repo.save(device) async def controller_did_receive_events(self, controller, events): events = list(events) async with self._make_device_repository() as repo: for event in events: if not isinstance(event, DeviceParameterChange): continue if device := await repo.get(event.device_id): device.last_seen = datetime.now() await repo.save(device) if state := await repo.get_parameter_state(event.device_id, event.parameter_id): state.value = event.value await repo.save_parameter_state(event.device_id, state) for event in events: _print(f"[event] {type(event).__name__}: {event!r}") # --- the REPL ---------------------------------------------------------------------------------- async def run_cli( controller_type: type[AbstractController], *, storage_root: Path | None = None, db_path: str | Path | None = None, ) -> None: """Start ``controller_type`` (with real discovery + a local repository) and drop into an interactive prompt. ``db_path`` file-backs the repository so paired devices survive restarts; omit it for in-memory. ``exit`` / Ctrl-D leaves the prompt and stops everything cleanly.""" # Capture all logs + warnings into a buffer for the duration of the session so they don't break # the interactive TUI; the buffer is dumped to stderr on exit/crash (see the finally block). # The CLI's own discovery/event/command output (via _print) stays visible and prompt-safe. root = logging.getLogger() saved_handlers, saved_level = root.handlers[:], root.level captured = _CapturingHandler() captured.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s")) root.handlers[:] = [captured] root.setLevel(logging.DEBUG) logging.captureWarnings(True) loop = asyncio.get_running_loop() # Identity is class-level, so the repository/documents folder are scoped before construction; # then swap in an output that also persists state. deps = build_dependencies( storage_root=storage_root, db_path=db_path, integration=controller_type.name, slug=controller_type.slug(), ) deps = deps.copy(output=_CliControllerOutput(deps.make_device_repository)) controller = controller_type(deps) session = _CliSession(controller) await deps.zeroconf_discovery_service.start() await deps.ssdp_discovery_service.start() await deps.ble_discovery_service.start() await controller.start() _print(f"{controller.name} started — discovering. Type 'help' for commands, 'exit' to quit.") # Shell commands run in a worker thread (typer-shell blocks); hop back onto this loop to call # the controller's async API. def call(coro): return asyncio.run_coroutine_threadsafe(coro, loop).result() app = make_typer_shell( prompt=f"{controller.name.lower()} > ", intro=f"MajorDom {controller.name} standalone CLI. Type 'help' for commands, 'exit' to quit.", ) @app.command() def discoveries(): """List discoveries currently visible (id, name, transport, expected credentials).""" found = session.discoveries if not found: _print("(no discoveries yet)") for id, d in found.items(): expects = ", ".join(c.value for c in d.expected_credentials_options) _print(f"{id} {d.device_name} transport={d.transport} expects=[{expects}]") @app.command() def discovery(discovery_id: str): """Show full discovery info by id.""" d = session.discoveries.get(UUID(discovery_id)) _print(d.model_dump_json(indent=2) if d else f"No discovery {discovery_id}") @app.command() def pair(discovery_id: str, credentials_type: str = "none", credentials_value: str = ""): """Pair a discovered device. credentials_type must be one of the discovery's expected options (see `discovery`); credentials_value is the code/QR string when that type needs one.""" device_id = call(session.pair(UUID(discovery_id), credentials_type, credentials_value)) _print(f"Paired device {device_id}" if device_id else f"No discovery {discovery_id}") @app.command() def pair_window(seconds: int = 60): """Open a pairing/join window for `seconds` (Zigbee needs this; a no-op for integrations that discover continuously).""" call(session.pair_window(seconds)) _print(f"Pairing window open for {seconds}s") @app.command() def devices(): """List paired devices (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}") @app.command() def device(device_id: str): """List a paired device's parameters (id, name, role, visibility, value).""" state = call(session.device_state(UUID(device_id))) if not state: _print(f"No device {device_id}") return for p in state.parameters: _print(f"{p.id} {p.name} role={p.role.value} visibility={p.visibility.value} value={p.value!r}") @app.command() def identify(device_id: str): """Ask a device to identify itself (blink/beep).""" _print("identify sent" if call(session.identify(UUID(device_id))) else f"No device {device_id}") @app.command() def fetch(device_id: str): """Refresh a device's state from the device.""" _print("fetched" if call(session.fetch(UUID(device_id))) else f"No device {device_id}") @app.command() def unpair(device_id: str): """Remove a paired device.""" _print(f"Unpaired {device_id}" if call(session.unpair(UUID(device_id))) else f"No device {device_id}") @app.command() def control(device_id: str, parameter_id: str, value: str): """Send a command: device_id parameter_id value (value is parsed as JSON when possible, else used as a raw string). The target parameter must have the `control` role.""" try: parsed: object = json.loads(value) except json.JSONDecodeError: parsed = value _print(call(session.control(UUID(device_id), UUID(parameter_id), parsed))) # typer-shell parses sys.argv on start; clear it so a bare `run_cli()` doesn't inherit flags. sys.argv = sys.argv[:1] try: await loop.run_in_executor(None, app) except (KeyboardInterrupt, asyncio.CancelledError): pass finally: await controller.stop() await deps.ble_discovery_service.stop() await deps.ssdp_discovery_service.stop() await deps.zeroconf_discovery_service.stop() _print(f"{controller.name} stopped.") # restore logging and dump everything captured during the session for debugging logging.captureWarnings(False) root.handlers[:], root.level = saved_handlers, saved_level captured.dump() __all__ = ["run_cli"] ================================================================================ # FILE: majordom_integration_sdk/discovery/__init__.py ================================================================================ from .ble_discovery import BLEDiscoveryInfo, BLEDiscoveryListener, BLEDiscoveryService from .ssdp_discovery import SSDPDiscoveryInfo, SSDPDiscoveryListener, SSDPDiscoveryService from .zeroconf_discovery import ZeroconfDiscoveryInfo, ZeroconfDiscoveryListener, ZeroconfDiscoveryService __all__ = [ "BLEDiscoveryInfo", "BLEDiscoveryListener", "BLEDiscoveryService", "SSDPDiscoveryInfo", "SSDPDiscoveryListener", "SSDPDiscoveryService", "ZeroconfDiscoveryInfo", "ZeroconfDiscoveryListener", "ZeroconfDiscoveryService", ] ================================================================================ # FILE: majordom_integration_sdk/discovery/ble_discovery.py ================================================================================ import asyncio import logging from collections.abc import Callable from dataclasses import dataclass from time import monotonic from typing import Protocol from uuid import UUID from bleak import BleakScanner from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData logger = logging.getLogger(__name__) @dataclass class BLEDiscoveryInfo: device: BLEDevice advertisement: AdvertisementData class BLEDiscoveryListener(Protocol): async def ble_did_discover_device(self, ble: "BLEDiscoveryService", info: BLEDiscoveryInfo): ... async def ble_did_update_device(self, ble: "BLEDiscoveryService", info: BLEDiscoveryInfo): ... async def ble_did_remove_device(self, ble: "BLEDiscoveryService", info: BLEDiscoveryInfo): ... def _adv_signature(adv: AdvertisementData) -> tuple: """Stable advertisement fingerprint — avoids false updates from object identity changes.""" return ( adv.local_name, tuple(sorted(adv.service_uuids)), tuple(sorted((k, bytes(v)) for k, v in adv.manufacturer_data.items())), tuple(sorted((k, bytes(v)) for k, v in adv.service_data.items())), ) @dataclass class _BLETrackedDevice: info: BLEDiscoveryInfo last_seen_at: float # monotonic signature: tuple = () class BLEDiscoveryService: """Bleak-based BLE device discovery.""" def __init__(self): self._services: dict[frozenset[UUID], BLEDiscoveryListener] = {} self._is_running: bool = False self._removal_grace_sec: float = 11.0 # seconds since last seen before did_remove is fired self._eviction_interval_sec: float = 10.0 self._tracked: dict[str, _BLETrackedDevice] = {} # address -> tracked device self._scanner: BleakScanner | None = None self._tasks: list[asyncio.Task] = [] # FUTURE: filter type def register(self, listener: BLEDiscoveryListener, service_ids: set[UUID]) -> Callable[[], None]: """ Registers a listener for BLE devices advertising any of the given service UUIDs. `ble_did_discover_device` is called on each advertisement received matching the service UUIDs. Returns a cancel function — call it on stop to deregister. """ key = frozenset(service_ids) self._services[key] = listener def cancel(): self._services.pop(key, None) return cancel async def start(self): self._is_running = True self._scanner = BleakScanner(detection_callback=self._on_advertisement) # FUTURE: add support for BLE proxies await self._scanner.start() eviction_task = asyncio.create_task(self._eviction_loop()) eviction_task.add_done_callback(self._on_task_done) self._tasks = [eviction_task] logger.debug(f"BLE scanner started, {len(self._services)} service(s) registered") async def stop(self): self._is_running = False if self._scanner: await self._scanner.stop() self._scanner = None for task in self._tasks: task.cancel() self._tasks.clear() logger.debug(f"BLE scanner stopped, {len(self._services)} service(s) registered") def _on_task_done(self, task: asyncio.Task): if not task.cancelled() and (exc := task.exception()): logger.error(f"BLE task crashed: {exc}") def _on_advertisement(self, device: BLEDevice, adv_data: AdvertisementData): asyncio.create_task(self._handle_advertisement(device, adv_data)) async def _handle_advertisement(self, device: BLEDevice, adv_data: AdvertisementData): address = device.address info = BLEDiscoveryInfo(device, adv_data) sig = _adv_signature(adv_data) now = monotonic() prev = self._tracked.get(address) adv_changed = prev is not None and prev.signature != sig # update tracking before awaiting listeners — guards against concurrent advertisements # for the same address both appearing as new self._tracked[address] = _BLETrackedDevice(info=info, last_seen_at=now, signature=sig) tasks = [] for service_ids, listener in self._services.items(): if not any(str(sid) in adv_data.service_data or str(sid) in adv_data.service_uuids for sid in service_ids): continue # is_new is per-subscriber match: first time this device matched this filter matched_before = prev is not None and any( str(sid) in prev.info.advertisement.service_data or str(sid) in prev.info.advertisement.service_uuids for sid in service_ids ) if not matched_before: tasks.append((True, listener.ble_did_discover_device(self, info))) elif adv_changed: tasks.append((False, listener.ble_did_update_device(self, info))) if tasks: is_new = tasks[0][0] logger.debug(f"BLE {'discovered' if is_new else 'updated'}: {device.name} ({address})") await asyncio.gather(*(coro for _, coro in tasks)) async def _eviction_loop(self): while self._is_running: await asyncio.sleep(self._eviction_interval_sec) await self._evict_stale() async def _evict_stale(self): now = monotonic() for address, tracked in list(self._tracked.items()): if now - tracked.last_seen_at < self._removal_grace_sec: continue del self._tracked[address] tasks = [] for service_ids, listener in self._services.items(): adv_data = tracked.info.advertisement if any(str(sid) in adv_data.service_data or str(sid) in adv_data.service_uuids for sid in service_ids): tasks.append(listener.ble_did_remove_device(self, tracked.info)) if tasks: logger.debug(f"BLE removed: {tracked.info.device.name} ({address})") await asyncio.gather(*tasks) async def perform_scan(self): """Triggers an immediate eviction check. Scanner runs continuously — no manual scan needed.""" await self._evict_stale() # Example usage if __name__ == "__main__": # logging.basicConfig() # logger.setLevel(logging.DEBUG) async def main(): discovery = BLEDiscoveryService() class SwitchBotListener: async def ble_did_discover_device(self, ble: BLEDiscoveryService, info: BLEDiscoveryInfo): print(f"Found: {info.device.name} ({info.device.address})") async def ble_did_update_device(self, ble: BLEDiscoveryService, info: BLEDiscoveryInfo): print(f"Updated: {info.device.name} ({info.device.address})") async def ble_did_remove_device(self, ble: BLEDiscoveryService, info: BLEDiscoveryInfo): print(f"Removed: {info.device.name} ({info.device.address})") switchbot_services = { UUID("00000d00-0000-1000-8000-00805f9b34fb"), UUID("0000fd3d-0000-1000-8000-00805f9b34fb"), UUID("cba20d00-224d-11e6-9fb8-0002a5d5c51b"), } discovery.register(SwitchBotListener(), switchbot_services) await discovery.start() while True: await asyncio.sleep(1) asyncio.run(main()) ================================================================================ # FILE: majordom_integration_sdk/discovery/ssdp_discovery.py ================================================================================ import asyncio import logging import socket import struct from collections import defaultdict from collections.abc import Callable from dataclasses import dataclass, field from time import monotonic from typing import NamedTuple, Protocol logger = logging.getLogger(__name__) @dataclass class SSDPDiscoveryInfo: addr: str host: str | None search_target: str | None service_name: str | None server: str | None cache_control: str | None location: str | None response: dict[str, str] class SSDPDiscoveryListener(Protocol): async def ssdp_did_discover_service(self, ssdp: "SSDPDiscoveryService", info: "SSDPDiscoveryInfo"): ... async def ssdp_did_update_service(self, ssdp: "SSDPDiscoveryService", info: "SSDPDiscoveryInfo"): ... async def ssdp_did_remove_service(self, ssdp: "SSDPDiscoveryService", info: "SSDPDiscoveryInfo"): ... @dataclass class _KnownService: info: SSDPDiscoveryInfo last_seen_at: float # monotonic — updated on every scan reply @dataclass class _Subscriber: listener: SSDPDiscoveryListener search_target: str mcast_group: str port: int socket: socket.socket known: dict[str, _KnownService] = field(default_factory=dict) # USN -> _KnownService class _Endpoint(NamedTuple): mcast_group: str port: int @dataclass class _SocketSubscribers: scan_socket: socket.socket # ephemeral, unbound — sends M-SEARCH and reads unicast replies (blocking + timeout) search_subscribers: dict[str, list[_Subscriber]] = field( default_factory=lambda: defaultdict(list) ) # search_target -> list of Subscriber class SSDPDiscoveryService: def __init__(self): # Single dict: Endpoint -> SocketSubscribers(socket, [Subscriber]) self._endpoints_subscribers: dict[_Endpoint, _SocketSubscribers] = dict() self._is_running = False self._tasks: list[asyncio.Task] = [] self._mx = 2 # MX - maximum wait time (timeout) seconds. Used to even scan responses across time and reduce network load. self._scan_interval: float = 10.0 # seconds between M-SEARCH bursts self._missed_scans_evict: int = 3 # evict after this many consecutive scans with no reply self._ignorelist: set[str] = self._local_ips() # FUTURE: filter type def register( self, listener: SSDPDiscoveryListener, search_target: str, mcast_group: str = "239.255.255.250", port: int = 1900, ) -> Callable[[], None]: """ Args: listener: Object that will receive discovery events. search_target: SSDP ST (Search Target) field to specify the type of device or service to discover. Common ST templates: - "uuid:": discover a device by its UUID - "urn:schemas-upnp-org:device::": discover devices of a specific type - "urn:schemas-upnp-org:service::": discover specific service types - "upnp:rootdevice": discover root devices - "ssdp:all": discover all devices/services Examples: - "uuid:550e8400-e29b-41d4-a716-446655440000" - "urn:schemas-upnp-org:device:MediaServer:1" mcast_group: Multicast group address for SSDP (default: "239.255.255.250"). Don't change it unless you know what you're doing. port: Port for SSDP (default: 1900). Don't change it unless you know what you're doing. """ endpoint = _Endpoint(mcast_group, port) if endpoint not in self._endpoints_subscribers: scan_sock = self._create_scan_socket() self._endpoints_subscribers[endpoint] = _SocketSubscribers(scan_sock) subscriber = _Subscriber( listener, search_target, mcast_group, port, self._endpoints_subscribers[endpoint].scan_socket ) self._endpoints_subscribers[endpoint].search_subscribers[search_target].append(subscriber) def cancel(): subscribers = self._endpoints_subscribers.get(endpoint, _SocketSubscribers(None)).search_subscribers # type: ignore[arg-type] if search_target in subscribers: try: subscribers[search_target].remove(subscriber) except ValueError: pass return cancel # Putting workers together async def start(self): self._is_running = True self._tasks = [ asyncio.create_task(self._start_scanning()), asyncio.create_task(self._start_expiry_checks()), asyncio.create_task(self._start_listening_notify()), ] def perform_scan(self): """Triggers an immediate M-SEARCH burst on all registered endpoints. Use from start_pairing_window.""" asyncio.create_task(self._scan_and_read()) async def stop(self): self._is_running = False for task in self._tasks: task.cancel() await asyncio.gather(*self._tasks, return_exceptions=True) self._tasks.clear() for es in self._endpoints_subscribers.values(): es.scan_socket.close() # Private async def _start_scanning(self): """Send M-SEARCH bursts and collect unicast replies; repeat every scan_interval.""" while self._is_running: await self._scan_and_read() await asyncio.sleep(self._scan_interval) async def _start_expiry_checks(self): while self._is_running: await asyncio.sleep(self._scan_interval) await self._evict_expired() async def _start_listening_notify(self): """Listens for unsolicited SSDP NOTIFY advertisements on the multicast group.""" sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(("", 1900)) sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, self._get_mreq("239.255.255.250")) sock.setblocking(False) loop = asyncio.get_event_loop() try: while self._is_running: try: data, addr = await loop.sock_recvfrom(sock, 2048) except (BlockingIOError, OSError): await asyncio.sleep(0.5) continue if addr[0] in self._ignorelist: continue msg = data.decode(errors="ignore") if not msg.startswith("NOTIFY"): continue response = self._parse_ssdp_response(msg) nts = response.get("NTS", "") usn = response.get("USN") st = response.get("NT") # NOTIFY uses NT instead of ST if usn is None or st is None: continue for es in self._endpoints_subscribers.values(): subscribers = es.search_subscribers.get(st, []) if not subscribers: continue if nts == "ssdp:byebye": for subscriber in subscribers: if usn in subscriber.known: ks = subscriber.known.pop(usn) logger.debug(f"SSDP byebye: {ks.info.service_name} ({ks.info.addr})") await subscriber.listener.ssdp_did_remove_service(self, ks.info) elif nts == "ssdp:alive": info = SSDPDiscoveryInfo( addr=addr[0], host=response.get("HOST"), search_target=st, service_name=usn, server=response.get("SERVER"), cache_control=response.get("CACHE-CONTROL"), location=response.get("LOCATION"), response=response, ) await asyncio.gather(*[self._notify_subscriber(s, usn, info) for s in subscribers]) finally: sock.setsockopt(socket.IPPROTO_IP, socket.IP_DROP_MEMBERSHIP, self._get_mreq("239.255.255.250")) sock.close() # SSDP Implementation def _collect_scan_replies(self, sock: socket.socket, mx: int) -> list[tuple[bytes, tuple[str, int]]]: """Blocking: reads all unicast M-SEARCH replies within the MX window. Runs in executor.""" replies: list[tuple[bytes, tuple[str, int]]] = [] sock.settimeout(mx + 0.5) # slightly over MX so we catch late responders deadline = monotonic() + mx + 0.5 while True: remaining = deadline - monotonic() if remaining <= 0: break sock.settimeout(remaining) try: data, addr = sock.recvfrom(1024) replies.append((data, addr)) except TimeoutError: break except OSError: break return replies async def _notify_subscriber(self, subscriber: _Subscriber, usn: str, info: SSDPDiscoveryInfo): now = monotonic() if usn not in subscriber.known: subscriber.known[usn] = _KnownService(info, now) logger.debug(f"SSDP discovered: {info.service_name} ({info.addr})") await subscriber.listener.ssdp_did_discover_service(self, info) else: known = subscriber.known[usn] subscriber.known[usn] = _KnownService(info, now) if info.location != known.info.location or info.server != known.info.server: logger.debug(f"SSDP updated: {info.service_name} ({info.addr})") await subscriber.listener.ssdp_did_update_service(self, info) async def _evict_expired(self): now = monotonic() missed_scans_cutoff = now - self._scan_interval * self._missed_scans_evict for es in self._endpoints_subscribers.values(): for subscribers in es.search_subscribers.values(): for subscriber in subscribers: expired = [usn for usn, ks in subscriber.known.items() if ks.last_seen_at < missed_scans_cutoff] for usn in expired: ks = subscriber.known.pop(usn) logger.debug(f"SSDP expired: {ks.info.service_name} ({ks.info.addr})") await subscriber.listener.ssdp_did_remove_service(self, ks.info) async def _scan_and_read(self): """Send all M-SEARCH requests then collect replies for each endpoint.""" loop = asyncio.get_running_loop() for endpoint, es in self._endpoints_subscribers.items(): search_targets = list(es.search_subscribers.keys()) for search_target in search_targets: query = self._ssdp_query_template( ip=endpoint.mcast_group, port=str(endpoint.port), mx=str(self._mx), st=search_target ) logger.debug(f"SSDP scan: {search_target} on {endpoint.mcast_group}:{endpoint.port}") es.scan_socket.sendto(query.encode(), (endpoint.mcast_group, endpoint.port)) replies = await loop.run_in_executor(None, self._collect_scan_replies, es.scan_socket, self._mx) await self._process_scan_replies(endpoint, search_targets, replies) async def _process_scan_replies( self, endpoint: _Endpoint, search_targets: list[str], replies: list[tuple[bytes, tuple[str, int]]] ): for data, addr in replies: if addr[0] in self._ignorelist: continue response = self._parse_ssdp_response(data.decode(errors="ignore")) # 200 OK replies omit ST — fall back to matching against all search targets for this endpoint response_st = response.get("ST") # USN is standard UPnP but Yeelight (and others) omit it — fall back to Location then addr usn = response.get("USN") or response.get("LOCATION") or addr[0] search_subscribers = self._endpoints_subscribers[endpoint].search_subscribers candidates = [response_st] if response_st else search_targets for st in candidates: subscribers = search_subscribers.get(st, []) if not subscribers: continue info = SSDPDiscoveryInfo( addr=addr[0], host=response.get("HOST"), search_target=st, service_name=usn, server=response.get("SERVER"), cache_control=response.get("CACHE-CONTROL"), location=response.get("LOCATION"), response=response, ) await asyncio.gather(*[self._notify_subscriber(subscriber, usn, info) for subscriber in subscribers]) # SSDP config def _create_scan_socket(self) -> socket.socket: # Ephemeral unbound socket — OS assigns a random source port. # Devices send M-SEARCH replies back to that source port (unicast), # so we must read replies on the same socket we sent from. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) return sock def _get_mreq(self, mcast_group) -> bytes: return struct.pack("4sL", socket.inet_aton(mcast_group), socket.INADDR_ANY) def _ssdp_query_template(self, ip: str, port: str, mx: str, st: str) -> str: return f'M-SEARCH * HTTP/1.1\r\nMAN: "ssdp:discover"\r\nHOST: {ip}:{port}\r\nMX: {mx}\r\nST: {st}\r\n\r\n' def _parse_ssdp_response(self, response: str) -> dict[str, str]: lines = response.split("\r\n") data = {} for line in lines: if ":" in line: key, value = line.split(":", 1) data[key.strip().upper()] = value.strip() return data # Helpers def _local_ips(self) -> set[str]: """Returns all local IPv4 addresses to exclude from discovery responses.""" ips: set[str] = {"127.0.0.1"} try: infos = socket.getaddrinfo(None, None, socket.AF_INET, socket.SOCK_DGRAM, 0, socket.AI_PASSIVE) ips.update(str(info[4][0]) for info in infos if info[4][0] != "0.0.0.0") except Exception: pass return ips if __name__ == "__main__": # logging.basicConfig() # logger.setLevel(logging.DEBUG) async def main(): ssdp = SSDPDiscoveryService() class SSDPListener: async def ssdp_did_discover_service(self, ssdp: SSDPDiscoveryService, info: SSDPDiscoveryInfo): print(f"Found: {info.service_name} @ {info.addr} location={info.location}") async def ssdp_did_update_service(self, ssdp: SSDPDiscoveryService, info: SSDPDiscoveryInfo): print(f"Updated: {info.service_name} @ {info.addr}") async def ssdp_did_remove_service(self, ssdp: SSDPDiscoveryService, info: SSDPDiscoveryInfo): print(f"Removed: {info.service_name} @ {info.addr}") ssdp.register(SSDPListener(), "wifi_bulb", port=1982) await ssdp.start() while True: await asyncio.sleep(1) asyncio.run(main()) ================================================================================ # FILE: majordom_integration_sdk/discovery/zeroconf_discovery.py ================================================================================ import asyncio import logging from collections.abc import Callable from dataclasses import dataclass from typing import Protocol, cast from zeroconf import BadTypeInNameException, ServiceListener, Zeroconf, current_time_millis from zeroconf._dns import DNSPointer from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf from zeroconf.const import _CLASS_IN, _TYPE_A, _TYPE_AAAA, _TYPE_PTR logger = logging.getLogger(__name__) _RESOLVE_DELAY = 0.5 # seconds — coalesces burst PTR/SRV/TXT/A packets into one resolve _RESOLVE_TIMEOUT_MS = 3_000 _CACHE_POLL_INTERVAL = 30 # seconds — how often to reconcile our state against zeroconf's PTR cache _EVICT_GRACE_MS = 10_000 # ms — grace window after A/AAAA expiry before probing; allows zeroconf to renew @dataclass class ZeroconfDiscoveryInfo: """ * `type_`: fully qualified service type name * `name`: fully qualified service name * `port`: port that the service runs on * `weight`: weight of the service * `priority`: priority of the service * `properties`: dictionary of properties as raw bytes. Keys with `None` values are value-less attributes. * `decoded_properties`: `properties` decoded to `str` — use this for most integrations. * `text`: raw TXT record bytes — use only if you need to parse the record manually. * `server`: fully qualified name for service host (defaults to name) * `host_ttl`: ttl used for A/SRV records * `other_ttl`: ttl used for PTR/TXT records * `addresses` and `parsed_addresses`: List of IP addresses (either as bytes, network byte order, or in parsed form as text; at most one of those parameters can be provided) * interface_index: scope_id or zone_id for IPv6 link-local addresses i.e. an identifier of the interface where the peer is connected to """ type_: str name: str server: str | None port: int | None addresses: list[bytes] | None parsed_addresses: list[str] | None weight: int | None priority: int | None properties: dict[bytes, bytes | None] decoded_properties: dict[str, str | None] text: bytes host_ttl: int | None other_ttl: int | None interface_index: int | None def __eq__(self, other: object) -> bool: if not isinstance(other, ZeroconfDiscoveryInfo): return NotImplemented # exclude host_ttl / other_ttl — they change on every re-announcement return ( self.type_ == other.type_ and self.name == other.name and self.server == other.server and self.port == other.port and self.addresses == other.addresses and self.parsed_addresses == other.parsed_addresses and self.weight == other.weight and self.priority == other.priority and self.properties == other.properties and self.text == other.text and self.interface_index == other.interface_index ) class ZeroconfDiscoveryListener(Protocol): async def zeroconf_did_discover_service( self, zeroconf: "ZeroconfDiscoveryService", info: ZeroconfDiscoveryInfo ): ... async def zeroconf_did_update_service(self, zeroconf: "ZeroconfDiscoveryService", info: ZeroconfDiscoveryInfo): ... async def zeroconf_did_remove_service(self, zeroconf: "ZeroconfDiscoveryService", type_: str, name: str): """Fired on mDNS goodbye packets (TTL=0) or when a silent departure is confirmed: A/AAAA records expired (~120s TTL) and a follow-up async_request got no response. For faster detection, implement liveness checks at the pairing/connection layer.""" ... class ZeroconfDiscoveryService: @property def async_zeroconf(self) -> AsyncZeroconf | None: return self._async_zeroconf def __init__(self): self._async_zeroconf: AsyncZeroconf | None = None self._browsers: set[AsyncServiceBrowser] = set() self._adapters: list[_ServiceListenerAdapter] = [] self._poll_task: asyncio.Task | None = None # registrations made before start() — flushed on start self._pending: list[tuple[set[str], ZeroconfDiscoveryListener, int]] = [] self._pending_id: int = 0 # FUTURE: filter type def register(self, listener: ZeroconfDiscoveryListener, services: set[str]) -> Callable[[], None]: if self._async_zeroconf is None: reg_id = self._pending_id self._pending_id += 1 self._pending.append((services, listener, reg_id)) # internal order unchanged def cancel_pending(): for i, (_, _, rid) in enumerate(self._pending): if rid == reg_id: del self._pending[i] break return cancel_pending browser, adapter = self._make_browser(services, listener) def cancel(): adapter.cancel_all_pending() self._browsers.discard(browser) self._adapters.remove(adapter) asyncio.create_task(browser.async_cancel()) return cancel async def start(self): self._async_zeroconf = AsyncZeroconf() for services, listener, _ in self._pending: self._make_browser(services, listener) self._pending.clear() self._poll_task = asyncio.create_task(self._cache_poll_loop()) logger.debug(f"Zeroconf started, {len(self._browsers)} browser(s) active") async def stop(self): if self._poll_task: self._poll_task.cancel() self._poll_task = None for adapter in self._adapters: adapter.cancel_all_pending() for browser in list(self._browsers): await browser.async_cancel() self._browsers.clear() self._adapters.clear() if self._async_zeroconf is not None: await self._async_zeroconf.async_close() self._async_zeroconf = None logger.debug("Zeroconf stopped") async def _cache_poll_loop(self): while True: await asyncio.sleep(_CACHE_POLL_INTERVAL) if self._async_zeroconf is None: continue zc = self._async_zeroconf.zeroconf now = current_time_millis() for adapter in list(self._adapters): adapter.sync_with_cache(zc, now) def _make_browser( self, services: set[str], listener: ZeroconfDiscoveryListener ) -> tuple[AsyncServiceBrowser, "_ServiceListenerAdapter"]: assert self._async_zeroconf is not None adapter = _ServiceListenerAdapter(self, listener, services) browser = AsyncServiceBrowser( self._async_zeroconf.zeroconf, list(services), listener=adapter, ) self._browsers.add(browser) self._adapters.append(adapter) return browser, adapter class _ServiceListenerAdapter(ServiceListener): def __init__( self, zeroconf_discovery: ZeroconfDiscoveryService, listener: ZeroconfDiscoveryListener, services: set[str] ): self._zc_service = zeroconf_discovery self._listener = listener self._services = services self._loop = asyncio.get_running_loop() self._resolve_later_queue: dict[str, asyncio.TimerHandle] = {} self._known_names: dict[str, ZeroconfDiscoveryInfo] = {} # name → last known info def add_service(self, zc: Zeroconf, type_: str, name: str): self._schedule_resolve(type_, name) def update_service(self, zc: Zeroconf, type_: str, name: str): self._schedule_resolve(type_, name) def remove_service(self, zc: Zeroconf, type_: str, name: str): # goodbye packet — cancel pending resolve and notify immediately if handle := self._resolve_later_queue.pop(name, None): handle.cancel() self._known_names.pop(name, None) logger.debug(f"Zeroconf goodbye received: {name}") asyncio.create_task(self._listener.zeroconf_did_remove_service(self._zc_service, type_, name)) def cancel_all_pending(self): while self._resolve_later_queue: _, handle = self._resolve_later_queue.popitem() handle.cancel() def sync_with_cache(self, zc: Zeroconf, now: float): """Reconcile _known_names against the live cache. When A/AAAA records expire, we confirm via async_request before evicting — expiry alone is unreliable (device may have just re-announced). A failed async_request is the definitive "no goodbye" departure signal. Rediscovery scans live PTR records for names we don't know yet. """ # confirm-evict: A/AAAA expired → probe network; evict only if unreachable for name, last_info in list(self._known_names.items()): if last_info.server is None: continue a_records = list(zc.cache.get_all_by_details(last_info.server, _TYPE_A, _CLASS_IN)) aaaa_records = list(zc.cache.get_all_by_details(last_info.server, _TYPE_AAAA, _CLASS_IN)) all_address_records = a_records + aaaa_records if not all_address_records or all( r.get_expiration_time(100) + _EVICT_GRACE_MS <= now for r in all_address_records ): if name not in self._resolve_later_queue: logger.debug(f"Zeroconf A/AAAA expired, confirming: {name}") asyncio.create_task(self._confirm_evict(name, last_info.type_)) # rediscover: scan live PTR records for unknown names for type_ in self._services: for record in zc.cache.get_all_by_details(type_, _TYPE_PTR, _CLASS_IN): ptr = cast(DNSPointer, record) if ( not ptr.is_expired(now) and ptr.alias not in self._known_names and ptr.alias not in self._resolve_later_queue ): logger.debug(f"Zeroconf cache rediscovery: {ptr.alias}") self._schedule_resolve(type_, ptr.alias) async def _confirm_evict(self, name: str, type_: str): """Probe the network for a device whose A/AAAA records expired. Evict only if unreachable.""" assert self._zc_service._async_zeroconf is not None zc = self._zc_service._async_zeroconf.zeroconf info = AsyncServiceInfo(type_, name) if await info.async_request(zc, _RESOLVE_TIMEOUT_MS): # device responded — it's alive, update via normal resolve path logger.debug(f"Zeroconf confirm-evict: {name} is alive, refreshing") discovery_info = _build_info(info) if discovery_info and name in self._known_names: if discovery_info != self._known_names[name]: self._known_names[name] = discovery_info await self._listener.zeroconf_did_update_service(self._zc_service, discovery_info) else: self._known_names[name] = discovery_info # refresh server silently else: # no response — confirmed departed if name in self._known_names: self._known_names.pop(name) logger.debug(f"Zeroconf eviction confirmed (no response): {name}") await self._listener.zeroconf_did_remove_service(self._zc_service, type_, name) def _schedule_resolve(self, type_: str, name: str): # already queued — let the existing timer fire, burst coalescing if name in self._resolve_later_queue: return try: info = AsyncServiceInfo(type_, name) except BadTypeInNameException as ex: logger.debug(f"Ignoring record with bad type in name: {name}: {ex}") return handle = self._loop.call_later( _RESOLVE_DELAY, lambda: asyncio.create_task(self._do_resolve(name, info)), ) self._resolve_later_queue[name] = handle async def _do_resolve(self, name: str, info: AsyncServiceInfo): self._resolve_later_queue.pop(name, None) assert self._zc_service._async_zeroconf is not None zc = self._zc_service._async_zeroconf.zeroconf # cache-first: free if warm, network round-trip only if cold if not info.load_from_cache(zc): if not await info.async_request(zc, _RESOLVE_TIMEOUT_MS): logger.debug(f"Zeroconf resolve failed (no data): {name}") return discovery_info = _build_info(info) if discovery_info is None: return if name in self._known_names: if discovery_info != self._known_names[name]: self._known_names[name] = discovery_info await self._listener.zeroconf_did_update_service(self._zc_service, discovery_info) else: self._known_names[name] = discovery_info # refresh silently else: self._known_names[name] = discovery_info await self._listener.zeroconf_did_discover_service(self._zc_service, discovery_info) def _build_info(info: AsyncServiceInfo) -> ZeroconfDiscoveryInfo | None: if info.port is None: return None return ZeroconfDiscoveryInfo( type_=info.type, name=info.name, server=info.server, port=info.port, addresses=list(info.addresses), parsed_addresses=info.parsed_addresses(), weight=info.weight, priority=info.priority, properties=info.properties, decoded_properties=info.decoded_properties, text=info.text, host_ttl=info.host_ttl, other_ttl=info.other_ttl, interface_index=info.interface_index, ) if __name__ == "__main__": logging.basicConfig() logger.setLevel(logging.DEBUG) async def main(): discovery = ZeroconfDiscoveryService() class HAPListener: async def zeroconf_did_discover_service( self, zeroconf: ZeroconfDiscoveryService, info: ZeroconfDiscoveryInfo ): print(f"Found: {info.name} @ {info.parsed_addresses}:{info.port}") async def zeroconf_did_update_service( self, zeroconf: ZeroconfDiscoveryService, info: ZeroconfDiscoveryInfo ): print(f"Updated: {info.name} @ {info.parsed_addresses}:{info.port}") async def zeroconf_did_remove_service(self, zeroconf: ZeroconfDiscoveryService, type_: str, name: str): print(f"Removed: {name}") discovery.register(HAPListener(), {"_hap._tcp.local."}) await discovery.start() while True: await asyncio.sleep(1) asyncio.run(main()) ================================================================================ # FILE: majordom_integration_sdk/parameter_audit.py ================================================================================ """Dev/pairing-time sanity checks on a device's mapped parameters — protocol-neutral, so every integration (and the audit tools) share one implementation. See the "Parameter Visibility & UX" recipe in the docs. All checks are advisory: they return human-readable warnings, never raise. """ from __future__ import annotations from collections import defaultdict from collections.abc import Iterable, Sequence from difflib import SequenceMatcher from .schemas.parameter import Parameter, ParameterUnit, ParameterVisibility # A device's `user` bucket is the everyday device screen; more than this is a strong # over-exposure smell (usually uncurated read-only attrs leaking through). MAX_USER_PARAMETERS = 8 # Name-similarity ratio (0..1) above which two `user` params look like near-duplicates. NAME_SIMILARITY_THRESHOLD = 0.86 def _name_similarity(a: str, b: str) -> float: return SequenceMatcher(None, a, b).ratio() def audit_device_parameters( device_name: str, parameters: Iterable[Parameter], *, max_user: int = MAX_USER_PARAMETERS, similarity_threshold: float = NAME_SIMILARITY_THRESHOLD, ignore_similar_pairs: Sequence[tuple[str, str]] = (), ) -> list[str]: """Return advisory warnings about a device's parameter mapping. Checks: * too many `user`-visible parameters (over-exposure), * near-duplicate names in the `user` bucket (a normalized string-similarity ratio — `difflib`, close to a Levenshtein signal without a dependency), * same-unit + same-role duplicates in the `user` bucket (catches redundant *representations* of one quantity even when the names differ, e.g. colour `hue/sat` and `x/y`). `ignore_similar_pairs` whitelists legitimately-similar name pairs (order-insensitive), e.g. `("occupied_heating_setpoint", "occupied_cooling_setpoint")`. """ warnings: list[str] = [] user = [p for p in parameters if p.visibility == ParameterVisibility.user] if len(user) > max_user: names = ", ".join(p.name for p in user[:12]) warnings.append( f"{device_name}: {len(user)} user-visible parameters (> {max_user}) — likely " f"over-exposed; review visibility curation. [{names}{' …' if len(user) > 12 else ''}]" ) # Near-duplicate names among user params (a Levenshtein-like signal). whitelist = {frozenset(pair) for pair in ignore_similar_pairs} for i in range(len(user)): for j in range(i + 1, len(user)): a, b = user[i], user[j] if frozenset((a.name, b.name)) in whitelist: continue ratio = _name_similarity(a.name, b.name) if ratio >= similarity_threshold: warnings.append( f"{device_name}: user params {a.name!r} and {b.name!r} have near-duplicate " f"names (similarity {ratio:.2f}) — is one redundant?" ) # Redundant *representations* of one quantity — several user params sharing a specific # (non-plain) unit + role + type, even with different names (e.g. colour hue/sat AND x/y). # Grouped and thresholded so ordinary multi-sensor devices don't trip it. groups: dict[tuple, list[str]] = defaultdict(list) for p in user: if p.unit != ParameterUnit.plain: groups[(p.unit, p.role, p.data_type)].append(p.name) for (unit, role, _dt), group_names in groups.items(): if len(group_names) >= 3: warnings.append( f"{device_name}: {len(group_names)} user params share {unit.value}/{role.value} " f"({', '.join(group_names)}) — possible redundant representations." ) return warnings ================================================================================ # FILE: majordom_integration_sdk/repository/__init__.py ================================================================================ """Device persistence for integrations — one protocol, two standalone implementations.""" from .memory import DeviceRepositoryMemory from .protocol import DeviceRepositoryProtocol from .sqlite import SqliteDeviceRepository __all__ = ["DeviceRepositoryProtocol", "DeviceRepositoryMemory", "SqliteDeviceRepository"] ================================================================================ # FILE: majordom_integration_sdk/repository/_record.py ================================================================================ """Internal record helpers shared by the SDK's repository implementations. A stored record is one JSON-safe dict per device holding *everything* known about it — info, ``integration_data`` (from a :class:`Device`) and ``parameters`` (from a :class:`DeviceState`). ``Device`` and ``DeviceState`` are siblings off ``DeviceInfo``, so neither alone is a complete record; merging keeps both halves and lets a read deserialize into whichever view (``as_``) the caller asks for. """ from __future__ import annotations import json from typing import Any from pydantic import BaseModel from majordom_integration_sdk.schemas.device import Device, DeviceState type Record = dict[str, Any] def dump(device: BaseModel) -> Record: """JSON-safe dump, so records round-trip identically through memory or SQLite. Goes through ``model_dump_json`` rather than ``model_dump(mode="json")`` — the JSON serializer is the one these models are actually exercised against (``ParameterState`` base64-encodes its ``bytes`` value there), so it's what the SQLite rows already store. """ return json.loads(device.model_dump_json()) def merge(base: Record | None, device: Device | DeviceState) -> Record: """Merge a device into an existing record without clobbering the other view's fields.""" return {**(base or {}), **dump(device)} __all__ = ["Record", "dump", "merge"] ================================================================================ # FILE: majordom_integration_sdk/repository/memory.py ================================================================================ """In-memory :class:`DeviceRepositoryProtocol` implementation. A legitimate, lightweight persistence choice for standalone/dev runs and tests — not a mock. State lives in a dict for the process lifetime; use :class:`SqliteDeviceRepository` when you need it to survive a restart. """ from __future__ import annotations from collections.abc import AsyncIterator from contextlib import asynccontextmanager from uuid import UUID from majordom_integration_sdk.repository._record import Record, dump, merge from majordom_integration_sdk.schemas.device import Device, DeviceState from majordom_integration_sdk.schemas.parameter import ParameterState, ParameterVisibility class DeviceRepositoryMemory: """Stores device records in a dict shared across sessions. Pass ``integration`` to scope every operation to that integration's devices (the guard the Hub sets when handing a repository to a controller). ``None`` gives full access. """ def __init__(self, integration: str | None = None, store: dict[UUID, Record] | None = None): self.integration = integration self._store: dict[UUID, Record] = store if store is not None else {} @asynccontextmanager async def session(self) -> AsyncIterator[DeviceRepositoryMemory]: """Async-context-manager factory to pass as ``make_device_repository``.""" yield self 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 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) 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) 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) if not record: return None return next( (ParameterState.model_validate(p) for p in record.get("parameters", []) if p["id"] == str(parameter_id)), None, ) async def save(self, device: Device | DeviceState, previous_id: UUID | None = None) -> None: 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) async def save_parameter_state(self, device_id: UUID, parameter_state: ParameterState) -> None: record = self._owned(device_id) if not record: raise KeyError(f"Unknown or out-of-scope device {device_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. async def update_parameter_visibility(self, parameter_id: UUID, visibility: ParameterVisibility) -> None: for record in self._store.values(): if not self._in_scope(record): continue for parameter in record.get("parameters", []): if parameter["id"] == str(parameter_id): parameter["visibility"] = visibility.value return async def delete(self, device_id: UUID) -> None: if self._owned(device_id): self._store.pop(device_id, None) __all__ = ["DeviceRepositoryMemory"] ================================================================================ # FILE: majordom_integration_sdk/repository/protocol.py ================================================================================ """The device-repository interface a controller talks to. A controller never touches a database directly — it goes through this protocol, obtained from ``dependencies.make_device_repository()`` (an async context manager scoping one unit of work). The Hub backs it with its shared SQLAlchemy database; the SDK ships two first-class implementations for standalone/dev use — :class:`DeviceRepositoryMemory` and :class:`SqliteDeviceRepository`. **Integration scope.** The repository is bound to one integration at construction, so there is no ``integration`` argument on the reads: an integration only ever sees its own devices. Out-of-scope reads come back ``None``/empty and out-of-scope writes raise — a guard against carelessness, not a security boundary. Constructing with ``integration=None`` gives full access (the Hub's own higher-level use). **Typed reads.** ``as_`` deserializes into your own ``Device``/``DeviceState`` subclass, so ``integration_data`` comes back typed instead of a bare dict:: async with self.dependencies.make_device_repository() as repo: device = await repo.get(device_id, as_=MyDevice) # device.integration_data: 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 protocol so an integration can't reach past its scope. """ from __future__ import annotations from typing import Protocol, runtime_checkable from uuid import UUID from majordom_integration_sdk.schemas.device import Device, DeviceState from majordom_integration_sdk.schemas.parameter import ParameterState @runtime_checkable class DeviceRepositoryProtocol(Protocol): """Persistence for one integration's paired devices and their parameter states.""" 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: """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: """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: """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) -> None: """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`` — 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. """ ... async def save_parameter_state(self, device_id: UUID, parameter_state: ParameterState) -> None: """Update a single parameter's value/state on an existing in-scope device.""" ... __all__ = ["DeviceRepositoryProtocol"] ================================================================================ # FILE: majordom_integration_sdk/repository/sqlite.py ================================================================================ """File-backed :class:`DeviceRepositoryProtocol` on the standard-library ``sqlite3``. No third-party dependency: each device is stored as one row of JSON (the record already stores pythonic parameter values as JSON via Pydantic). Suitable for a standalone integration that needs its paired devices to survive a restart; the Hub uses its own SQLAlchemy-backed repository instead. ``sqlite3`` is synchronous; calls are cheap and local, so the async methods invoke it directly. Obtain a session with :meth:`session` (opens a connection, commits on clean exit). """ from __future__ import annotations import json import sqlite3 from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path from uuid import UUID from majordom_integration_sdk.repository._record import Record, dump, merge from majordom_integration_sdk.schemas.device import Device, DeviceState from majordom_integration_sdk.schemas.parameter import ParameterState, ParameterVisibility class SqliteDeviceRepository: """Persists device records as JSON rows in a SQLite file. Pass ``integration`` to scope every operation to that integration's devices (the guard the Hub sets when handing a repository to a controller). ``None`` gives full access. """ def __init__(self, path: str | Path, integration: str | None = None): self.path = str(path) self.integration = integration conn = self._connect() try: conn.execute( "CREATE TABLE IF NOT EXISTS devices (" " id INTEGER PRIMARY KEY, uuid TEXT UNIQUE NOT NULL," " integration TEXT NOT NULL, data TEXT NOT NULL)" ) conn.commit() finally: conn.close() def _connect(self) -> sqlite3.Connection: conn = sqlite3.connect(self.path) conn.row_factory = sqlite3.Row return conn @asynccontextmanager async def session(self) -> AsyncIterator[SqliteDeviceRepository]: """Async-context-manager factory to pass as ``make_device_repository``.""" # A fresh connection per unit of work; sharing one across concurrent tasks is unsafe. self._conn = self._connect() try: yield self self._conn.commit() finally: self._conn.close() del self._conn def _cursor(self) -> sqlite3.Connection: conn = getattr(self, "_conn", None) if conn is None: 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.""" if self.integration is None: row = self._cursor().execute("SELECT data FROM devices WHERE uuid = ?", (str(device_id),)).fetchone() else: row = ( self._cursor() .execute( "SELECT data FROM devices WHERE uuid = ? AND integration = ?", (str(device_id), self.integration), ) .fetchone() ) return json.loads(row["data"]) if row else None def _write(self, record: Record) -> None: 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)), ) async def get_all[T: Device](self, as_: type[T] = Device) -> list[T]: if self.integration is None: rows = self._cursor().execute("SELECT data FROM devices").fetchall() else: rows = ( self._cursor().execute("SELECT data FROM devices WHERE integration = ?", (self.integration,)).fetchall() ) 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) 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) 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) if not record: return None return next( (ParameterState.model_validate(p) for p in record.get("parameters", []) if p["id"] == str(parameter_id)), None, ) async def save(self, device: Device | DeviceState, previous_id: UUID | None = None) -> None: 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) -> None: record = self._record(device_id) if not record: raise KeyError(f"Unknown or out-of-scope device {device_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. async def update_parameter_visibility(self, parameter_id: UUID, visibility: ParameterVisibility) -> None: for device in await self.get_all(): record = self._record(device.id) if not record: continue if any(p["id"] == str(parameter_id) for p in record.get("parameters", [])): for parameter in record["parameters"]: if parameter["id"] == str(parameter_id): parameter["visibility"] = visibility.value self._write(record) return async def delete(self, device_id: UUID) -> None: if self._record(device_id) is None: return self._cursor().execute("DELETE FROM devices WHERE uuid = ?", (str(device_id),)) __all__ = ["SqliteDeviceRepository"] ================================================================================ # FILE: majordom_integration_sdk/schemas/__init__.py ================================================================================ """The MajorDom wire protocol — the device/parameter models shared between an integration and the Hub. Scope is deliberately the device domain: devices, parameters, pairing/discovery, commands, and parameter-change reports. Hub-only concerns (rooms, houses, users, the websocket protocol) and the automation event bus live in the Hub, not here. """ from .base import Base, NonEmptyStr, StrEnum, StrIdentifiable, UUIdentifable from .command import DeviceCommand from .device import ( CredentialsType, CredentialsValue, Device, DeviceCreate, DeviceDataModel, DeviceInfo, DevicePair, DevicePatch, DeviceState, Discovery, ProvidedCredentials, ) from .event import DeviceParameterChange, Event from .parameter import ( Parameter, ParameterDataType, ParameterRole, ParameterState, ParameterUnit, ParameterVisibility, ParameterVisibilityPatch, ) __all__ = [ # base "Base", "NonEmptyStr", "StrEnum", "StrIdentifiable", "UUIdentifable", # command / events "DeviceCommand", "Event", "DeviceParameterChange", # device "CredentialsType", "CredentialsValue", "Device", "DeviceCreate", "DeviceDataModel", "DeviceInfo", "DevicePair", "DevicePatch", "DeviceState", "Discovery", "ProvidedCredentials", # parameter "Parameter", "ParameterDataType", "ParameterRole", "ParameterState", "ParameterUnit", "ParameterVisibility", "ParameterVisibilityPatch", ] ================================================================================ # FILE: majordom_integration_sdk/schemas/base.py ================================================================================ from __future__ import annotations from enum import Enum from typing import Any from uuid import UUID from pydantic import BaseModel as PydanticBaseModel from pydantic import GetJsonSchemaHandler from pydantic_core import core_schema as cs class StrEnum(str, Enum): pass class Base(PydanticBaseModel): # TODO: make sure all models use it model_config = { "from_attributes": True, "validate_assignment": True, } class NonEmptyStr(str): @classmethod def __get_pydantic_core_schema__(cls, _source: Any, _handler) -> cs.CoreSchema: # Create a core schema of type string with a validator function return cs.str_schema( min_length=1, strict=True, # You can add custom validation here if needed, but min_length=1 ensures non-empty ) @classmethod def __get_pydantic_json_schema__( cls, core_schema: cs.CoreSchema, handler: GetJsonSchemaHandler, ) -> dict: json_schema = handler(core_schema) json_schema.update( title="NonEmptyStr", description="A non-empty string", examples=["example text"], ) return json_schema class UUIdentifable(Base): # TODO: review id: UUID def __hash__(self): return hash(self.id) def __eq__(self, other): return self.id == other.id class StrIdentifiable(Base): id: str ================================================================================ # FILE: majordom_integration_sdk/schemas/command.py ================================================================================ from typing import Any from uuid import UUID from .base import Base class DeviceCommand(Base): device_id: UUID parameter_id: UUID value: Any ================================================================================ # FILE: majordom_integration_sdk/schemas/device.py ================================================================================ from __future__ import annotations from datetime import datetime from enum import Enum from uuid import UUID from pydantic import Field, SerializeAsAny from .base import Base, NonEmptyStr from .parameter import Parameter, ParameterState # TODO: split the file # Pairing class CredentialsType(str, Enum): code = "code" # e.g. 1234-123-1234 (matter) or 123-45-678 (homekit) # TODO: rename to pin qr = "qr" # raw qr data; TODO: decode to see what data is inside some protocols secret = "secret" # AES key like in esphome none = "none" # like yeelight classic LAN def with_mask(self, code_mask: str) -> CredentialsType: """ mask format: D as digit placeholder, other symbols remain unchanged e.g. dashes, for example "DDD-DD-DDD" for "123-45-678" """ self.code_mask = code_mask return self # FUTURE: # @dataclass # class CredentialsCode: # code: str # @dataclass # class CredentialsSecret: # secret: str # Credentials = Union[CredentialsCode] type CredentialsValue = str class ProvidedCredentials(Base): """What a pairing request actually supplies — paired with its type, so pairing can validate it against Discovery.expected_credentials_options instead of trusting whatever the integration guessed at discovery time.""" type: CredentialsType value: CredentialsValue | None = None class Discovery(Base): # technical id: UUID integration: NonEmptyStr expected_credentials_options: list[CredentialsType] # TODO: pass code mask and other things expiration: datetime | None = None # for UX transport: NonEmptyStr device_manufacturer: str | None device_name: NonEmptyStr device_category: str | None device_icon: str | None # TODO: icon system last_error: str | None = None # device_model_id: UUID | None = None # is it still relevant? # TODO: room hint? # TODO: integration_data # TODO: handle devices with multiple transports or multiple integrations, show options, add priority, allow choosing # Device class DevicePatch(Base): name: str note: str = "" icon: str | None = None category: str | None = None room_id: UUID main_parameter: UUID | None = None class DeviceCreate(DevicePatch): discovery_id: UUID # | None = None credentials: ProvidedCredentials | None = None class DevicePair(Base): """Re-pair discovery to the existing device.""" device_id: UUID discovery_id: UUID credentials: ProvidedCredentials | None = None class DeviceInfo(DevicePatch): id: UUID # NOTE: moved from DeviceCreate, check whether it's correct transport: str integration: str manufacturer: str | None # Manufacturer-provided, read-only description of the device (a model blurb / product name). # Integrations set it when the protocol exposes one; the Hub never edits it. (User-editable # free text is `note`, on DevicePatch.) description: str | None = None main_parameter: UUID | None = None # for the tap action on the room view, toggle in most cases last_seen: datetime | None = None available: bool = False last_error: str | None = None # model_id: UUID | None = None # model: DeviceModel - is it needed here? # merlin24: DeviceMerlin24 | None # DEPRECATED class Device(DeviceInfo): # TODO: review models integration_data: SerializeAsAny[dict | Base] = Field(default_factory=Base) class DeviceDataModel(DeviceInfo): parameters: list[Parameter] class DeviceState(DeviceInfo): parameters: list[ParameterState] @property def parameters_dict(self): return {param.id: param for param in self.parameters} def can_set_main_parameter(self, parameter_id: UUID | None) -> bool: """Whether `parameter_id` may be this device's main (one-tap) parameter: clearing it (None) is always allowed, otherwise it must be one of this device's parameters and satisfy ParameterState.can_be_main_parameter.""" if parameter_id is None: return True parameter = self.parameters_dict.get(parameter_id) return parameter is not None and parameter.can_be_main_parameter # Device Model TODO: review usage; # Current idea is to store more metadata that can't be accessed from the device itself, # for example, manufacturer's website, preview picture, 3d model, specs, etc # class DeviceModelInfo(Base): # id: UUID # name: NonEmptyStr # transports: list[str] # integration: NonEmptyStr # manufacturer: str | None # category: str | None # icon: str | None # is_custom: bool = False # TODO: DEPRECATE? # # picture: str | None # TODO: resolve # class DeviceModel(DeviceModelInfo): # parameters: list[Parameter] ================================================================================ # FILE: majordom_integration_sdk/schemas/event.py ================================================================================ from typing import Any from uuid import UUID from .base import Base class Event(Base): """Base class for device-domain events a controller reports to the Hub. Delivered through :meth:`ControllerOutput.controller_did_receive_events`. A parameter change is the first concrete kind; more (button presses, device online/offline, …) can extend this without changing the callback. Kept device-domain and distinct from the Hub's automation event bus, which wraps these on its side. """ 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. """ device_id: UUID parameter_id: UUID value: Any ================================================================================ # FILE: majordom_integration_sdk/schemas/notification.py ================================================================================ from .base import Base, StrEnum class NotificationType(StrEnum): """The callout style the app renders the notification with.""" info = "info" warning = "warning" error = "error" class NotificationPriority(StrEnum): """How insistently the notification is delivered (maps to the OS interruption levels).""" silent = "silent" # appears in the list, no alert normal = "normal" # default banner time_sensitive = "time_sensitive" # breaks through focus/quiet modes urgent = "urgent" # demands attention class Notification(Base): """A general user-facing message an integration surfaces to the user as a floating tile in the app. Distinct from ``controller_did_encounter_error`` (which is specifically for integration failures/health) — use this for anything informational or advisory the user should see. ``ttl`` is how many seconds the tile stays before auto-dismissing; ``None`` keeps it until the user dismisses it. """ message: str type: NotificationType = NotificationType.info priority: NotificationPriority = NotificationPriority.normal ttl: int | None = None ================================================================================ # FILE: majordom_integration_sdk/schemas/parameter.py ================================================================================ from collections.abc import Sequence from typing import Any from uuid import UUID from pydantic import field_validator from .base import Base, StrEnum, UUIdentifable # TODO: consider adding clusters / groups / endpoints to the device model to group parameters like in zigbee # OLD (Merlin-alpha): # class ParameterDataType(StrEnum): # integer = "integer" # uint8 # decimal = "decimal" # uint8 to be casted to [0...1] decimal range # bool = "bool" # one-bit integer # enum = "enum" # uint8 with string_representation # string = "string" # string # class ParameterUnit(StrEnum): # plain = "plain" # any # humidity = "humidity" # decimal; # temperature_c = "temperature_c" # float32; # color_temperature = "color_temperature" # Kelvin, decimal; 0.5 is white # rgb = "rgb" # hue wheel angle, decimal; # volume = "volume" # decimal; # button = "button" # None, just a button # timeinterval = "timeinterval" # seconds, int32; class ParameterDataType(StrEnum): none = "none" # e.g. button # numeric bool = "bool" integer = "integer" decimal = "decimal" # python float enum = "enum" # integer with string_representation # data string = "string" struct = "struct" # multi-field object for things like Metter command arguments or just complex Parameters; Value format: {:} data = "data" # freeform binary data, base64 encoded at high level, for documents and extensions' internal usage # homekit also has array and dict # can be extended if needed class ParameterUnit(StrEnum): plain = "plain" # raw data type percentage = "percentage" # time second = "second" hertz = "hertz" # kinematic kilogram = "kilogram" arcdegree = "arcdegree" meters = "meters" mps = "mps" # meters per second, speed mps2 = "mps2" # meters per second squared, acceleration m3h = "m3h" # cubic meters per hour, volumetric flow rpm = "rpm" # revolutions per minute newton = "newton" # force joule = "joule" # energy kwh = "kwh" # kilowatt-hour, energy (metering display unit) watt = "watt" # power # temperature celsius = "celsius" kelvin = "kelvin" mired = "mired" # reciprocal megakelvin, color temperature # electricity volt = "volt" ampere = "ampere" # light # check if lumen or candela are needed lux = "lux" # rgb = "rgb" # hex str; UPD: homekit implements color as separate simple HSV parameters w/o adding complex data structs # air pascal = "pascal" ppm = "ppm" # parts per million, air quality ugm3 = "ugm3" # micrograms per cubic meter, particulate matter (PM2.5/PM10) # informatics bytes = "bytes" # data size bps = "bps" # bytes per second, data rate json = "json" # freeform json with code snippet display document = "document" # upload/download files class ParameterRole(StrEnum): sensor = "sensor" # get-only control = "control" # get-set event = "event" class ParameterVisibility(StrEnum): user = "user" # main, everyday interaction, device screen widgets (on/off, brightness, volume) setting = "setting" # user-configurable but behind am extra "settings"/"advanced" tap: configured once and rarely touched again; or diagnostic readings (RSSI, firmware version) system = "system" # hidden under-the-hood wirings; not visible to the user def next_main_parameter_value( current: int | float | str | bool | None, cycle: Sequence[int | float | str] | None, ) -> int | float | str | None: """The value a one-tap (cycle/toggle) main parameter should send next. ``cycle`` is the ordered set of values to rotate through — the parameter's ``default_value`` values (a set), or its ``valid_values`` keys when no ``default_value`` is set. A single-element cycle is a "set to this value" button. Returns the element after ``current`` (wrapping), or the first element when ``current`` isn't in the set (or is unknown). ``None`` for an empty cycle. """ if not cycle: return None if len(cycle) == 1 or current not in cycle: return cycle[0] return cycle[(cycle.index(current) + 1) % len(cycle)] def _sorted_values(values) -> list: """Cycle values in a deterministic order — numeric where the values are numbers. (Accepts a ``valid_values`` dict, iterating its keys, or any iterable of values, e.g. a decoded set.)""" try: return sorted(values, key=float) except (TypeError, ValueError): return list(values) class Parameter[V](UUIdentifable): """A device parameter. Generic over its value type ``V`` — ``value``, the keys of ``valid_values``, and ``default_value`` all share ``V`` (an ``int`` parameter has ``int`` labels and an ``int`` default, a ``str`` parameter has ``str`` throughout, etc.).""" id: UUID name: str # Manufacturer-provided, read-only description (for display / tooltips). Integrations set it # when the protocol exposes one; the Hub never edits it. description: str | None = None # User-editable free-text note, stored MajorDom-side only (never sent to the device). note: str | None = None data_type: ParameterDataType unit: ParameterUnit = ParameterUnit.plain # TODO: consider making str in case of an unsupported value (e.g. version mismatch). Alternativelely, consider adding a case like unknown role: ParameterRole visibility: ParameterVisibility # mainly for UX # value constraints (value for nubmers, char length for string, byte length for data) min_value: int | float | None = None max_value: int | float | None = None min_step: int | float | None = None # Allowed values -> display labels (an enum's members, or labelled presets). Keys are the same # type V as `value`. Mostly for enums; numeric parameters usually use min/max/step instead. valid_values: dict[V, str] | None = None fields: list[Any] | None = None # sub-parameters (Parameter instances) for data_type=struct # Integrations that expose commands with arguments (e.g. Matter) model the command itself as a # Parameter and each of its arguments as a nested Parameter in `fields` — command=parameter, arg=sub-parameter. # The main-parameter tap value(s): one value is a button, a set is a cycle (2 = toggle, # 3+ = cycle). Same value type V as `value`. See `main_cycle` / `can_be_main_parameter`. default_value: set[V] | V | None = None integration_data: Any @field_validator("valid_values", mode="before") @classmethod def _coerce_valid_values_keys(cls, v, info): """Coerce `valid_values` keys to the parameter's value type. JSON object keys are always strings, so a stored ``{0: "off"}`` (int enum) comes back as ``{"0": "off"}`` — without a concrete ``V`` (heterogeneous parameter lists are used unparametrized) Pydantic can't recover the type. Drive it off ``data_type`` instead, so keys match ``value``.""" if not isinstance(v, dict): return v coerce = { ParameterDataType.integer: int, ParameterDataType.enum: int, ParameterDataType.decimal: float, ParameterDataType.bool: bool, }.get(info.data.get("data_type")) if coerce is None: return v out = {} for key, label in v.items(): try: out[coerce(key)] = label except (TypeError, ValueError): out[key] = label return out @property def can_be_main_parameter(self) -> bool: """Whether this parameter can be a device's one-tap ``main_parameter`` (the room-tile shortcut). Requires ``user`` visibility — the main action is the most exposed control of all, so a settings/system parameter is never a candidate. Beyond that, eligible when a tap can do something meaningful: - ``bool`` — a toggle (each tap flips it); ``none`` — a button (fires the command); - ``valid_values`` set (an enum) — a **cycle** through its values; - ``default_value`` set — one value is a button, a set is a cycle (2 = toggle, 3+ = cycle) for ANY data type (see :func:`next_main_parameter_value`). """ return self.visibility == ParameterVisibility.user and bool( self.data_type in (ParameterDataType.bool, ParameterDataType.none) or self.default_value is not None or self.valid_values ) @property def main_cycle(self) -> list | None: """The ordered values a main-parameter tap cycles through, or ``None`` when a tap isn't a cycle (a ``none`` command button, or nothing to derive from). Derivation, first match: - ``default_value`` -> its values (a set is a cycle; a single value is a one-element button); - ``valid_values`` -> its keys; - ``bool`` -> ``[False, True]`` (a toggle). Values are ordered numerically where possible, so e.g. off(0) -> on(4) -> wraps. """ if self.default_value is not None: if isinstance(self.default_value, set): return _sorted_values(self.default_value) return [self.default_value] if self.valid_values: return _sorted_values(self.valid_values) if self.data_type == ParameterDataType.bool: return [False, True] return None # @classmethod # def from_orm(cls, obj): # if isinstance(obj, DeviceModelParameter): # return super().model_validate(obj.parameter) # else: # return super().model_validate(obj) class ParameterVisibilityPatch(Base): visibility: ParameterVisibility class ParameterState[V](Parameter[V]): """A parameter plus its current ``value`` (same type ``V`` as the parameter's labels/default). Values are pythonic and serialize natively (Pydantic handles JSON — a ``set`` default_value becomes an array, etc.). The Hub persists them as JSON; integrations set ``value`` directly. """ value: V | None = None # device_id: UUID ================================================================================ # FILE: majordom_integration_sdk/spec_drift.py ================================================================================ """Source-agnostic spec-drift diffing — the shared engine behind the MVD canary and the zha / matter-HA harvest refreshers. A "spec" here is any ``dict`` keyed by an identity (e.g. ``(cluster_id, attribute_id)``) whose values are the harvested judgment. :func:`diff_specs` compares a freshly-produced spec against the committed baseline and tiers every change by risk, mirroring how Dependabot tiers a version bump: ADD a key that didn't exist — low risk (reveals a previously-uncurated item) REMOVE a key that disappeared — medium (something we surfaced may vanish) RECLASSIFY an existing key's value changed — HIGH (changes what current users already see) CI runs the harvester, calls :func:`diff_specs` against the vendored artifact, and opens a Dependabot-style PR when anything changed — highlighting RECLASSIFY at the top for human review. """ from __future__ import annotations from collections.abc import Hashable, Mapping from dataclasses import dataclass from typing import Any # Generic over the key type — `Mapping` is invariant in its key, so a plain # `Mapping[Hashable, Any]` would reject perfectly valid concrete specs like # `dict[tuple[int, int], str]`. @dataclass(frozen=True) class DriftReport[K: Hashable]: added: dict[K, Any] removed: dict[K, Any] reclassified: dict[K, tuple[Any, Any]] # key -> (old, new) @property def is_empty(self) -> bool: return not (self.added or self.removed or self.reclassified) @property def has_high_risk(self) -> bool: """RECLASSIFY changes an existing item's judgment — the only tier that can silently alter what current users already see, so it always warrants human review.""" return bool(self.reclassified) def render(self, *, source: str = "spec") -> str: if self.is_empty: return f"[{source}] no drift" lines = [ f"[{source}] drift: +{len(self.added)} added -{len(self.removed)} removed " f"~{len(self.reclassified)} reclassified" ] # RECLASSIFY first — it's the high-risk tier. for key, (old, new) in sorted(self.reclassified.items(), key=lambda kv: repr(kv[0])): lines.append(f" ~ RECLASSIFY {key!r}: {old!r} -> {new!r}") for key, val in sorted(self.added.items(), key=lambda kv: repr(kv[0])): lines.append(f" + ADD {key!r}: {val!r}") for key, val in sorted(self.removed.items(), key=lambda kv: repr(kv[0])): lines.append(f" - REMOVE {key!r}: {val!r}") return "\n".join(lines) def diff_specs[K: Hashable](current: Mapping[K, Any], baseline: Mapping[K, Any]) -> DriftReport[K]: """Diff a freshly-harvested ``current`` spec against the committed ``baseline``.""" added = {k: current[k] for k in current if k not in baseline} removed = {k: baseline[k] for k in baseline if k not in current} reclassified = { k: (baseline[k], current[k]) for k in current if k in baseline and current[k] != baseline[k] } return DriftReport(added=added, removed=removed, reclassified=reclassified) ================================================================================ # FILE: majordom_integration_sdk/testing/__init__.py ================================================================================ """Shared test doubles for integration test suites. Both the Hub's own integration tests and third-party ``integration-*`` packages import these instead of reinventing them, so every integration is exercised through the same harness: - :class:`RecordingControllerOutput` — a spy implementing every ``ControllerOutput`` callback, capturing what a controller reports back to the Hub. - Offline discovery-service doubles (no radios/sockets touched). - :func:`build_test_dependencies` — assembles an ``AbstractController.Dependencies`` wired with the recording output, an in-memory device repository, temp-dir storage, and the discovery doubles, so a test can drive a controller exactly as the Hub would. """ from __future__ import annotations import tempfile from collections.abc import Iterable from pathlib import Path from typing import TYPE_CHECKING from uuid import UUID from majordom_integration_sdk.controller.abstract_controller import AbstractController, ControllerOutput from majordom_integration_sdk.discovery.ble_discovery import BLEDiscoveryService from majordom_integration_sdk.discovery.ssdp_discovery import SSDPDiscoveryService from majordom_integration_sdk.discovery.zeroconf_discovery import ZeroconfDiscoveryService from majordom_integration_sdk.repository.memory import DeviceRepositoryMemory from majordom_integration_sdk.schemas.device import 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 class RecordingControllerOutput(ControllerOutput): """Captures every callback a controller makes, for assertions in tests.""" 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.events: list[Event] = [] self.errors: list[tuple[str, bool]] = [] # (message, still_running) self.notifications: list[Notification] = [] async def controller_did_receive_discovery(self, controller: _Controller, discovery: Discovery): self.received_discoveries.append(discovery) async def controller_did_update_discovery(self, controller: _Controller, discovery: Discovery): self.updated_discoveries.append(discovery) 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_lose_device(self, controller: _Controller, device_id: UUID): self.lost_devices.append(device_id) async def controller_did_receive_events(self, controller: _Controller, events: Iterable[Event]): self.events.extend(events) async def controller_did_encounter_error(self, controller: _Controller, message: str, still_running: bool): self.errors.append((message, still_running)) async def controller_did_emit_notification(self, controller: _Controller, notification: Notification): self.notifications.append(notification) class FakeZeroconfDiscoveryService(ZeroconfDiscoveryService): """Real registration bookkeeping, but ``start``/``stop`` touch no network.""" async def start(self): pass async def stop(self): pass class FakeSSDPDiscoveryService(SSDPDiscoveryService): async def start(self): pass async def stop(self): pass class FakeBLEDiscoveryService(BLEDiscoveryService): async def start(self): pass async def stop(self): pass def build_test_dependencies( documents_folder: Path | None = None, integration: str | None = None, ) -> AbstractController.Dependencies: """Assemble controller ``Dependencies`` wired entirely with offline test doubles. Pass ``documents_folder`` to control where the controller writes files; a fresh temp dir is used otherwise. Pass ``integration`` to scope the in-memory repository to that integration's devices (access-control guard), mirroring how the Hub injects it. The recording output is reachable as ``deps.output``. """ repository = DeviceRepositoryMemory(integration=integration) folder = documents_folder or Path(tempfile.mkdtemp(prefix="majordom-sdk-test-")) folder.mkdir(parents=True, exist_ok=True) return AbstractController.Dependencies( output=RecordingControllerOutput(), make_device_repository=repository.session, documents_folder=folder, zeroconf_discovery_service=FakeZeroconfDiscoveryService(), ssdp_discovery_service=FakeSSDPDiscoveryService(), ble_discovery_service=FakeBLEDiscoveryService(), ) __all__ = [ "RecordingControllerOutput", "FakeZeroconfDiscoveryService", "FakeSSDPDiscoveryService", "FakeBLEDiscoveryService", "build_test_dependencies", ] ================================================================================ # FILE: pyproject.toml ================================================================================ [project] name = "majordom-integration-sdk" version = "0.1.5" description = "Models, protocols, and tooling for building MajorDom integrations." authors = [{ name = "Mark Parker", email = "mark@parker-programs.com" }] readme = "README.md" license = "Apache-2.0" license-files = ["LICENSE"] keywords = ["majordom", "smart-home", "home-automation", "iot", "integration", "sdk", "device-controller", "matter", "zigbee", "homekit"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Topic :: Home Automation", "Topic :: Software Development :: Libraries :: Application Frameworks", "Programming Language :: Python :: 3", "Operating System :: OS Independent", ] requires-python = ">=3.12" dependencies = [ "pydantic (>=2.0,<3.0)", "python-slugify", "zeroconf (>=0.130)", "bleak (>=0.22)", ] [project.optional-dependencies] # Interactive standalone runner: `majordom_integration_sdk.dev.run_cli`. Kept out of the base # install (the SDK is a runtime dep of every integration) — `pip install "majordom-integration-sdk[cli]"`. cli = ["typer-shell (>=0.2)", "rich (>=13)"] [project.urls] Homepage = "https://majordom.io" Documentation = "https://docs.majordom.io/device-integration" Repository = "https://github.com/MajorDom-Systems/integration-sdk" Issues = "https://github.com/MajorDom-Systems/integration-sdk/issues" [dependency-groups] dev = [ "poethepoet (>=0.36.0,<1.0.0)", "ty", # type checker; swap for mypy/pyright by editing the `check` task below "ruff", "pip-audit", # dependency vuln scan — gates release (not everyday CI); run locally with `poetry run pip-audit` "pytest>=9.0.3", "pytest-asyncio", "pytest-cov", "pytest-timeout", "pytest-repeat", "pytest-benchmark", "pytest-profiling", "pytest-resource-usage", "coverage", "pympler", # object memory introspection, leak hunting "psutil", # process/system resource usage "faker", # realistic fake data for tests "typer-shell", # the `cli` extra — installed in dev so ty/ruff check dev/cli.py "rich", ] [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" [tool.poetry] requires-poetry = ">=2.0" packages = [{ include = "majordom_integration_sdk" }] # schemas/ and discovery/ are lifted verbatim from the Hub, which type-checks them under # mypy (they carry mypy-style `# type: ignore` comments ty doesn't read). Exempt just those # rules for just those paths — all SDK-native code stays fully checked. Re-home in the Hub. [[tool.ty.overrides]] include = ["majordom_integration_sdk/schemas/**", "majordom_integration_sdk/discovery/**"] [tool.ty.overrides.rules] invalid-return-type = "ignore" invalid-assignment = "ignore" invalid-argument-type = "ignore" unsupported-base = "ignore" # The repository's typed reads use the `as_: type[T] = Device` idiom (same as the Hub's own # repository) so callers get `get(id, as_=MyDevice) -> MyDevice` while the bare `get(id)` # still returns a Device. ty rejects a concrete default for a TypeVar-typed parameter; the # alternative is @overload on every read across the protocol and both implementations. [[tool.ty.overrides]] include = ["majordom_integration_sdk/repository/**"] [tool.ty.overrides.rules] invalid-parameter-default = "ignore" [tool.pytest.ini_options] asyncio_mode = "auto" pythonpath = ["."] filterwarnings = ["once"] [tool.coverage.run] omit = ["tests/*"] [tool.ruff] line-length = 120 indent-width = 4 [tool.ruff.lint] select = [ "E", # pycodestyle "F", # pyflakes "UP", # pyupgrade "B", # flake8-bugbear "SIM", # flake8-simplify "I", # isort ] fixable = ["ALL"] [tool.ruff.format] quote-style = "double" indent-style = "space" line-ending = "auto" docstring-code-format = true [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] "tests/*" = ["E402"] # schemas/ and discovery/ are lifted verbatim from the Hub and maintained under its lint # rules; abstract_controller.py keeps the Hub's long-form docstrings. Exempt only the # house-style rules for these paths — SDK-native code stays fully linted. "majordom_integration_sdk/schemas/*" = ["E501", "UP042"] "majordom_integration_sdk/discovery/*" = ["E501", "SIM102", "SIM105"] "majordom_integration_sdk/controller/abstract_controller.py" = ["E501"] [tool.poe.tasks.install] shell = """ poetry install echo '#!/bin/sh\npoetry run poe check' > .git/hooks/pre-commit chmod +x .git/hooks/pre-commit echo "Pre-commit hook installed." """ [tool.poe.tasks.check] shell = """ code=0 echo "\nRunning ruff lint:" poetry run ruff check --fix || code=1 echo "\nRunning ruff format:" poetry run ruff format || code=1 echo "\nRunning ty:" poetry run ty check || code=1 echo "\nRunning pytest:" poetry run pytest --cov=majordom_integration_sdk --cov-report=xml || code=1 echo "\nRunning poetry build:" poetry build || code=1 echo "\nRunning poetry check:" poetry check || code=1 ${ci:+git diff --exit-code} exit $code """ args = [{ name = "ci", type = "boolean" }] ================================================================================ # FILE: tests/conftest.py ================================================================================ """Shared fixtures + factories for the SDK's own test suite.""" from uuid import UUID, uuid4 import pytest from majordom_integration_sdk.schemas.device import DeviceState from majordom_integration_sdk.schemas.parameter import ( ParameterDataType, ParameterRole, ParameterState, ParameterVisibility, ) def make_parameter_state( *, id: UUID | None = None, name: str = "Power", data_type: ParameterDataType = ParameterDataType.bool, role: ParameterRole = ParameterRole.control, visibility: ParameterVisibility = ParameterVisibility.user, ) -> ParameterState: state = ParameterState( id=id or uuid4(), name=name, data_type=data_type, role=role, visibility=visibility, integration_data=None, ) if data_type is ParameterDataType.bool: state.value = True return state def make_device_state( *, id: UUID | None = None, integration: str = "example", name: str = "Lamp", parameters: list[ParameterState] | None = None, ) -> DeviceState: return DeviceState( id=id or uuid4(), name=name, room_id=uuid4(), transport="wifi", integration=integration, manufacturer="ACME", parameters=parameters if parameters is not None else [make_parameter_state()], ) @pytest.fixture def device_state() -> DeviceState: return make_device_state() ================================================================================ # FILE: tests/test_cli.py ================================================================================ """Exercises the interactive CLI's controller-driving core (`_CliSession`) against a fake controller wired with the offline test dependencies — no REPL, no network. The typer-shell wiring on top is a thin layer over these same calls.""" from __future__ import annotations from uuid import UUID, uuid4 import pytest from majordom_integration_sdk.controller import AbstractController from majordom_integration_sdk.dev.cli import _CliControllerOutput, _CliSession, _print from majordom_integration_sdk.schemas.command import DeviceCommand from majordom_integration_sdk.schemas.device import ( CredentialsType, Device, DeviceState, Discovery, ProvidedCredentials, ) from majordom_integration_sdk.schemas.event import DeviceParameterChange from majordom_integration_sdk.schemas.parameter import ( ParameterDataType, ParameterRole, ParameterState, ParameterVisibility, ) from majordom_integration_sdk.testing import build_test_dependencies INTEGRATION = "Fake" def _param(role: ParameterRole = ParameterRole.control) -> ParameterState: return ParameterState( id=uuid4(), name="On", data_type=ParameterDataType.bool, role=role, visibility=ParameterVisibility.user, integration_data=None, ) class FakeController(AbstractController): """Minimal controller: pairing persists a device with one control parameter; every other method just records that it was called with the resolved object.""" name = INTEGRATION def __init__(self, dependencies, *, param_role: ParameterRole = ParameterRole.control): super().__init__(dependencies) self._discoveries: dict[UUID, Discovery] = {} self._param_role = param_role self.paired: list[UUID] = [] self.sent: list[tuple] = [] self.identified: list[UUID] = [] self.fetched: list[UUID] = [] self.unpaired: list[UUID] = [] @property def discoveries(self) -> dict[UUID, Discovery]: return self._discoveries async def start(self): pass 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.name, manufacturer=None, parameters=[_param(self._param_role)], ) async with self.dependencies.make_device_repository() as repo: await repo.save(state) self.paired.append(device_id) return device_id async def unpair(self, device: Device): self.unpaired.append(device.id) async def identify(self, device: Device): self.identified.append(device.id) async def fetch(self, device: Device): self.fetched.append(device.id) async def send_command(self, command: DeviceCommand, device: Device, parameter): self.sent.append((command.device_id, command.parameter_id, command.value)) def _discovery() -> Discovery: return Discovery( id=uuid4(), integration=INTEGRATION, expected_credentials_options=[CredentialsType.none], transport="test", device_manufacturer=None, device_name="Bulb", device_category=None, device_icon=None, ) @pytest.fixture def session_and_controller(): deps = build_test_dependencies(integration=INTEGRATION) controller = FakeController(deps) return _CliSession(controller), controller async def test_pair_list_control_unpair_flow(session_and_controller): session, controller = session_and_controller discovery = _discovery() controller.discoveries[discovery.id] = discovery # pair -> persists a device device_id = await session.pair(discovery.id, "none", "") assert device_id in controller.paired # devices -> lists it devices = await session.list_devices() assert [d.id for d in devices] == [device_id] # device -> shows its (single control) parameter state = await session.device_state(device_id) assert state is not None assert len(state.parameters) == 1 parameter = state.parameters[0] assert parameter.role == ParameterRole.control # 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)] # 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] # unpair assert await session.unpair(device_id) is True assert controller.unpaired == [device_id] async def test_pair_unknown_discovery_returns_none(session_and_controller): session, _ = session_and_controller assert await session.pair(uuid4()) is None async def test_actions_on_missing_device(session_and_controller): session, controller = session_and_controller missing = uuid4() assert (await session.control(missing, uuid4(), True)).startswith("No device") assert await session.identify(missing) is False assert await session.fetch(missing) is False assert await session.unpair(missing) is False assert controller.sent == controller.identified == [] async def test_control_rejects_non_control_parameter(): deps = build_test_dependencies(integration=INTEGRATION) controller = FakeController(deps, param_role=ParameterRole.sensor) session = _CliSession(controller) discovery = _discovery() controller.discoveries[discovery.id] = discovery device_id = await session.pair(discovery.id) parameter = (await session.device_state(device_id)).parameters[0] message = await session.control(device_id, parameter.id, True) assert "not controllable" in message assert controller.sent == [] # never reached the controller async def test_output_delegate_persists_event_values(): deps = build_test_dependencies(integration=INTEGRATION) controller = FakeController(deps) session = _CliSession(controller) discovery = _discovery() controller.discoveries[discovery.id] = discovery device_id = await session.pair(discovery.id) parameter = (await session.device_state(device_id)).parameters[0] output = _CliControllerOutput(deps.make_device_repository) await output.controller_did_receive_events( controller, [DeviceParameterChange(device_id=device_id, parameter_id=parameter.id, value=True)] ) async with deps.make_device_repository() as repo: stored = await repo.get_parameter_state(device_id, parameter.id) assert stored is not None assert stored.value # a (non-empty, encoded) value was written back by the event def test_prompt_safe_print_is_a_noop_safe_call(capsys): # Not a TTY under pytest, so _print falls back to a plain print (no escape codes leaking). _print("hello", "world") out = capsys.readouterr().out assert "hello" in out and "world" in out assert "\x1b[" not in out # no cursor-magic when stdout isn't a terminal def test_run_cli_is_lazily_exported(): from majordom_integration_sdk import dev assert callable(dev.run_cli) def test_capturing_handler_buffers_then_dumps(capsys): import logging from majordom_integration_sdk.dev.cli import _CapturingHandler handler = _CapturingHandler() handler.setFormatter(logging.Formatter("%(levelname)s %(message)s")) log = logging.getLogger("test.capture") log.addHandler(handler) log.setLevel(logging.DEBUG) log.propagate = False log.warning("chatty controller noise") # buffered, not printed — the TUI stays clean during the session assert capsys.readouterr().err == "" assert len(handler.records) == 1 # dumped to stderr on exit for debugging handler.dump() err = capsys.readouterr().err assert "chatty controller noise" in err assert "captured logs" in err ================================================================================ # FILE: tests/test_controller.py ================================================================================ """A minimal concrete controller exercises the framework end to end with test doubles.""" from uuid import UUID from conftest import make_device_state from majordom_integration_sdk.controller import AbstractController from majordom_integration_sdk.schemas.command import DeviceCommand from majordom_integration_sdk.schemas.device import Device, Discovery, Parameter, ProvidedCredentials from majordom_integration_sdk.schemas.event import DeviceParameterChange from majordom_integration_sdk.testing import RecordingControllerOutput, build_test_dependencies class _MinimalController(AbstractController[Device, Parameter]): name = "Example Protocol" @property def discoveries(self) -> dict[UUID, Discovery]: return {} async def start(self): pass async def stop(self): pass async def pair_device(self, discovery: Discovery, credentials: ProvidedCredentials | None): pass async def unpair(self, device: Device): pass async def identify(self, device: Device): pass async def fetch(self, device: Device): pass async def send_command(self, command: DeviceCommand, device: Device, parameter: Parameter): pass def test_name_slug_and_uuid_helpers(): controller = _MinimalController(build_test_dependencies()) assert controller.name_slug == "example-protocol" # Deterministic, namespaced, and hierarchical. device_uuid = controller.device_uuid("abc") assert controller.device_uuid("abc") == device_uuid assert controller.parameter_uuid(device_uuid, "p1") != device_uuid def test_documents_folder_is_the_injected_path(tmp_path): deps = build_test_dependencies(documents_folder=tmp_path) controller = _MinimalController(deps) assert controller.documents_folder == tmp_path assert controller.documents_folder.exists() async def test_controller_can_use_injected_repository(): deps = build_test_dependencies() controller = _MinimalController(deps) device = make_device_state(integration=controller.name_slug) async with controller.dependencies.make_device_repository() as repo: await repo.save(device) stored = await repo.state(device.id) assert stored is not None and stored.id == device.id async def test_recording_output_captures_reports(): deps = build_test_dependencies() controller = _MinimalController(deps) output = deps.output assert isinstance(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) await output.controller_did_receive_events(controller, [change]) assert output.connected_devices == [device.id] assert output.events == [change] def test_name_is_autoderived_from_class_name(): """`name` defaults to the class name (CamelCase → spaced), overridable with an explicit `name`.""" class HueController(AbstractController[Device, Parameter]): pass class ZigBeeController(AbstractController[Device, Parameter]): name = "ZigBee" # override the "Zig Bee" default assert HueController.name == "Hue" assert HueController.slug() == "hue" assert ZigBeeController.name == "ZigBee" assert ZigBeeController.slug() == "zigbee" ================================================================================ # FILE: tests/test_dev.py ================================================================================ """Smoke tests for the standalone dev runner (``majordom_integration_sdk.dev``). ``run_controller`` wires real, network-live discovery services and then blocks forever, so it is never run whole in CI. These tests cover the parts that can break without a device on the network: ``build_dependencies`` assembles a correctly-scoped dependency set, and ``run_controller`` starts and stops a controller cleanly (discovery services faked out). """ from uuid import UUID from conftest import make_device_state import majordom_integration_sdk.dev as dev from majordom_integration_sdk.controller import AbstractController from majordom_integration_sdk.dev import LoggingControllerOutput, build_dependencies, run_controller from majordom_integration_sdk.schemas.command import DeviceCommand from majordom_integration_sdk.schemas.device import Device, Discovery, Parameter, ProvidedCredentials from majordom_integration_sdk.testing import ( FakeBLEDiscoveryService, FakeSSDPDiscoveryService, FakeZeroconfDiscoveryService, ) class _RecordingController(AbstractController[Device, Parameter]): """Minimal controller that records that it was started and stopped.""" name = "Dev Example" instances: list["_RecordingController"] = [] def __init__(self, deps): super().__init__(deps) self.started = False self.stopped = False _RecordingController.instances.append(self) @property def discoveries(self) -> dict[UUID, Discovery]: return {} async def start(self): self.started = True async def stop(self): self.stopped = True async def pair_device(self, discovery: Discovery, credentials: ProvidedCredentials | None): pass async def unpair(self, device: Device): pass async def identify(self, device: Device): pass async def fetch(self, device: Device): pass async def send_command(self, command: DeviceCommand, device: Device, parameter: Parameter): pass # --- build_dependencies ------------------------------------------------------------------- def test_build_dependencies_defaults_to_memory_and_logging_output(tmp_path): deps = build_dependencies(storage_root=tmp_path, integration="Example") assert isinstance(deps.output, LoggingControllerOutput) # Real (network-live) discovery services are wired for a standalone run. assert isinstance(deps.zeroconf_discovery_service, dev.ZeroconfDiscoveryService) assert isinstance(deps.ssdp_discovery_service, dev.SSDPDiscoveryService) assert isinstance(deps.ble_discovery_service, dev.BLEDiscoveryService) def test_build_dependencies_scopes_documents_folder(tmp_path): # slug wins over integration for the subfolder name; the folder is created eagerly. deps = build_dependencies(storage_root=tmp_path, integration="Example", slug="example-protocol") assert deps.documents_folder == tmp_path / "example-protocol" assert deps.documents_folder.is_dir() def test_build_dependencies_honors_custom_output(tmp_path): from majordom_integration_sdk.testing import RecordingControllerOutput output = RecordingControllerOutput() deps = build_dependencies(storage_root=tmp_path, output=output) assert deps.output is output async def test_build_dependencies_memory_repository_round_trips(tmp_path): deps = build_dependencies(storage_root=tmp_path, integration="example") device = make_device_state(integration="example") async with deps.make_device_repository() as repo: await repo.save(device) assert (await repo.state(device.id)) is not None async def test_build_dependencies_sqlite_persists_to_file(tmp_path): db_path = tmp_path / "dev.sqlite" device = make_device_state(integration="example") deps = build_dependencies(storage_root=tmp_path, db_path=db_path, integration="example") async with deps.make_device_repository() as repo: await repo.save(device) assert db_path.exists(), "a db_path should select the file-backed sqlite repository" # A fresh dependency set over the same file sees the persisted device (survives 'restarts'). deps2 = build_dependencies(storage_root=tmp_path, db_path=db_path, integration="example") async with deps2.make_device_repository() as repo: assert (await repo.state(device.id)) is not None # --- run_controller ----------------------------------------------------------------------- async def test_run_controller_starts_and_stops_cleanly(tmp_path, monkeypatch): """run_controller should start the controller + discovery services and, on cancellation, stop everything without raising. Discovery services are faked so nothing touches the network.""" import asyncio monkeypatch.setattr(dev, "ZeroconfDiscoveryService", FakeZeroconfDiscoveryService) monkeypatch.setattr(dev, "SSDPDiscoveryService", FakeSSDPDiscoveryService) monkeypatch.setattr(dev, "BLEDiscoveryService", FakeBLEDiscoveryService) _RecordingController.instances.clear() task = asyncio.create_task(run_controller(_RecordingController, storage_root=tmp_path)) async with asyncio.timeout(5): while not (_RecordingController.instances and _RecordingController.instances[-1].started): await asyncio.sleep(0.01) task.cancel() await task # run_controller swallows the cancellation after cleaning up controller = _RecordingController.instances[-1] assert controller.started and controller.stopped assert task.done() and task.exception() is None ================================================================================ # FILE: tests/test_main_parameter.py ================================================================================ from uuid import uuid4 from majordom_integration_sdk.schemas.parameter import ( Parameter, ParameterDataType, ParameterRole, ParameterVisibility, next_main_parameter_value, ) def _p(*, data_type, valid_values=None, default_value=None): return Parameter( id=uuid4(), name="p", data_type=data_type, role=ParameterRole.control, visibility=ParameterVisibility.user, valid_values=valid_values, default_value=default_value, integration_data=None, ) def test_cycle_toggles_between_two_values(): assert next_main_parameter_value(0, [0, 4]) == 4 assert next_main_parameter_value(4, [0, 4]) == 0 def test_cycle_rotates_and_wraps(): assert next_main_parameter_value(1, [0, 1, 2]) == 2 assert next_main_parameter_value(2, [0, 1, 2]) == 0 def test_single_value_cycle_is_a_button(): assert next_main_parameter_value(99, [3]) == 3 # always sets to 3 regardless of current def test_unknown_current_starts_at_first(): assert next_main_parameter_value(None, [0, 4]) == 0 assert next_main_parameter_value(7, [0, 4]) == 0 def test_empty_cycle_returns_none(): assert next_main_parameter_value(0, []) is None assert next_main_parameter_value(0, None) is None def test_enum_with_valid_values_can_be_main(): assert _p(data_type=ParameterDataType.enum, valid_values={0: "off", 4: "on"}).can_be_main_parameter def test_enum_without_valid_values_cannot_be_main(): assert not _p(data_type=ParameterDataType.enum).can_be_main_parameter def test_default_value_makes_any_type_main(): assert _p(data_type=ParameterDataType.integer, default_value=5).can_be_main_parameter def test_valid_values_makes_any_type_main(): assert _p(data_type=ParameterDataType.integer, valid_values={0: "off", 80: "bright"}).can_be_main_parameter def test_main_cycle_from_valid_values(): p = _p(data_type=ParameterDataType.integer, valid_values={80: "bright", 0: "off"}) assert p.main_cycle == [0, 80] # numeric order def test_main_cycle_from_set_default_value(): p = _p(data_type=ParameterDataType.integer, valid_values={0: "off", 40: "dim", 80: "bright"}, default_value={0, 80}) assert p.main_cycle == [0, 80] # the curated default_value subset wins over full valid_values assert p.default_value == {0, 80} # a pythonic set (serializes to a JSON array) def test_main_cycle_single_default_is_button(): p = _p(data_type=ParameterDataType.integer, default_value=5) assert p.main_cycle == [5] def test_main_cycle_bool_toggles(): assert _p(data_type=ParameterDataType.bool).main_cycle == [False, True] def test_main_cycle_none_command_is_not_a_cycle(): assert _p(data_type=ParameterDataType.none).main_cycle is None def test_value_is_pythonic(): from majordom_integration_sdk.schemas.parameter import ParameterState p = _p(data_type=ParameterDataType.integer) state = ParameterState.model_validate(p, from_attributes=True) state.value = 42 assert state.value == 42 assert ParameterState.model_validate_json(state.model_dump_json()).value == 42 def test_non_user_visibility_cannot_be_main(): p = _p(data_type=ParameterDataType.bool) p.visibility = ParameterVisibility.setting assert not p.can_be_main_parameter p.visibility = ParameterVisibility.system assert not p.can_be_main_parameter ================================================================================ # FILE: tests/test_notification.py ================================================================================ import asyncio from typing import Any, cast from majordom_integration_sdk.schemas.notification import Notification, NotificationPriority, NotificationType from majordom_integration_sdk.testing import RecordingControllerOutput def test_notification_defaults(): n = Notification(message="Firmware update available") assert n.type is NotificationType.info assert n.priority is NotificationPriority.normal assert n.ttl is None def test_output_records_notification(): out = RecordingControllerOutput() n = Notification( message="Re-plug the radio", type=NotificationType.warning, priority=NotificationPriority.urgent, ttl=30, ) asyncio.run(out.controller_did_emit_notification(cast(Any, None), n)) assert out.notifications == [n] ================================================================================ # FILE: tests/test_parameter_audit.py ================================================================================ from uuid import uuid4 from majordom_integration_sdk.parameter_audit import audit_device_parameters from majordom_integration_sdk.schemas.parameter import ( Parameter, ParameterDataType, ParameterRole, ParameterUnit, ParameterVisibility, ) def _p(name, *, vis=ParameterVisibility.user, unit=ParameterUnit.plain, role=ParameterRole.sensor): return Parameter( id=uuid4(), name=name, data_type=ParameterDataType.integer, unit=unit, role=role, visibility=vis, integration_data=None, ) def test_clean_device_has_no_warnings(): params = [ _p("temperature", unit=ParameterUnit.celsius), _p("humidity", unit=ParameterUnit.percentage), _p("battery", unit=ParameterUnit.percentage, role=ParameterRole.sensor), ] assert audit_device_parameters("Sensor", params) == [] def test_flags_over_exposure(): params = [_p(f"attr_{i}") for i in range(12)] warnings = audit_device_parameters("Flooded", params) assert any("over-exposed" in w for w in warnings) def test_ignores_non_user_params_in_count(): params = [_p(f"attr_{i}", vis=ParameterVisibility.system) for i in range(20)] + [_p("on_off")] assert audit_device_parameters("Lean", params) == [] def test_flags_near_duplicate_names(): params = [_p("current_x", unit=ParameterUnit.percentage), _p("current_y", unit=ParameterUnit.percentage)] assert any("near-duplicate" in w for w in audit_device_parameters("Light", params)) def test_similar_name_whitelist_suppresses(): params = [ _p("occupied_heating_setpoint", unit=ParameterUnit.celsius, role=ParameterRole.control), _p("occupied_cooling_setpoint", unit=ParameterUnit.celsius, role=ParameterRole.control), ] warnings = audit_device_parameters( "Thermostat", params, ignore_similar_pairs=[("occupied_heating_setpoint", "occupied_cooling_setpoint")], ) assert not any("near-duplicate" in w for w in warnings) def test_flags_redundant_representation_group(): params = [ _p("current_hue", unit=ParameterUnit.percentage), _p("current_saturation", unit=ParameterUnit.percentage), _p("current_x", unit=ParameterUnit.percentage), _p("current_y", unit=ParameterUnit.percentage), ] assert any("redundant representations" in w for w in audit_device_parameters("Light", params)) ================================================================================ # FILE: tests/test_repository.py ================================================================================ """Both first-class repositories must satisfy the same protocol behaviour.""" from uuid import uuid4 import pytest from conftest import make_device_state from majordom_integration_sdk.repository import ( DeviceRepositoryMemory, DeviceRepositoryProtocol, SqliteDeviceRepository, ) from majordom_integration_sdk.schemas.base import Base from majordom_integration_sdk.schemas.device import Device from majordom_integration_sdk.schemas.parameter import ParameterVisibility class _PairingData(Base): """An integration's own typed `integration_data` (e.g. HomeKit pairing data).""" token: str class _MyDevice(Device): integration_data: _PairingData def make_my_device(device_id=None, token: str = "secret", integration: str = "example") -> _MyDevice: return _MyDevice( id=device_id or uuid4(), name="Lamp", room_id=uuid4(), transport="wifi", integration=integration, manufacturer="ACME", integration_data=_PairingData(token=token), ) @pytest.fixture(params=["memory", "sqlite"]) def repository(request, tmp_path) -> DeviceRepositoryProtocol: if request.param == "memory": return DeviceRepositoryMemory() return SqliteDeviceRepository(tmp_path / "devices.db") 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) 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] 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 async def test_parameter_state_round_trip(repository, device_state): parameter = device_state.parameters[0] async with repository.session() as repo: await repo.save(device_state) fetched = await repo.get_parameter_state(device_state.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) assert updated is not None and updated.value == 7 async def test_update_visibility(repository, device_state): parameter = device_state.parameters[0] 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) 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 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) 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_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 async def test_typed_read_returns_typed_integration_data(repository): device = make_my_device(token="secret") async with repository.session() as repo: await repo.save(device) got = await repo.get(device.id, as_=_MyDevice) assert got is not None assert isinstance(got.integration_data, _PairingData) assert got.integration_data.token == "secret" listed = await repo.get_all(as_=_MyDevice) assert [d.integration_data.token for d in listed] == ["secret"] async def test_device_and_state_views_merge(repository, device_state): """Device carries integration_data, DeviceState carries parameters — they're siblings, so saving one must not clobber the other's half of the record.""" device = make_my_device(device_id=device_state.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) 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) assert typed_again is not None, "integration_data must survive a DeviceState save" assert typed_again.integration_data.token == "t" @pytest.fixture(params=["memory", "sqlite"]) def make_scoped(request, tmp_path): """Factory building repositories that share one backing store, scoped per call.""" store: dict = {} path = tmp_path / "scoped.db" def make(integration): if request.param == "memory": return DeviceRepositoryMemory(integration=integration, store=store) return SqliteDeviceRepository(path, integration=integration) return make async def test_scope_limits_get_all(make_scoped): admin = make_scoped(None) # full access async with admin.session() as repo: await repo.save(make_device_state(integration="mine")) await repo.save(make_device_state(integration="other")) async with make_scoped("mine").session() as repo: assert {d.integration for d in await repo.get_all()} == {"mine"} async with make_scoped(None).session() as repo: assert {d.integration for d in await repo.get_all()} == {"mine", "other"} async def test_scope_rejects_foreign_save(make_scoped): async with make_scoped("mine").session() as repo: with pytest.raises(PermissionError): await repo.save(make_device_state(integration="other")) async def test_scope_hides_foreign_reads(make_scoped): foreign = make_device_state(integration="other") 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 async def test_sqlite_persists_across_sessions(tmp_path, device_state): repo = SqliteDeviceRepository(tmp_path / "devices.db") async with repo.session() as session: await session.save(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) assert persisted is not None and persisted.id == device_state.id ================================================================================ # FILE: tests/test_schemas.py ================================================================================ """Schema behaviour that the SDK owns — pythonic parameter values, generic over the value type V.""" from uuid import uuid4 from majordom_integration_sdk.schemas.parameter import ( ParameterDataType, ParameterRole, ParameterState, ParameterVisibility, ) def _state(data_type: ParameterDataType, **kw) -> ParameterState: return ParameterState( id=uuid4(), name="p", data_type=data_type, role=ParameterRole.control, visibility=ParameterVisibility.user, integration_data=None, **kw, ) def test_scalar_value_is_pythonic_and_roundtrips(): for dt, v in [ (ParameterDataType.integer, 5), (ParameterDataType.bool, True), (ParameterDataType.decimal, 1.5), (ParameterDataType.string, "hi"), ]: s = _state(dt, value=v) assert s.value == v assert ParameterState.model_validate_json(s.model_dump_json()).value == v def test_struct_value_is_a_plain_dict(): s = _state(ParameterDataType.struct, value={"a": 1, "b": [2, 3]}) assert s.value == {"a": 1, "b": [2, 3]} assert ParameterState.model_validate_json(s.model_dump_json()).value == {"a": 1, "b": [2, 3]} def test_value_valid_values_and_default_share_one_type(): # An int parameter: int value, int valid_values keys, int default_value — all V=int. s = _state(ParameterDataType.enum, value=0, valid_values={0: "off", 4: "on"}, default_value={0, 4}) assert (s.value, s.valid_values, s.default_value) == (0, {0: "off", 4: "on"}, {0, 4}) rt = ParameterState.model_validate_json(s.model_dump_json()) assert rt.default_value == {0, 4} # a set survives the JSON-array round-trip assert rt.valid_values == {0: "off", 4: "on"} # int keys survive def test_single_default_value_stays_scalar(): s = _state(ParameterDataType.integer, default_value=5) assert s.default_value == 5 # a lone value is the scalar branch of `set[V] | V`, not a set ================================================================================ # FILE: tests/test_spec_drift.py ================================================================================ from majordom_integration_sdk.spec_drift import diff_specs def test_detects_each_tier(): baseline = {(1, 1): "user", (1, 2): "setting", (1, 3): "system"} current = {(1, 1): "user", (1, 2): "user", (1, 4): "setting"} # 1,2 reclassified; 1,3 removed; 1,4 added r = diff_specs(current, baseline) assert r.added == {(1, 4): "setting"} assert r.removed == {(1, 3): "system"} assert r.reclassified == {(1, 2): ("setting", "user")} def test_reclassify_is_high_risk(): r = diff_specs({(1, 1): "user"}, {(1, 1): "setting"}) assert r.has_high_risk assert not r.is_empty def test_add_only_is_not_high_risk(): r = diff_specs({(1, 1): "user", (2, 2): "setting"}, {(1, 1): "user"}) assert not r.has_high_risk assert r.added == {(2, 2): "setting"} def test_identical_is_empty(): spec = {(1, 1): "user"} r = diff_specs(spec, dict(spec)) assert r.is_empty assert "no drift" in r.render(source="zha") def test_render_lists_reclassify_first(): r = diff_specs({(1, 1): "user", (9, 9): "user"}, {(1, 1): "setting"}) out = r.render(source="zha") assert out.index("RECLASSIFY") < out.index("ADD") # integration-template — full source # repo: https://github.com/MajorDom-Systems/integration-template branch: master commit: 9522739a0e9235fee98d1fbf1bda0695387998c3 # generated: 2026-07-24 — AUTO-GENERATED, do not edit by hand ## File tree .gitignore LICENSE README.md integration_template/__init__.py integration_template/controller.py pyproject.toml tests/conftest.py tests/test_controller.py tests/test_smoke.py ================================================================================ # FILE: .gitignore ================================================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] *$py.class # C extensions *.so # Distribution / packaging build/ dist/ *.egg-info/ *.egg # Unit test / coverage reports .coverage .coverage.* coverage.xml htmlcov/ .pytest_cache/ .hypothesis/ # Environments .env .envrc .venv env/ venv/ # Poetry # poetry.lock # uncomment for applications, keep commented for libraries # Type checkers .mypy_cache/ .dmypy.json .pyre/ .pytype/ # Tools .ruff_cache/ .ropeproject # mkdocs (only relevant if you kept mkdocs.yml + docs/) /site .cache/ # IDEs .idea/ .vscode/ # OS .DS_Store Thumbs.db ================================================================================ # FILE: LICENSE ================================================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================================================ # FILE: README.md ================================================================================ # Creating a new MajorDom integration from this template Click **Use this template → Create a new repository** (name it `integration-`, e.g. `integration-hue`), then: **1. Rename the placeholders** (find & replace across the repo) - `integration-template` → your distribution name, by convention `majordom-` (hyphens): in `pyproject.toml` (`[project].name`) and `.github/workflows/release.yml` (`pypi-package-name`). - `integration_template` → your import name `majordom_` (underscores): the `integration_template/` directory, plus the `--cov=` and `packages` references in `pyproject.toml`. - Rename `ExampleController` and fill in `integration_template/controller.py` with real protocol logic. `tests/test_controller.py` is prefilled to fail until you do — work the **Progress** checklist below and tick items off as your CI goes green. - In `.github/workflows/test.yml`, delete the `template-selfcheck` job and the `if:` guard on the `test` job — they exist only to keep the *template* repo green and aren't wanted in a real integration. - Fill in the **About this integration** section below (it doubles as your PR summary — a reviewer reads the checklist to see what's actually implemented). **2. Install + pre-commit hook** ```sh pip install poetry poethepoet && poe install ``` **3. Create the `develop` branch** ```sh git checkout -b develop && git push origin develop ``` **4. GitHub repo settings** — same as any package repo built on the shared workflows: - **General** → enable **Allow auto-merge**; - **Branches** → protect `master` (require the `test / check` status, restrict push to `github-actions[bot]`) and `develop` (require `test / check`); - **Environments** → create `release` (deployment branch `develop` only, required reviewer). Then configure PyPI trusted publishing for your package (Owner = the GitHub user or org that owns **this** repo, Repository = this repo, Workflow `release.yml`, Environment `release`). The reusable CI/CD lives in [ParkerIndustries/workflows](https://github.com/ParkerIndustries/workflows). # integration-template A [MajorDom](https://majordom.io) integration — bridges the Example protocol into the MajorDom language. Built for the **MajorDom Hub**, but it doesn't need it: this is a standalone, standardized library for the Example protocol that you can use on its own (see **Run it standalone** below). Built on the [MajorDom Integration SDK](https://github.com/MajorDom-Systems/integration-sdk). The integration's entry point is a `Controller` (`integration_template/controller.py`) that the Hub — or the SDK's dev runner — instantiates and drives through its lifecycle: discovery → pairing → commands → teardown. - **Other protocols:** browse the [MajorDom integrations](https://github.com/orgs/MajorDom-Systems/repositories?q=integration-). - **Create your own:** start from the [integration template](https://github.com/MajorDom-Systems/integration-template). ## Documentation Full integration-author docs — the controller lifecycle, data models, storing data, discovery, and a worked example — live at **[docs.majordom.io](https://docs.majordom.io/device-integration)**. ## Development ```sh poetry install && poetry run poe install ``` | Task | Description | |------|-------------| | `poe check` | Full quality pipeline (ruff, ty, pytest, poetry build/check) | | `poe check --ci` | Same, plus `git diff --exit-code` | Work lands on `develop`; `master` is protected and released via **Actions → Release**. Tests drive the controller with the SDK's test doubles (`majordom_integration_sdk.testing`) against a virtual/simulated device — see `tests/`. ## Run it standalone (without the Hub) Your integration is a standalone library — import it into another app, or run **just this integration** interactively (discover, pair, control, and inspect devices from a prompt) with no Hub. Note here whatever prerequisites your protocol needs to run on its own (a broker, a radio, a local server, …). See **[Standalone mode](https://docs.majordom.io/device-integration/standalone)** for the interactive CLI, watch mode, and the programmatic API. ## About this integration - **Protocol / platform:** _e.g. Philips Hue (Zigbee via a bridge)_ - **Transport(s):** _wifi / ble / zigbee / …_ - **Supported devices:** _…_ - **Credentials needed to pair:** _none / code / secret / qr_ ### Required harness - **Hardware adapters:** _e.g. an 802.15.4 radio (SkyConnect / a Thread or Zigbee dongle), a USB serial gateway …_ — the Hub assigns OS device paths through `dependencies.hardware_interfaces` (e.g. `/dev/ttyACM0`). - **Third-party software services:** _e.g. an OpenThread Border Router (OTBR), a vendor bridge/hub, an MQTT broker, a matter-server instance …_ — what must be running and reachable. - **OS / permissions:** _e.g. Bluetooth access, host networking, mDNS/SSDP on the LAN …_ ### Protocol stack (OSI) | OSI layer | Protocol | Implemented by | |-----------|----------|----------------| | Application (7) | _Matter clusters / data model_ | **this integration** (via `chip` lib) | | Session (5) | _CASE / PASE secure session_ | library | | Transport (4) | _UDP_ | OS | | Network (3) | _IPv6 · 6LoWPAN_ | OS · OTBR (harness) | | Data link / Physical (1–2) | _Thread · IEEE 802.15.4_ | radio adapter (harness) | ### Progress The canonical [Implementation Checklist](https://docs.majordom.io/device-integration) — tick items as you implement them (and as the corresponding test in `tests/` goes green): - [ ] Discovery services registered via `self.dependencies.zeroconf_discovery_service`, `ssdp_discovery_service`, and/or `ble_discovery_service` as appropriate; cancel closures saved and called in `stop` - [ ] Discovery service listeners fire when devices are found, and the controller calls `self.dependencies.output.controller_did_receive_discovery` - [ ] Discovery of devices already paired to the Hub on reconnect, e.g. after a reboot (`self.dependencies.output.controller_did_connect_device` is called) - [ ] `start_pairing_window` is implemented but only if the protocol requires an explicit scan (like zigbee) - [ ] Device pairing - [ ] Device schema is properly mapped: device info, parameter list, and each parameter's metadata are translated to MajorDom's domain model - [ ] Hub → Device control (`send_command` is implemented) - [ ] Device → Hub event subscription (`self.dependencies.output.controller_did_receive_events` is called on incoming events) - [ ] `identify` is implemented - [ ] `unpair` is implemented - [ ] `fetch` is implemented - [ ] Paired devices going offline/coming back online *while the Hub is running* (not just on reboot) — set `device.available` accordingly (report `controller_did_lose_device`), and clear/set `last_error` to match - [ ] Graceful shutdown in `stop`, cancelling any running tasks, discovery stopped, all connections closed - [ ] Tests pass against a virtual/simulated device (`tests/test_controller.py`) - [ ] README fully filled in: delete the template-setup section above, complete **About this integration**, **Required harness**, **Protocol stack (OSI)**, and **Notes** with real content ### Notes _e.g. "Hobby project, maintained best-effort." · "IP transport works; BLE pairing is not implemented yet." · "Help wanted: reliable re-pair after a bridge reboot." · known quirks, firmware versions tested against, limitations._ ## License See [LICENSE](LICENSE). Your integration code is yours to license as you choose. For commercial licensing or partnership inquiries regarding MajorDom, contact us via [parker-industries.org/partnership](https://parker-industries.org/partnership). ================================================================================ # FILE: integration_template/__init__.py ================================================================================ """integration_template — rename this package to majordom_ (see README). Exposes the integration's Controller, the entry point the Hub (or the SDK's standalone dev runner) instantiates and drives. """ from integration_template.controller import ExampleController __all__ = ["ExampleController"] ================================================================================ # FILE: integration_template/controller.py ================================================================================ """A minimal MajorDom integration controller. This is a worked skeleton — replace the TODO bodies with real protocol calls. It shows the full lifecycle the Hub drives: discovery -> pairing -> commands -> teardown. See the full guide at https://docs.majordom.io/device-integration. A Controller bridges one external protocol/platform into the MajorDom language. The Hub instantiates it with injected `Dependencies` and calls the methods below; the controller reports back to the Hub through `dependencies.output` (a `ControllerOutput`). """ from __future__ import annotations from uuid import UUID from majordom_integration_sdk.controller import AbstractController from majordom_integration_sdk.schemas.command import DeviceCommand from majordom_integration_sdk.schemas.device import Device, Discovery, Parameter, ProvidedCredentials # Output methods to call (see the docs): # 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_lose_device(self, controller: AbstractController, device_id: UUID): ... # async def controller_did_receive_events(self, controller: AbstractController, events: Iterable[Event]): ... class ExampleController(AbstractController[Device, Parameter]): """Bridges the Example protocol into MajorDom.""" def __init__(self, dependencies: AbstractController.Dependencies): super().__init__(dependencies) self._discoveries: dict[UUID, Discovery] = {} name = "Example" @property def discoveries(self) -> dict[UUID, Discovery]: # Return the cached snapshot only — never scan here. return self._discoveries # Lifecycle ------------------------------------------------------------- async def start(self) -> None: # Register discovery services, subscribe to protocol events, and reconcile the # state of already-paired devices here. # e.g. self.dependencies.zeroconf_discovery_service.add_listener("_example._tcp.local.", self) ... async def stop(self) -> None: # Cancel tasks and release every held resource. ... # Hub -> device --------------------------------------------------------- async def pair_device(self, discovery: Discovery, credentials: ProvidedCredentials | None) -> None: # Establish a session with the device, then report success to the Hub: # await self.dependencies.output.controller_did_connect_device(self, self.device_uuid(...)) raise NotImplementedError async def unpair(self, device: Device) -> None: raise NotImplementedError async def identify(self, device: Device) -> None: # Ask the device to blink/beep so the user can locate it. raise NotImplementedError async def fetch(self, device: Device) -> None: # Refresh the device and its parameters, reporting changes via # self.dependencies.output.controller_did_receive_events(self, [...]). raise NotImplementedError async def send_command(self, command: DeviceCommand, device: Device, parameter: Parameter) -> None: # Translate the MajorDom command into a protocol-level write. raise NotImplementedError ================================================================================ # FILE: pyproject.toml ================================================================================ # Rename `integration-template` (PyPI/distribution name, hyphens) and the # `integration_template/` package directory (import name, underscores) to your own — by # convention `majordom-` / `majordom_` (e.g. majordom-homekit / # majordom_homekit). Keep them matching so poetry-core auto-detects the package, and update # `pypi-package-name` in the release stub + `--cov=` below to match. [project] name = "integration-template" version = "0.1.0" description = "A MajorDom integration." authors = [{ name = "Mark Parker", email = "mark@parker-programs.com" }] readme = "README.md" # The template scaffold is Apache-2.0. Relicense your integration as you see fit — note # that linking a copyleft (GPL/AGPL) protocol library may constrain your choice. license = "Apache-2.0" license-files = ["LICENSE"] keywords = ["majordom", "smart-home", "home-automation", "iot", "integration", "template", "scaffold"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Topic :: Home Automation", "Programming Language :: Python :: 3", ] requires-python = ">=3.12" dependencies = [ "majordom-integration-sdk (>=0.1,<1.0)", ] [project.urls] Homepage = "https://majordom.io" Documentation = "https://docs.majordom.io/device-integration" Repository = "https://github.com/MajorDom-Systems/integration-template" [dependency-groups] dev = [ "poethepoet (>=0.36.0,<1.0.0)", "ty", # type checker; swap for mypy/pyright by editing the `check` task below "ruff", "pip-audit", # dependency vuln scan — gates release (not everyday CI); run locally with `poetry run pip-audit` "pytest", "pytest-asyncio", "pytest-cov", "pytest-timeout", "pytest-repeat", "pytest-benchmark", "pytest-profiling", "pytest-resource-usage", "coverage", "pympler", # object memory introspection, leak hunting "psutil", # process/system resource usage "faker", # realistic fake data for tests ] [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" [tool.poetry] requires-poetry = ">=2.0" packages = [{ include = "integration_template" }] [tool.pytest.ini_options] asyncio_mode = "auto" pythonpath = ["."] filterwarnings = ["once"] [tool.coverage.run] omit = ["tests/*"] [tool.ruff] line-length = 120 indent-width = 4 [tool.ruff.lint] select = [ "E", # pycodestyle "F", # pyflakes "UP", # pyupgrade "B", # flake8-bugbear "SIM", # flake8-simplify "I", # isort ] fixable = ["ALL"] [tool.ruff.format] quote-style = "double" indent-style = "space" line-ending = "auto" docstring-code-format = true [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] "tests/*" = ["E402"] [tool.poe.tasks.install] shell = """ poetry install echo '#!/bin/sh\npoetry run poe check' > .git/hooks/pre-commit chmod +x .git/hooks/pre-commit echo "Pre-commit hook installed." """ [tool.poe.tasks.check] shell = """ code=0 echo "\nRunning ruff lint:" poetry run ruff check --fix || code=1 echo "\nRunning ruff format:" poetry run ruff format || code=1 echo "\nRunning ty:" poetry run ty check || code=1 echo "\nRunning pytest:" poetry run pytest --cov=integration_template --cov-report=xml || code=1 echo "\nRunning poetry build:" poetry build || code=1 echo "\nRunning poetry check:" poetry check || code=1 ${ci:+git diff --exit-code} exit $code """ args = [{ name = "ci", type = "boolean" }] ================================================================================ # FILE: tests/conftest.py ================================================================================ """Shared pytest fixtures for the whole suite live here. conftest.py is auto-discovered by pytest — no import needed. The fixtures wire the integration's Controller with the SDK's test doubles (a recording `ControllerOutput`, an in-memory device repository, and fake discovery services) and build sample `Discovery`/`DeviceState` objects, so tests can drive the controller exactly as the Hub would — without a running Hub. **Device persistence is the Hub's core business logic, not the integration's.** The Hub creates and stores device rows (name/room mapping, id assignment, the provisional→final id reconciliation on pairing); an integration only reads through the injected repository and writes parameter *state*. These fixtures therefore seed the repository the way the Hub would, so the controller sees realistic state — the tests never make the integration persist a device itself. """ from uuid import uuid4 import pytest from majordom_integration_sdk.controller import AbstractController from majordom_integration_sdk.schemas.device import CredentialsType, Device, DeviceState, Discovery from majordom_integration_sdk.schemas.parameter import ( ParameterDataType, ParameterRole, ParameterState, ParameterVisibility, ) from majordom_integration_sdk.testing import build_test_dependencies from integration_template import ExampleController INTEGRATION = "example" @pytest.fixture def deps() -> AbstractController.Dependencies: """SDK-provided test dependencies (recording output + in-memory repo + fake discovery).""" return build_test_dependencies(integration=INTEGRATION) @pytest.fixture def controller(deps: AbstractController.Dependencies) -> ExampleController: return ExampleController(deps) # A virtual / simulated device — a fake endpoint that speaks your protocol in-process (no # hardware, no network) so tests are deterministic and can assert what actually reached the # device. Stand one up here and have the controller talk to it (e.g. via a base URL / mock # transport injected through the controller). Tests then assert on both the controller's # reports (deps.output) and this object's observed state. # # @pytest.fixture # def virtual_device(): # dev = VirtualExampleDevice() # your in-process fake # dev.start() # yield dev # dev.stop() async def _seed(deps: AbstractController.Dependencies, device: DeviceState) -> None: """Persist a device the way the Hub would, before handing control to the integration.""" async with deps.make_device_repository() as repo: await repo.save(device) @pytest.fixture def discovery() -> Discovery: """A sample discovered-but-unpaired device (the controller surfaced this).""" return Discovery( id=uuid4(), integration=INTEGRATION, expected_credentials_options=[CredentialsType.none], transport="wifi", device_manufacturer="ACME", device_name="Example Lamp", device_category=None, device_icon=None, ) @pytest.fixture async def provisional_device(deps: AbstractController.Dependencies, discovery: Discovery) -> DeviceState: """The device as it exists when the Hub calls `pair_device`. Before pairing, the Hub has already created the row from the user's input: name and room mapped, and its id set to the discovery's (still provisional) id — but no parameters yet; the controller discovers/maps those while pairing. After pairing the controller reports the real device id and the Hub reconciles the row. We seed the repository to match. """ device = DeviceState( id=discovery.id, # provisional id == discovery id, until pairing assigns the real one name="Living Room Lamp", # mapped by the Hub from the user's input room_id=uuid4(), transport=discovery.transport, integration=INTEGRATION, manufacturer=discovery.device_manufacturer, parameters=[], ) await _seed(deps, device) return device @pytest.fixture async def device_state(deps: AbstractController.Dependencies) -> DeviceState: """An already-paired device with one on/off parameter, as the Hub *stores* it (the full state, with parameters). Seeded into the repository.""" power = ParameterState( id=uuid4(), name="Power", data_type=ParameterDataType.bool, role=ParameterRole.control, visibility=ParameterVisibility.user, integration_data=None, ).with_value(True) state = DeviceState( id=uuid4(), name="Living Room Lamp", room_id=uuid4(), transport="wifi", integration=INTEGRATION, manufacturer="ACME", parameters=[power], ) await _seed(deps, state) return state @pytest.fixture def device(device_state: DeviceState) -> Device: """The device as the Hub *passes* it to `fetch`/`identify`/`unpair`/`send_command` — a `Device` (info + integration_data), without the parameter list.""" return Device.model_validate(device_state.model_dump()) @pytest.fixture def parameter(device_state: DeviceState) -> ParameterState: """The target parameter for `send_command`, paired with `device`.""" return device_state.parameters[0] ================================================================================ # FILE: tests/test_controller.py ================================================================================ """Tests that drive the controller through its lifecycle. These are **prefilled to fail** against the unimplemented skeleton: every test below calls a method that still raises `NotImplementedError`, so CI stays red until you implement the controller. Run these against a **virtual / simulated device** — a fake endpoint that speaks your protocol in-process (no real hardware, no network), so CI is deterministic. Add a fixture for it in `conftest.py` and have the controller talk to it. Each test should then assert on **both sides**: what the controller reported back to the Hub (via `deps.output`) *and* what actually landed on the device (the virtual device observed the command / changed state) — don't just call the method and assume it worked. Persistence is the Hub's job (see `conftest.py`): the fixtures seed the repository the way the Hub would, so assert on the controller's protocol behaviour and reports — not that the integration created a device row. (The template's own CI excludes this file so the template repo stays green — see `.github/workflows/test.yml`. Your integration repo runs it in full, and it's red until you implement the controller.) """ from majordom_integration_sdk.controller import AbstractController from majordom_integration_sdk.schemas.command import DeviceCommand from majordom_integration_sdk.schemas.device import Device, Discovery from majordom_integration_sdk.schemas.parameter import ParameterState from integration_template import ExampleController async def test_pairs_a_discovered_device( controller: ExampleController, deps: AbstractController.Dependencies, discovery: Discovery, provisional_device: Device, # creates a device with discovery.id and saves it to the db ) -> None: # The Hub has already stored `provisional_device` (id == discovery.id) before this call. await controller.pair_device(discovery, credentials=None) # TODO: verify pairing actually completed — the controller established the session and # reported back (it does not persist the device itself): # - Hub side: `discovery.id` is in `deps.output.connected_devices`. # - Device side: the virtual device accepted the credentials / opened a session. # - Parameters: if you map parameters during pairing, assert their initial states were # written via the repository (`repo.save_parameter_state`). async def test_fetches_state( controller: ExampleController, deps: AbstractController.Dependencies, device: Device, ) -> None: await controller.fetch(device) # TODO: verify the refresh reached the device and propagated back: # - Set a known state on the virtual device first, then assert `deps.output.events` # (or the repository's parameter states) now reflect exactly that state — not stale # or default values. async def test_sends_a_command(controller: ExampleController, device: Device, parameter: ParameterState) -> None: command = DeviceCommand(device_id=device.id, parameter_id=parameter.id, value=False) await controller.send_command(command, device, parameter) # TODO: verify the command actually applied on the device, not just that the call # returned: assert the virtual device received this exact write and its state changed # to `value` (read it back). A no-op that silently succeeds must fail this test. async def test_identifies(controller: ExampleController, device: Device) -> None: await controller.identify(device) # TODO: assert the virtual device recorded an identify request (e.g. its identify counter # incremented / the identify command was received). async def test_unpairs(controller: ExampleController, device: Device) -> None: await controller.unpair(device) # TODO: verify removal took effect: # - Device side: the virtual device saw the session torn down / was de-registered. # - Controller: the device is gone — no more connections or commands should be accepted. ================================================================================ # FILE: tests/test_smoke.py ================================================================================ """Basic tests that pass out of the box — they don't depend on protocol logic. Kept separate from ``test_controller.py`` so the template repo's own CI (which excludes the prefilled, expected-to-fail controller suite) still has tests to run and stays green. """ from integration_template import ExampleController async def test_starts_and_stops(controller: ExampleController) -> None: await controller.start() await controller.stop() async def test_name_and_slug(controller: ExampleController) -> None: assert controller.name == "Example" assert controller.name_slug == "example" # integration-matter — full source # repo: https://github.com/MajorDom-Systems/integration-matter branch: master commit: 15613a8ebaa8e53ad43b963878eca14ee5de31f6 # generated: 2026-07-24 — AUTO-GENERATED, do not edit by hand ## File tree .gitignore LICENSE README.md docker/Dockerfile.test docker-compose.matter-tests.mac.yml docker-compose.matter-tests.yml majordom_matter/__init__.py majordom_matter/config.py majordom_matter/controller.py majordom_matter/exceptions.py majordom_matter/mapper.py majordom_matter/matter_spec.py majordom_matter/matter_spec_ha.py majordom_matter/model.py majordom_matter/readme.md majordom_matter/testing/__init__.py majordom_matter/testing/fixtures/on_off_light_node.json pyproject.toml scripts/check_matter_ha_drift.py scripts/fetch_mvd.sh scripts/fetch_paa_certs.py scripts/harvest_matter_ha.py scripts/param_ux_audit.py tests/conftest.py tests/test_controller.py tests/test_controller_stub.py tests/test_param_ux.py tests/test_smoke.py ================================================================================ # FILE: .gitignore ================================================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] *$py.class # C extensions *.so # Distribution / packaging build/ dist/ *.egg-info/ *.egg # Unit test / coverage reports .coverage .coverage.* coverage.xml htmlcov/ .pytest_cache/ .hypothesis/ # Environments .env .envrc .venv env/ venv/ # Poetry # poetry.lock # uncomment for applications, keep commented for libraries # Type checkers .mypy_cache/ .dmypy.json .pyre/ .pytype/ # Tools .ruff_cache/ .ropeproject # mkdocs (only relevant if you kept mkdocs.yml + docs/) /site .cache/ # IDEs .idea/ .vscode/ # OS .DS_Store Thumbs.db # Matter Virtual Device (MVD) `chef` binaries + PAA certificates — NOT committed (they were # 77MB of opaque blobs in the Hub). CI fetches them as a versioned artifact and the test # jobs download that; see .github/workflows/mvd-artifact.yml (plan §3.5). tests/mvd/ tests/mvd-artifact/ # matter-server storage + PAA certs (fetched by CI, §3.5) matter_data/ paa/ # fetch_paa_certs.py / mvd-artifact.yml default cert output dir ================================================================================ # FILE: LICENSE ================================================================================ # PolyForm Noncommercial License 1.0.0 ## Acceptance In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses. ## Copyright License The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license). ## Distribution License The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license). ## Notices You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example: > Required Notice: Copyright Yoyodyne, Inc. (http://example.com) ## Changes and New Works License The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose. ## Patent License The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software. ## Noncommercial Purposes Any noncommercial purpose is a permitted purpose. ## Personal Uses Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose. ## Noncommercial Organizations Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding. ## Fair Use You may have "fair use" rights for the software under the law. These terms do not limit them. ## No Other Rights These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses. ## Patent Defense If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company. ## Violations The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately. ## No Liability ***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.*** ## Definitions The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms. **You** refers to the individual or entity agreeing to these terms. **Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect. **Your licenses** are all the licenses granted to you for the software under these terms. **Use** means anything you do with the software requiring one of your licenses. ================================================================================ # FILE: README.md ================================================================================ # integration-matter A [MajorDom](https://majordom.io) integration — bridges **Matter** (CSA Connectivity Standard) devices into the MajorDom language. Built for the **MajorDom Hub**, but it doesn't need it: this is a standalone, standardized library for Matter that you can use on its own (see **Run it standalone** below). Built on the [MajorDom Integration SDK](https://github.com/MajorDom-Systems/integration-sdk). The entry point is `MatterController` (`majordom_matter/controller.py`), which the Hub — or the SDK's dev runner — instantiates and drives through its lifecycle: discovery → pairing → commands → teardown. - **Other protocols:** browse the [MajorDom integrations](https://github.com/orgs/MajorDom-Systems/repositories?q=integration-). - **Create your own:** start from the [integration template](https://github.com/MajorDom-Systems/integration-template). ## Documentation Full integration-author docs — the controller lifecycle, data models, storing data, discovery, and a worked example — live at **[docs.majordom.io](https://docs.majordom.io/device-integration)**. ## Development ```sh poetry install && poetry run poe install ``` | Task | Description | |------|-------------| | `poe check` | Full quality pipeline (ruff, ty, pytest, poetry build/check) | | `poe check --ci` | Same, plus `git diff --exit-code` | Work lands on `develop`; `master` is protected and released via **Actions → Release**. The default suite drives the controller against a canned node with the SDK's test doubles — no matter-server, no docker (`tests/test_controller_stub.py`). The exhaustive real-device coverage commissions Google's Matter Virtual Devices in a dockerized matter-server (`tests/test_controller.py`, see `docker-compose.matter-tests.yml`); the MVD binaries + PAA certs are fetched, not committed. ## Run it standalone (without the Hub) `majordom-matter` is a standalone library — import it into your own app, or run **just this integration** interactively (discover, pair, control, and inspect devices from a prompt) with no Hub. It needs a running [python-matter-server](https://github.com/home-assistant-libs/python-matter-server) reachable over its WebSocket (`MATTER_SERVER_URL`, default `ws://localhost:5580/ws`); Thread devices also need an OpenThread Border Router. See **[Standalone mode](https://docs.majordom.io/device-integration/standalone)** for the interactive CLI, watch mode, and the programmatic API. ## About this integration - **Protocol / platform:** Matter (Connectivity Standards Alliance) via `python-matter-server` + `chip`. - **Transport(s):** IP over Thread / Wi-Fi / Ethernet; BLE for commissioning. - **Supported devices:** any Matter-certified device — lights, plugs, switches, sensors, locks, thermostats, covers, fans, appliances (verified against 30 Matter Virtual Device types). - **Credentials needed to pair:** `code` (manual pairing code) or `qr`. ### Required harness - **Hardware adapters:** an 802.15.4 radio (e.g. a SkyConnect / Thread dongle) for Thread devices — driven by matter-server / the OTBR, not this package directly. - **Third-party software services:** a **matter-server** instance reachable over WebSocket (`MATTER_SERVER_URL`), and an **OpenThread Border Router (OTBR)** for Thread devices. - **OS / permissions:** BLE access for commissioning; mDNS on the LAN for on-network discovery. ### Protocol stack (OSI) | OSI layer | Protocol | Implemented by | |-----------|----------|----------------| | Application (7) | Matter clusters / data model | **this integration** (via `chip` lib) | | Session (5) | CASE / PASE secure session | library (matter-server) | | Transport (4) | UDP | OS | | Network (3) | IPv6 · 6LoWPAN | OS · OTBR (harness) | | Data link / Physical (1–2) | Thread · IEEE 802.15.4 (or Wi-Fi / Ethernet) | radio adapter (harness) | ### Progress - [x] Discovery services registered (mDNS on-network via matter-server; BLE for commissionable devices); cancel closures called in `stop` - [x] Discovery listeners fire and call `controller_did_receive_discovery` - [x] Re-discovery of already-paired devices on reconnect (`controller_did_connect_device`) - [x] Device pairing (BLE→Thread and on-network commissioning) - [x] Device schema mapped: device info, parameter list, per-parameter metadata → MajorDom's domain model - [x] Hub → Device control (`send_command`) - [x] Device → Hub event subscription (`controller_did_receive_events`) - [x] `identify` - [x] `unpair` - [x] `fetch` - [x] Availability tracking while running (`controller_did_lose_device` / `last_error`) - [x] Graceful shutdown in `stop` - [x] Tests pass against virtual/simulated devices (stub + dockerized MVD suite) ### Parameter metadata sources & priority Every parameter's UX metadata is resolved from several sources. See also the [parameter-ux recipe](https://docs.majordom.io/device-integration/parameter-ux). **Visibility / role / unit** — resolved by `classify_attribute()` in `matter_spec.py` (first match wins): | # | Source | What it is | |---|--------|-----------| | — | system cluster / sensitive (`Aliro*` crypto) | forced **system** (safety, top priority) | | 1 | `OUR_ATTRIBUTE_UX` (`USER_READINGS`, `EVERYDAY_CONTROL_ATTRIBUTES`) | our hand curation | | 2 | `MATTER_HA_ATTRIBUTE_UX` | judgment **harvested** from Home Assistant's Matter discovery (`scripts/harvest_matter_ha.py`, AST-parsed, vendored — no `homeassistant` dep) | | 3 | **fallback policy** | writable → setting, else system; **logs a warning** on uncurated attrs. Flip `_FALLBACK_HIDE_UNCURATED` once coverage is validated. | Matter has no runtime quirk layer, so (unlike zigbee) there is no v2-quirk tier. The Matter Data Model (`chip`) already supplies names/types/bounds; the harvest adds only the `entity_category` (user/config/diagnostic) judgment the spec doesn't dictate. **Bounds** come from the device's own limit attributes (runtime) > spec tables > wire-type range — see `resolve_runtime_bounds()` / `METADATA_SOURCES`. **Drift.** `scripts/check_matter_ha_drift.py` re-runs the AST harvest against home-assistant/core and diffs vs the vendored artifact via the SDK's `diff_specs`, tiering ADD / REMOVE / **RECLASSIFY**. ### Notes The MVD `chef` binaries are x86-64 Linux only, so the real-device suite runs in an amd64 container (Rosetta on Apple Silicon). A monthly canary fetches the *latest* upstream MVD release and fails if it ships something unsupported — the signal to add support. The same drift machinery (the SDK's `diff_specs`) now also watches the harvested HA-Matter judgment. ## License See [LICENSE](LICENSE). For commercial licensing or partnership inquiries regarding MajorDom, contact us via [parker-industries.org/partnership](https://parker-industries.org/partnership). ================================================================================ # FILE: docker/Dockerfile.test ================================================================================ # Runs the Matter integration's virtual-device suite against real MVD `chef` binaries. # Build context is the repo root; the SDK is a published PyPI dependency # (majordom-integration-sdk), resolved by `poetry install` — nothing is copied from outside the repo. FROM python:3.12-slim WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential gcc python3-dev git \ avahi-daemon avahi-utils libavahi-client3 libavahi-common3 \ dbus libdbus-1-3 libdbus-1-dev libglib2.0-0 libglib2.0-dev \ && rm -rf /var/lib/apt/lists/* RUN sed -i 's/#enable-dbus=yes/enable-dbus=no/g; s/use-ipv6=no/use-ipv6=yes/g; s/#enable-chroot=yes/enable-chroot=no/g' \ /etc/avahi/avahi-daemon.conf RUN pip install poetry && poetry config virtualenvs.create false COPY pyproject.toml poetry.lock /app/ RUN poetry install --no-root COPY . /app/ RUN pip install -e . --no-deps CMD avahi-daemon --daemonize && sleep 2 && \ chmod +x tests/mvd/* && \ poetry run pytest tests/ -v ================================================================================ # FILE: docker-compose.matter-tests.mac.yml ================================================================================ # macOS (Apple-silicon) override: the MVD binaries are Linux x86-64, so pin the test # container to linux/amd64 and let Docker Desktop's Rosetta run them. # One-time: Docker Desktop -> Settings -> General -> "Use Rosetta for x86/amd64 emulation". # docker compose -f docker-compose.matter-tests.yml -f docker-compose.matter-tests.mac.yml \ # up --build --exit-code-from matter-integration-tests # matter-server runs no x86-64 binary (just mDNS/network), so it stays native arm64. services: matter-integration-tests: platform: linux/amd64 environment: - MATTER_DISCOVERY_WARMUP_S=180 ================================================================================ # FILE: docker-compose.matter-tests.yml ================================================================================ # Matter integration test stack: python-matter-server + a container that runs the # integration's virtual-device suite against real MVD `chef` binaries. # # Run: docker compose -f docker-compose.matter-tests.yml up --build \ # --exit-code-from matter-integration-tests # On Apple Silicon add the mac override (Rosetta): -f docker-compose.matter-tests.mac.yml services: matter-server: image: ghcr.io/matter-js/python-matter-server:stable restart: unless-stopped privileged: true sysctls: - net.ipv6.conf.all.disable_ipv6=0 - net.ipv6.conf.all.forwarding=1 volumes: - ./matter_data:/data command: --storage-path /data --paa-root-cert-dir /data/credentials --primary-interface eth0 networks: [matter-net] matter-integration-tests: build: context: . # repo root; the SDK is a published PyPI dependency dockerfile: docker/Dockerfile.test privileged: true depends_on: [matter-server] environment: - MATTER_SERVER_URL=ws://matter-server:5580/ws - MATTER_DISCOVERY_TIMEOUT_S=12 - MATTER_INTEGRATION_TESTS=1 sysctls: - net.ipv6.conf.all.disable_ipv6=0 networks: [matter-net] networks: matter-net: driver: bridge enable_ipv6: true ipam: config: - subnet: 172.16.238.0/24 - subnet: fd00:db8:1::/64 ================================================================================ # FILE: majordom_matter/__init__.py ================================================================================ """Matter integration for MajorDom. Bridges Matter devices into the MajorDom language via python-matter-server. `MatterController` is the entry point the Hub (or the SDK's standalone dev runner) instantiates and drives. """ from majordom_matter.controller import MatterController __all__ = ["MatterController"] ================================================================================ # FILE: majordom_matter/config.py ================================================================================ import os # The MajorDom Matter integration talks to a python-matter-server instance over WebSocket. # Point it at yours via MATTER_SERVER_URL; the default matches a `matter-server` service on # the same Docker network. matter_server_url = os.environ.get("MATTER_SERVER_URL", "ws://matter-server:5580/ws") ================================================================================ # FILE: majordom_matter/controller.py ================================================================================ import asyncio import contextlib import inspect import logging from typing import override from uuid import UUID from aiohttp import ClientSession from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand from chip.clusters.Objects import Identify from majordom_integration_sdk.controller import AbstractController from majordom_integration_sdk.discovery.ble_discovery import BLEDiscoveryInfo, BLEDiscoveryService from majordom_integration_sdk.schemas.base import NonEmptyStr from majordom_integration_sdk.schemas.command import DeviceCommand from majordom_integration_sdk.schemas.device import CredentialsType, Discovery, ProvidedCredentials from majordom_integration_sdk.schemas.event import DeviceParameterChange from majordom_integration_sdk.schemas.parameter import ParameterRole from matter_server.client import MatterClient from matter_server.client.models.node import MatterNode from matter_server.common.errors import UnknownError from matter_server.common.models import CommissionableNodeData, EventType from majordom_matter.config import matter_server_url from .exceptions import ( MatterConnectionError, MatterNotFoundParameter, MatterUnexpectedError, MatterUnsupportedParameter, ) from .mapper import MatterMapper from .matter_spec import MAIN_PARAMETER_BY_CLUSTER, DefaultParams from .model import ( MatterDevice, MatterDeviceIntegrationData, MatterDeviceState, MatterParameter, MatterParameterState, MatterParameterTypeEnum, ) class MatterController(AbstractController): """Bridges the Hub to Matter devices through a Matter controller server (matter-server). matter-server owns the Matter fabric, the Thread/BLE radios, and commissioning; this controller adapts it to the Hub's AbstractController contract, talking to it over its WebSocket API. On-network devices are discovered via matter-server; BLE-only commissionable devices are discovered via the Hub's shared BLE service. See readme.md. """ _matter_client: MatterClient _matter_client_session: ClientSession _majordom_descoveries: dict[UUID, Discovery] _mapper: MatterMapper # Matter commissionable BLE service (spec 5.4.2.5.6) — devices in commissioning mode advertise # this service + its service-data payload. discover_commissionable_nodes only surfaces IP/mDNS # devices, so BLE-only devices (a factory-fresh bulb not yet on any network) are discovered here # via the shared BLE scanner instead. _MATTER_COMMISSIONABLE_SERVICE = UUID("0000fff6-0000-1000-8000-00805f9b34fb") def __init__(self, dependencies: AbstractController.Dependencies): super().__init__(dependencies) # Instance state (not class-level): the mapper is wired with the framework's UUID # generators, and the discoveries dict must not be shared across instances. self._mapper = MatterMapper(self.device_uuid, self.parameter_uuid) self._majordom_descoveries = dict() # ------------------------------------------------------------------------- # AbstractController interface # ------------------------------------------------------------------------- @property def discoveries(self) -> dict[UUID, Discovery]: return self._majordom_descoveries @property @override def device_type(self) -> type[MatterDevice]: return MatterDevice @property @override def parameter_type(self) -> type[MatterParameter]: return MatterParameter # ------------------------------------------------------------------------- # Lifecycle # ------------------------------------------------------------------------- async def start(self): self._background_tasks: list[asyncio.Task] = [] self._node_availability: dict[UUID, bool] = {} self._matter_client_session = ClientSession() self._matter_client = MatterClient(matter_server_url, self._matter_client_session) await self._matter_client.connect() init_ready = asyncio.Event() self._background_tasks.append(asyncio.create_task(self._matter_client.start_listening(init_ready=init_ready))) await init_ready.wait() self._background_tasks.append(asyncio.create_task(self._matter_discovery_loop())) # BLE discovery for commissionable devices not yet on any IP network (the mDNS-based # discovery loop above can't see them). No-op in virtual mode where the BLE service is off. self._ble_addresses: dict[UUID, set[str]] = {} # discovery_id -> live BLE addresses self._ble_cancel = self.dependencies.ble_discovery_service.register(self, {self._MATTER_COMMISSIONABLE_SERVICE}) device_nodes: list[int] = [] async with self.dependencies.make_device_repository() as device_repository: for device in await device_repository.get_all(as_=MatterDevice): if node := self._matter_client.get_node(device.integration_data.node_id): self._subscription(device.id, node) device_nodes.append(device.integration_data.node_id) else: device.available = False device.last_error = f"Device {device.name} is no longer connected to the Matter network" await device_repository.save(device, device.id) for node in self._matter_client.get_nodes(): if node.node_id in device_nodes: continue discovery_id = self._mapper.device_uuid(node.node_id, node.device_info.productName) discovery = Discovery( id=discovery_id, integration=NonEmptyStr(self.name), expected_credentials_options=[CredentialsType.none], expiration=None, transport=NonEmptyStr("IP"), device_name=NonEmptyStr(node.device_info.productName or "Unknown"), device_manufacturer=node.device_info.vendorName or str(node.device_info.vendorID), device_category=None, device_icon=None, ) self._majordom_descoveries[discovery_id] = discovery await self.dependencies.output.controller_did_receive_discovery(self, discovery) async def stop(self): self._majordom_descoveries.clear() if cancel := getattr(self, "_ble_cancel", None): cancel() self._ble_cancel = None try: running_loop = asyncio.get_running_loop() except RuntimeError: running_loop = None # Cancel the discovery loop and listener started in start() — otherwise they keep # running against a matter client/event loop that stop() is about to tear down, # which surfaces as "Future attached to a different loop" once the next test/run # starts a fresh event loop. cancel() is safe to call across loops. # # Whether we then AWAIT them — and tear the matter client down — depends on the # loop. If stop() runs on a different loop than start() did (e.g. invoked via a sync # TestClient, whose portal runs on its own loop), awaiting any of these loop-bound # futures (the tasks, or the ws client's disconnect/close, which awaits the same # loop-A websocket) itself raises "attached to a different loop". There, cancel() # above is the meaningful cleanup and we skip the awaits; the normal same-loop # lifecycle does the full teardown. background_tasks = getattr(self, "_background_tasks", []) for task in background_tasks: task.cancel() on_owning_loop = running_loop is not None and ( not background_tasks or background_tasks[0].get_loop() is running_loop ) self._background_tasks = [] if not on_owning_loop: return for task in background_tasks: with contextlib.suppress(asyncio.CancelledError): await task if not self._matter_client_session or not self._matter_client: return await self._matter_client.disconnect() await self._matter_client_session.close() # ------------------------------------------------------------------------- # Hub -> device operations # ------------------------------------------------------------------------- async def pair_device(self, discovery: Discovery, credentials: ProvidedCredentials | None): self._require_matter_client() if not credentials or credentials.type not in discovery.expected_credentials_options: raise MatterUnexpectedError( f"Credentials type {credentials.type if credentials else None!r} is not one of the " f"types this discovery advertised: {discovery.expected_credentials_options}" ) # Every commissioning path below needs the pairing code/QR payload; a missing value is an # unexpected caller error, not something to stringify into "None" or crash on int(None). if credentials.value is None: raise MatterUnexpectedError("Matter commissioning requires a pairing code, but none was provided") # Compare by value (==), not identity (is): `credentials.type` can arrive through a # schema layer that resolved CredentialsType via a different import path, yielding a # distinct enum class where `is` would never match. `==` on a str-enum compares the # underlying value and is robust to that (and semantically what we want anyway). if credentials.type == CredentialsType.qr: commission_node = await self._matter_client.commission_with_code(str(credentials.value)) elif credentials.type == CredentialsType.code: if discovery.transport == "BLE": # commission_on_network only works for devices already reachable over IP # (matter-server's own docstring: "for advanced usecases only, use # commission_with_code for regular commissioning") — a BLE-discovered # device has no IP yet, so it must go through commission_with_code instead, # which chip-tool routes over BLE automatically based on the code. commission_node = await self._matter_client.commission_with_code(str(credentials.value)) else: commission_node = await self._matter_client.commission_on_network(int(credentials.value)) else: raise MatterUnexpectedError("This credentials type is not supported") self._majordom_descoveries.pop(discovery.id) node = self._matter_client.get_node(commission_node.node_id) device_id = self._mapper.device_uuid(node.node_id, node.device_info.productName) async with self.dependencies.make_device_repository() as device_repository: device = await device_repository.state(discovery.id, MatterDeviceState) assert device device.id = device_id # Manufacturer-provided, read-only description (BasicInformation product label). device.description = getattr(node.device_info, "productLabel", None) or None # Persist the commissioned node id — the Hub seeds a provisional device whose # integration_data already exists (so it's never falsy), and later reconnects look # the node up by this id, so set it unconditionally rather than only when absent. if device.integration_data: device.integration_data.node_id = node.node_id else: device.integration_data = MatterDeviceIntegrationData(node_id=node.node_id) for endpoint_id, endpoint in node.endpoints.items(): for cluster_id, cluster in endpoint.clusters.items(): if hasattr(cluster, "Commands"): for parameter in self._mapper.parse_commands(device_id, endpoint_id, cluster_id, cluster, node): device.parameters.append(MatterParameterState(**parameter.__dict__, value=None)) if hasattr(cluster, "Attributes"): for parameter in self._mapper.parse_attributes( device_id, endpoint_id, cluster_id, cluster, endpoint, node ): attribute_id = parameter.integration_data.attribute_id if attribute_id is None: raise MatterUnexpectedError( f"Attribute parameter {parameter.name!r} has no attribute_id " f"(endpoint {endpoint_id}, cluster {cluster_id})" ) value = node.get_attribute_value(endpoint_id, cluster_id, attribute_id) value = self._mapper.apply_attribute_scale(cluster_id, attribute_id, value) device.parameters.append( MatterParameterState( **parameter.__dict__, value=self._mapper.normalize_value(value) ) ) main_parameter_id, default_value = self._get_main_parameter(device.id, node) device.main_parameter = main_parameter_id if main_parameter_id and default_value is not None: main_parameter = next((p for p in device.parameters if p.id == main_parameter_id), None) if main_parameter is None: device.main_parameter = None elif main_parameter.integration_data.type is MatterParameterTypeEnum.attribute: main_parameter.default_value = self._mapper.normalize_value(default_value) elif isinstance(default_value, dict): # A command main parameter is tapped with a fixed argument set (a dict). main_parameter.integration_data.default_arguments = default_value else: raise MatterUnexpectedError( f"Command main parameter {main_parameter_id} expected dict default arguments, " f"got {type(default_value).__name__}: {default_value!r}" ) await device_repository.save(device, discovery.id) await self.dependencies.output.controller_did_connect_device(self, device_id) self._subscription(device_id, node) return device_id async def unpair(self, device: MatterDevice): await self._require_matter_client().remove_node(device.node_id) async def identify(self, device: MatterDevice): command = Identify.Commands.Identify() node = self._require_node(device) for endpoint_id in node.endpoints: if node.has_cluster(3, endpoint_id): await self._matter_client.send_device_command(device.node_id, endpoint_id, command) async def fetch(self, device: MatterDevice): node = self._require_node(device) events = [] for endpoint_id, endpoint in node.endpoints.items(): for cluster_id, cluster in endpoint.clusters.items(): if not hasattr(cluster, "Attributes"): continue for _, attribute in inspect.getmembers(cluster.Attributes, inspect.isclass): if not issubclass(attribute, ClusterAttributeDescriptor): continue attribute_id = getattr(attribute, "attribute_id", -1) value = node.get_attribute_value(endpoint_id, cluster_id, attribute_id) value = self._mapper.apply_attribute_scale(cluster_id, attribute_id, value) parameter_id = self._mapper.attribute_parameter_uuid( device.id, endpoint_id, cluster_id, attribute_id ) events.append( DeviceParameterChange( device_id=device.id, parameter_id=parameter_id, value=value if isinstance(value, str | int | float | bool) else None, ) ) await self.dependencies.output.controller_did_receive_events(self, events) async def send_command(self, command: DeviceCommand, device: MatterDevice, parameter: MatterParameter): try: node = self._require_node(device) endpoint_id = parameter.integration_data.endpoint_id endpoint = node.endpoints.get(endpoint_id) if not endpoint: raise MatterUnexpectedError(f"Endpoint {endpoint_id} not found on node {node.node_id}") cluster_id = parameter.integration_data.cluster_id cluster = endpoint.clusters.get(cluster_id) if not cluster: raise MatterUnexpectedError(f"Cluster {cluster_id} not found on endpoint {endpoint_id}") if parameter.integration_data.type is MatterParameterTypeEnum.command: await self._execute_cluster_command(node, endpoint_id, cluster, parameter, command) elif parameter.integration_data.type is MatterParameterTypeEnum.attribute: await self._write_cluster_attribute(node, endpoint_id, cluster_id, parameter, command) except UnknownError as e: error = str(e) error_map = { "0x8b": (MatterNotFoundParameter, "not found on device"), "0x81": (MatterUnsupportedParameter, "not supported by device"), } match = next(((exc, msg) for code, (exc, msg) in error_map.items() if code in error), None) if match is None: # Unknown/unmapped Matter error (e.g. Busy 0x9c) — don't swallow it logging.error(f"Command '{parameter.name}' failed with unmapped error: {error}") raise exception_class, message = match async with self.dependencies.make_device_repository() as device_repository: device_state = await device_repository.state(device.id, MatterDeviceState) if device_state is not None: # state() returns None only if the device was unpaired/removed out from under us; # nothing to prune then — just surface the original command error below. device_state.parameters = [p for p in device_state.parameters if p.id != parameter.id] device.integration_data.black_list.append(parameter.id) await device_repository.save(device, device.id) else: logging.warning(f"Device {device.id} is gone; skipped blacklisting parameter {parameter.id}") logging.error(f"Command '{parameter.name}' is {message} and will be removed") raise exception_class(f"Command '{parameter.name}' failed: {message}") from None # ------------------------------------------------------------------------- # Device -> Hub: discovery (mDNS / on-network) # ------------------------------------------------------------------------- async def _matter_discovery_loop(self, interval: int = 5): # On-network (mDNS) discovery is polled from matter-server rather than routed through the # Hub's shared Zeroconf service on purpose: matter-server already parses the Matter `_matterc` # TXT records into clean, structured CommissionableNodeData (discriminator, vendor/product, # commissioning mode, pairing hints). Re-deriving that on the raw Zeroconf service would just # duplicate matter-server's parser. BLE-only devices, which mDNS can't see, ARE discovered via # the Hub's shared BLE service instead (see the BLE discovery section below) — so the "our # discovery + SDK for pairing" split is used exactly where it adds value. while True: try: nodes: list[CommissionableNodeData] = await self._matter_client.discover_commissionable_nodes() for node in nodes: await self._async_matter_did_discover(node) except Exception as e: logging.error(f"[{self.name}] discovery error: {e}") await asyncio.sleep(interval) async def _async_matter_did_discover(self, node: CommissionableNodeData): discovery_id = self._mapper.discovery_uuid( node.instance_name or f"{node.vendor_id}_{node.product_id}_{node.addresses[0] if node.addresses else 'unknown'}" ) discovery = Discovery( id=discovery_id, integration=NonEmptyStr(self.name), expected_credentials_options=self._mapper.define_credentials_options( node.commissioning_mode, node.pairing_hint, node.pairing_instruction, bool(node.addresses) ), expiration=None, transport=NonEmptyStr("IP" if node.addresses else "BLE"), device_name=NonEmptyStr(node.device_name or node.instance_name or "Unknown"), device_manufacturer=f"Vendor {node.vendor_id}" if node.vendor_id else None, device_category=str(node.device_type), device_icon=None, ) self._majordom_descoveries[discovery_id] = discovery await self.dependencies.output.controller_did_receive_discovery(self, discovery) # ------------------------------------------------------------------------- # Device -> Hub: BLE discovery (BLEDiscoveryListener) — commissionable devices not yet on IP # ------------------------------------------------------------------------- async def ble_did_discover_device(self, ble: BLEDiscoveryService, info: BLEDiscoveryInfo): await self._matter_did_discover_ble(info) async def ble_did_update_device(self, ble: BLEDiscoveryService, info: BLEDiscoveryInfo): await self._matter_did_discover_ble(info) async def ble_did_remove_device(self, ble: BLEDiscoveryService, info: BLEDiscoveryInfo): # Intentionally keep the discovery. Commissionable devices rotate their BLE address while the # window is open (~minutes), so the scanner's short not-seen eviction (~11s) fires constantly # even while the device is very much present — dropping the discovery here would race # commissioning. pair_device removes it on success; stop() clears the rest. return async def _matter_did_discover_ble(self, info: BLEDiscoveryInfo): parsed = self._parse_commissionable_ble(info) if not parsed: return discriminator, vendor_id, product_id = parsed discovery_id = self._ble_discovery_id(discriminator, vendor_id, product_id) self._ble_addresses.setdefault(discovery_id, set()).add(info.device.address) if discovery_id in self._majordom_descoveries: return # already advertised; the device keeps beaconing while its window is open discovery = Discovery( id=discovery_id, integration=NonEmptyStr(self.name), # A commissionable device accepts its manual pairing code (or QR). pair_device's BLE # branch feeds the code to commission_with_code, which does its own BLE scan by discriminator. expected_credentials_options=[CredentialsType.code.with_mask("DDDD-DDD-DDDD"), CredentialsType.qr], expiration=None, transport=NonEmptyStr("BLE"), device_name=NonEmptyStr(info.advertisement.local_name or f"Matter {vendor_id:04x}:{product_id:04x}"), device_manufacturer=f"Vendor {vendor_id}", device_category=None, device_icon=None, ) self._majordom_descoveries[discovery_id] = discovery await self.dependencies.output.controller_did_receive_discovery(self, discovery) @staticmethod def _parse_commissionable_ble(info: BLEDiscoveryInfo) -> tuple[int, int, int] | None: """Decode a Matter commissionable BLE advert's service-data payload → (discriminator, vid, pid). Payload (Matter spec 5.4.2.5.6): [0]=opcode(0x00 Commissionable), [1:3]=discriminator (u16 LE, low 12 bits), [3:5]=vendor id (u16 LE), [5:7]=product id (u16 LE), [7]=additional-data flag. """ data = info.advertisement.service_data.get("0000fff6-0000-1000-8000-00805f9b34fb") if not data or len(data) < 7 or data[0] != 0x00: return None discriminator = (data[1] | (data[2] << 8)) & 0x0FFF vendor_id = data[3] | (data[4] << 8) product_id = data[5] | (data[6] << 8) return discriminator, vendor_id, product_id def _ble_discovery_id(self, discriminator: int, vendor_id: int, product_id: int) -> UUID: # Stable across the device's (possibly changing) BLE address and across mDNS re-discovery # after it joins Thread — keyed on the commissioning identity, not the transport address. return self._mapper.discovery_uuid(f"ble_{vendor_id:04x}_{product_id:04x}_{discriminator:03x}") # ------------------------------------------------------------------------- # Device -> Hub: subscriptions & availability # ------------------------------------------------------------------------- def _subscription(self, device_id: UUID, node: MatterNode): """ Registers attribute-change callbacks for every attribute on the node. Uses _make_callback to capture (device_id, parameter_id) by value — without it all callbacks would share the last loop iteration's values. """ for endpoint_id, endpoint in node.endpoints.items(): for cluster_id, cluster in endpoint.clusters.items(): if not hasattr(cluster, "Attributes"): continue for _, attribute in inspect.getmembers(cluster.Attributes, inspect.isclass): if not issubclass(attribute, ClusterAttributeDescriptor): continue attribute_path = f"{endpoint_id}/{cluster_id}/{attribute.attribute_id}" parameter_id = self._mapper.attribute_parameter_uuid( device_id, endpoint_id, cluster_id, attribute.attribute_id ) self._matter_client.subscribe_events( self._make_callback(device_id, parameter_id, cluster_id, attribute.attribute_id), EventType.ATTRIBUTE_UPDATED, node.node_id, attribute_path, ) # node.available flips when the device drops off/rejoins the Matter fabric while # the Hub keeps running (not just at Hub startup) — NODE_UPDATED fires on that and # other node-info changes, so track the last known value and only act on a real # transition instead of re-signalling on every unrelated update. self._node_availability[device_id] = node.available self._matter_client.subscribe_events( self._make_availability_callback(device_id), EventType.NODE_UPDATED, node.node_id, ) def _make_callback(self, device_id: UUID, parameter_id: UUID, cluster_id: int, attribute_id: int): def callback(event_type, new_value): event = DeviceParameterChange( device_id=device_id, parameter_id=parameter_id, value=self._mapper.normalize_value( self._mapper.apply_attribute_scale(cluster_id, attribute_id, new_value) ), ) asyncio.create_task(self.dependencies.output.controller_did_receive_events(self, [event])) return callback def _make_availability_callback(self, device_id: UUID): def callback(event_type, updated_node: MatterNode): was_available = self._node_availability.get(device_id) is_available = updated_node.available if is_available == was_available: return self._node_availability[device_id] = is_available if is_available: asyncio.create_task(self.dependencies.output.controller_did_connect_device(self, device_id)) else: asyncio.create_task(self.dependencies.output.controller_did_lose_device(self, device_id)) return callback # ------------------------------------------------------------------------- # Private helpers # ------------------------------------------------------------------------- def _require_matter_client(self) -> MatterClient: if not self._matter_client: raise MatterConnectionError("Matter client is not started") return self._matter_client def _require_node(self, device: MatterDevice) -> MatterNode: node = self._require_matter_client().get_node(device.node_id) if not node: raise MatterUnexpectedError(f"Node for device {device.node_id} not found") return node async def _execute_cluster_command( self, node, endpoint_id: int, cluster, parameter: MatterParameter, command: DeviceCommand ): command_id = parameter.integration_data.command_id time_requested_timeout = None if command_id is None or command_id < 0: raise MatterUnexpectedError(f"Invalid command_id: {command_id}") if not hasattr(cluster, "Commands"): return # A value-less send (e.g. tapping the main parameter) falls back to the arguments this # command was set up with as a main parameter — see integration_data.default_arguments. arguments = command.value if command.value is not None else parameter.integration_data.default_arguments for _, cmd_class in inspect.getmembers(cluster.Commands, inspect.isclass): if not issubclass(cmd_class, ClusterCommand): continue if getattr(cmd_class, "command_id", -1) != command_id: continue # Only send client-side commands if not getattr(cmd_class, "is_client", True): continue if cluster.id == 0x00000101: # DoorLock time_requested_timeout = 1000 if isinstance(arguments, dict): data = self._mapper.parse_data_for_command(cmd_class, arguments) await self._matter_client.send_device_command( node.node_id, endpoint_id, cmd_class(**data), timed_request_timeout_ms=time_requested_timeout ) else: await self._matter_client.send_device_command( node.node_id, endpoint_id, cmd_class(), timed_request_timeout_ms=time_requested_timeout ) return async def _write_cluster_attribute( self, node, endpoint_id: int, cluster_id: int, parameter: MatterParameter, command: DeviceCommand ): if parameter.role != ParameterRole.control: raise MatterUnexpectedError(f"Parameter '{parameter.name}' is not writable") attribute_path = f"{endpoint_id}/{cluster_id}/{parameter.integration_data.attribute_id}" if attribute_path not in node.node_data.attributes: return endpoint = node.endpoints[endpoint_id] cluster = endpoint.clusters[cluster_id] attribute_cls = next( ( a for _, a in inspect.getmembers(cluster.Attributes, inspect.isclass) if issubclass(a, ClusterAttributeDescriptor) and getattr(a, "attribute_id", -1) == parameter.integration_data.attribute_id ), None, ) value = self._mapper.parse_data_for_attribute(attribute_cls, command.value) if attribute_cls else command.value await self._matter_client.write_attribute(node.node_id, attribute_path, value) def _get_main_parameter(self, device_id: UUID, node: MatterNode) -> tuple[UUID | None, DefaultParams]: for endpoint_id, endpoint in node.endpoints.items(): for cluster_id, spec in MAIN_PARAMETER_BY_CLUSTER.items(): if cluster_id not in endpoint.clusters: continue if cluster_id == 0x00000202: # FanControl supported_attribute_ids: list[int] = node.get_attribute_value(endpoint_id, cluster_id, 0xFFFB) or [] if supported_attribute_ids and spec.command_or_attribute_id not in supported_attribute_ids: continue return ( self._mapper.attribute_parameter_uuid( device_id, endpoint_id, cluster_id, spec.command_or_attribute_id ), spec.default_params, ) accepted_command_ids: list[int] = node.get_attribute_value(endpoint_id, cluster_id, 0xFFF9) or [] if accepted_command_ids and spec.command_or_attribute_id not in accepted_command_ids: continue return ( self._mapper.command_parameter_uuid( device_id, endpoint_id, cluster_id, spec.command_or_attribute_id ), spec.default_params, ) return None, None ================================================================================ # FILE: majordom_matter/exceptions.py ================================================================================ class MatterConnectionError(Exception): """Raised when Matter client is not started or not available.""" class MatterUnexpectedError(Exception): """Raised when an unexpected internal error occurs.""" class MatterUnsupportedParameter(Exception): """Raised when a parameter is not supported by the device.""" class MatterNotFoundParameter(Exception): """Raised when a parameter was not found on the device.""" ================================================================================ # FILE: majordom_matter/mapper.py ================================================================================ import base64 import enum import inspect import logging from collections.abc import Callable from dataclasses import fields, is_dataclass from typing import Any, get_args, get_origin, get_type_hints from uuid import UUID from chip.clusters.CHIPClusters import ChipClusters from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand from chip.clusters.Types import Nullable, NullValue from chip.tlv import TLVReader from majordom_integration_sdk.schemas.device import CredentialsType from majordom_integration_sdk.schemas.parameter import ( Parameter, ParameterDataType, ParameterRole, ParameterUnit, ParameterVisibility, ) from matter_server.client.models.node import MatterNode from .matter_spec import ( ATTRIBUTE_MIN_STEPS, ATTRIBUTE_SCALE, ATTRIBUTE_UNITS, EVERYDAY_COMMANDS, FIELD_TYPE_TO_DATA_TYPE, METADATA_SOURCES, MIN_MAX_VALUE, SYSTEM_ATTRIBUTES, SYSTEM_CLUSTERS, AttributeKey, classify_attribute, ) from .model import MatterParameter, MatterParameterIntegrationData, MatterParameterTypeEnum class MatterMapper: def __init__( self, device_uuid: Callable[[str], UUID], parameter_uuid: Callable[[UUID, str], UUID], ): # The controller's framework UUID generators (see AbstractController). Every Matter id — # a device from its node/product, a discovery from its commissioning identity, a parameter # from its endpoint/cluster/attribute path — is derived through these, so parameters are # namespaced under the device and stay identical across pairing, fetch, and live reports. self._device_uuid = device_uuid self._parameter_uuid = parameter_uuid # ------------------------------------------------------------------------- # Identity: Matter identifiers -> MajorDom UUIDs. # Single source of truth for the id string formats — keep every call site going through # these instead of formatting the f-strings inline. # ------------------------------------------------------------------------- def discovery_uuid(self, matter_id: str) -> UUID: """UUID for a discovery, keyed on its (pre-commissioning) Matter identity string.""" return self._device_uuid(matter_id) def device_uuid(self, node_id: int, product_name: str | None) -> UUID: return self._device_uuid(f"{node_id}_{product_name}") def attribute_parameter_uuid(self, device_id: UUID, endpoint_id: int, cluster_id: int, attribute_id: int) -> UUID: return self._parameter_uuid(device_id, f"attribute_{endpoint_id}/{cluster_id}/{attribute_id}") def command_parameter_uuid(self, device_id: UUID, endpoint_id: int, cluster_id: int, command_id: int) -> UUID: return self._parameter_uuid(device_id, f"command_{endpoint_id}/{cluster_id}/{command_id}") def command_field_uuid( self, device_id: UUID, endpoint_id: int, cluster_id: int, command_id: int, field_name: str ) -> UUID: return self._parameter_uuid(device_id, f"field_{endpoint_id}/{cluster_id}/{command_id}/{field_name}") def define_credentials_options( self, commissioning_mode: int | None = None, pairing_hint: int | None = None, pairing_instruction: str | None = None, is_on_network: bool = False, ) -> list[CredentialsType]: """Every credentials type this node's pairing hint bitmap says it supports — a device can advertise more than one simultaneously (e.g. QR and a manual code), so this returns all of them instead of picking just one.""" if not commissioning_mode or commissioning_mode == 0: return [CredentialsType.none] hint = pairing_hint or 0 instruction = (pairing_instruction or "").lower() options: list[CredentialsType] = [] # Bitmask checks based on the Matter spec pairing hint bitmap. if is_on_network: options.append(CredentialsType.code.with_mask("DDD-DD-DDD")) if hint & (0x0004 | 0x0020 | 0x0008): options.append(CredentialsType.qr) if ( hint & (0x0002 | 0x0010) or "code" in instruction or "pin" in instruction ) and CredentialsType.code not in options: options.append(CredentialsType.code.with_mask("DDD-DD-DDD")) # bit 0x0001 (Power Cycle Commissioning Mode) isn't representable as a # CredentialsType — there's no code/QR/secret majordom can collect for it — # so it doesn't contribute an option here. return options or [CredentialsType.none] def normalize_value(self, value: Any): """Convert a chip runtime value to a plain, JSON-serializable python value. Parameter values are pythonic now (not byte-encoded), so anything the Hub stores/sends must serialize cleanly — recurse into lists/dicts and drop chip's ``Nullable``/``NullValue`` sentinels (→ ``None``); chip enums are ``IntEnum`` and already serialize as their int. """ if value is NullValue or isinstance(value, Nullable): return None if isinstance(value, (list, tuple)): return [self.normalize_value(v) for v in value] if isinstance(value, dict): return {k: self.normalize_value(v) for k, v in value.items()} if is_dataclass(value) and not isinstance(value, type): return {f.name: self.normalize_value(getattr(value, f.name)) for f in fields(value)} if isinstance(value, bytes | bytearray): return base64.b64encode(bytes(value)).decode() return value def get_parameter_data_type_from_value(self, value: Any) -> ParameterDataType: """Infers ParameterDataType from a Python runtime value.""" # Matter SDK uses a special sentinel for "no value", not Python None try: if value is NullValue or isinstance(value, Nullable): value = None return ParameterDataType.none except ImportError: pass if value is None: return ParameterDataType.none # bool must be checked before int because bool is a subclass of int. if isinstance(value, bool): return ParameterDataType.bool if isinstance(value, enum.Enum): return ParameterDataType.enum if isinstance(value, int): return ParameterDataType.integer if isinstance(value, float): return ParameterDataType.decimal if isinstance(value, str): return ParameterDataType.string if isinstance(value, (bytes, bytearray, memoryview)): return ParameterDataType.data if is_dataclass(value) or isinstance(value, dict): return ParameterDataType.struct if isinstance(value, (list, tuple, set)): return ParameterDataType.struct raise ValueError( f"Cannot infer ParameterDataType for value of type {type(value)!r}: {value!r}. " f"Extend get_parameter_data_type_from_value to handle this type explicitly." ) def apply_attribute_scale(self, cluster_id: int, attribute_id: int, value: Any) -> Any: """Converts a raw Matter attribute value into ATTRIBUTE_UNITS' base unit, per ATTRIBUTE_SCALE. Unwraps the cumulative-energy struct's `energy` field before scaling, since that's the only scaled attribute whose SDK value isn't already a plain number.""" scale = ATTRIBUTE_SCALE.get(AttributeKey(cluster_id, attribute_id)) if scale is None: return value if is_dataclass(value) and hasattr(value, "energy"): value = value.energy if isinstance(value, (int, float)) and not isinstance(value, bool): return value * scale return value def resolve_runtime_bounds( self, key, cluster_id: int, endpoint, name: str, default_min, default_max ) -> tuple[Any, Any]: """Metadata priority 1: override a parameter's min/max with the device's own limit attributes' runtime VALUES (the *_min/*_max we hide as metadata). Falls back to the passed defaults (spec/type) when the device doesn't report a source attribute, warning so a quirk or a new device that omits an expected limit shows up in the logs.""" source = METADATA_SOURCES.get(key) if source is None: return default_min, default_max min_v, max_v = default_min, default_max for attr_id, is_min in ((source.min_attr, True), (source.max_attr, False)): if attr_id is None: continue raw = endpoint.get_attribute_value(cluster_id, attr_id) resolved = self.normalize_value(raw) if resolved is not None: if is_min: min_v = resolved else: max_v = resolved else: logging.warning( f"Metadata source cluster {cluster_id:#x} attr {attr_id:#x} " f"({'min' if is_min else 'max'} for {name!r}) not reported — quirk or unsupported; " f"using spec/type default" ) return min_v, max_v def get_min_max_value(self, attribute, value) -> tuple[int | None, int | None]: """ Encodes the attribute value to TLV to discover its wire type, then looks up the valid numeric range for that type from matter_spec. Returns (None, None) if the type is unknown or encoding fails. """ try: tlv_bytes = attribute.ToTLV(None, value) reader = TLVReader(tlv_bytes) reader.get() tlv_type = reader.decoding[0]["type"] return MIN_MAX_VALUE.get(tlv_type, (None, None)) except Exception: return None, None def parse_data_for_command(self, cmd_class: type, data: dict) -> dict: import enum if not is_dataclass(cmd_class): raise TypeError(f"Expected a dataclass cluster-command class, got {cmd_class!r}") hints = get_type_hints(cmd_class) result = {} for field in fields(cmd_class): if field.name not in data: continue raw = data[field.name] field_type = hints.get(field.name, field.type) # Unwrap Optional / Union if get_origin(field_type): args = [a for a in get_args(field_type) if a is not type(None)] field_type = args[0] if args else field_type if raw is None: result[field.name] = NullValue elif isinstance(field_type, type) and issubclass(field_type, enum.Enum): result[field.name] = field_type(int(raw)) elif field_type in (bytes, bytearray): if isinstance(raw, str): result[field.name] = raw.encode("utf-8") elif isinstance(raw, (list, tuple)): result[field.name] = bytes(raw) else: result[field.name] = bytes(raw) if not isinstance(raw, (bytes, bytearray)) else raw else: result[field.name] = raw return result def parse_data_for_attribute(self, attribute_cls: type, raw: Any) -> Any: if raw is None: return NullValue attr_type = getattr(attribute_cls, "attribute_type", None) field_type = getattr(attr_type, "Type", None) if attr_type else None # Unwrap Optional[X] / Union[X, None] — same as in parse_data_for_command if get_origin(field_type): args = [a for a in get_args(field_type) if a is not type(None)] field_type = args[0] if args else field_type if isinstance(field_type, type) and issubclass(field_type, enum.Enum): return field_type(int(raw)) if field_type in (bytes, bytearray): if isinstance(raw, str): return raw.encode("utf-8") if isinstance(raw, (list, tuple)): return bytes(raw) return bytes(raw) if not isinstance(raw, (bytes, bytearray)) else raw return raw # ------------------------------------------------------------------------- # Node parsing: matter SDK cluster data -> MatterParameter # ------------------------------------------------------------------------- def parse_commands( self, device_id: UUID, endpoint_id: int, cluster_id: int, cluster, node: MatterNode ) -> list[MatterParameter]: params = [] on_system_cluster = cluster_id in SYSTEM_CLUSTERS # Only process commands actually supported by this device accepted_command_ids: list[int] = node.get_attribute_value(endpoint_id, cluster_id, 0xFFF9) or [] for name, command in inspect.getmembers(cluster.Commands, inspect.isclass): if not issubclass(command, ClusterCommand): continue # Skip response commands (server→client) — only keep client→server commands if not getattr(command, "is_client", True): continue command_id = getattr(command, "command_id", -1) # Skip commands not supported by this specific device if accepted_command_ids and command_id not in accepted_command_ids: continue # Command visibility: system-cluster commands hidden; everyday one-tap actions -> # user; every other command (schedule/credential/log management) -> setting. if on_system_cluster: visibility = ParameterVisibility.system elif (cluster_id, command_id) in EVERYDAY_COMMANDS: visibility = ParameterVisibility.user else: visibility = ParameterVisibility.setting # Reflect on dataclass fields to build typed argument descriptors args = [] if is_dataclass(command): command_types = get_type_hints(command) for field in fields(command): if field.name.startswith("_"): continue field_type = command_types.get(field.name, field.type) data_type = ParameterDataType.none valid_values = None # Unwrap Optional[X] / Union[X, None] → take the first concrete type if get_origin(field_type): args_ = [a for a in get_args(field_type) if a is not type(None)] field_type = args_[0] if args_ else field_type if isinstance(field_type, type): if issubclass(field_type, enum.Enum): data_type = ParameterDataType.enum # Keys are numeric values sent to device, values are display labels valid_values = {m.value: m.name for m in field_type if "unknown" not in m.name.lower()} else: # Exact match first, then subclass fallback (e.g. custom int wrappers) matched = FIELD_TYPE_TO_DATA_TYPE.get(field_type) if matched is None: for candidate_type, mapped_type in FIELD_TYPE_TO_DATA_TYPE.items(): if issubclass(field_type, candidate_type): matched = mapped_type break if matched is not None: data_type = matched args.append( Parameter( id=self.command_field_uuid(device_id, endpoint_id, cluster_id, command_id, field.name), name=field.name, data_type=data_type, unit=ParameterUnit.plain, role=ParameterRole.control, valid_values=valid_values, visibility=ParameterVisibility.setting, integration_data=None, ) ) params.append( MatterParameter( id=self.command_parameter_uuid(device_id, endpoint_id, cluster_id, command_id), name=name, data_type=ParameterDataType.none, role=ParameterRole.control, visibility=visibility, fields=args or None, integration_data=MatterParameterIntegrationData( endpoint_id=endpoint_id, cluster_id=cluster_id, command_id=command_id, type=MatterParameterTypeEnum.command, ), ) ) return params def parse_attributes( self, device_id: UUID, endpoint_id: int, cluster_id: int, cluster, endpoint, node: MatterNode ) -> list[MatterParameter]: params = [] # Only process attributes actually present on this device supported_attribute_ids: list[int] = node.get_attribute_value(endpoint_id, cluster_id, 0xFFFB) or [] for name, attribute in inspect.getmembers(cluster.Attributes, inspect.isclass): if not issubclass(attribute, ClusterAttributeDescriptor): continue attribute_id = getattr(attribute, "attribute_id", -1) # Skip system-level attributes (featureMap, clusterRevision, etc.) if attribute_id in SYSTEM_ATTRIBUTES: continue # Skip attributes not present on this specific device if supported_attribute_ids and attribute_id not in supported_attribute_ids: continue raw_value = endpoint.get_attribute_value(cluster_id, attribute_id) # Visibility (see the parameter-ux recipe in the docs). System clusters and # security material are always hidden. Otherwise: writable attrs are configure-once # `setting`s unless curated as everyday controls; read-only attrs are hidden `system` # unless curated as live readings (USER_READINGS). This inverts the old # "every read-only -> user" flood — uncurated read-onlys (bounds, capabilities, counts) # stay hidden and double as metadata sources; the user can still surface any of them. key = AttributeKey(cluster_id, attribute_id) writable = bool( ChipClusters(None) .GetClusterInfoById(cluster_id) .get("attributes", {}) .get(attribute_id, {}) .get("writable") ) # Classification via the priority ladder (see the matter README): safety (system # cluster / sensitive crypto) > our hand overrides > harvested HA judgment > fallback # (writable -> setting, else system), which WARNS on uncurated attributes. spec, source = classify_attribute( cluster_id, attribute_id, name, writable=writable, in_system_cluster=cluster_id in SYSTEM_CLUSTERS, ) visibility = spec.visibility role = spec.role if spec.role is not None else (ParameterRole.control if writable else ParameterRole.sensor) if source.startswith("fallback"): logging.warning( "Uncurated attribute cluster %#x attr %#x (%s) -> %s (%s); add to OUR_ATTRIBUTE_UX " "or refresh the matter-HA harvest", cluster_id, attribute_id, name, visibility.value, source, ) valid_values = None attr_type = attribute.attribute_type if attr_type and hasattr(attr_type, "Type") and isinstance(attr_type.Type, type): t = attr_type.Type if issubclass(t, (enum.Enum, enum.Flag)): # Keys are numeric values sent to device, values are display labels valid_values = {m.value: m.name for m in t} min_value, max_value = self.get_min_max_value(attribute, raw_value) # priority 3: wire-type range min_value, max_value = self.resolve_runtime_bounds( # priority 1: device's own limit attrs key, cluster_id, endpoint, name, min_value, max_value ) scale = ATTRIBUTE_SCALE.get(AttributeKey(cluster_id, attribute_id)) if scale is not None: min_value = min_value * scale if min_value is not None else None max_value = max_value * scale if max_value is not None else None value = self.apply_attribute_scale(cluster_id, attribute_id, raw_value) try: data_type = self.get_parameter_data_type_from_value(self.normalize_value(value)) except ValueError: # Value type isn't one we know how to represent yet. Skip just this attribute # rather than aborting the whole pairing — the device stays usable, only this # one parameter is unavailable. logging.warning( f"Could not infer data type for {name} " f"(cluster {cluster_id:#x}, attribute {attribute_id:#x}); skipping parameter" ) continue params.append( MatterParameter( id=self.attribute_parameter_uuid(device_id, endpoint_id, cluster_id, attribute_id), name=name, data_type=data_type, visibility=visibility, min_value=min_value, max_value=max_value, valid_values=valid_values, min_step=ATTRIBUTE_MIN_STEPS.get(AttributeKey(cluster_id, attribute_id)), # Our spec table wins; the harvested HA unit fills gaps it leaves plain. unit=ATTRIBUTE_UNITS.get(key, spec.unit or ParameterUnit.plain), role=role, integration_data=MatterParameterIntegrationData( endpoint_id=endpoint_id, cluster_id=cluster_id, attribute_id=attribute_id, type=MatterParameterTypeEnum.attribute, ), ) ) return params ================================================================================ # FILE: majordom_matter/matter_spec.py ================================================================================ from typing import Any, NamedTuple from chip.tlv import ( INT8_MAX, INT8_MIN, INT16_MAX, INT16_MIN, INT32_MAX, INT32_MIN, INT64_MAX, INT64_MIN, UINT8_MAX, UINT16_MAX, UINT32_MAX, UINT64_MAX, ) from majordom_integration_sdk.schemas.parameter import ( ParameterDataType, ParameterRole, ParameterUnit, ParameterVisibility, ) from .matter_spec_ha import MATTER_HA_ATTRIBUTE_UX SYSTEM_CLUSTERS: set[int] = { 0x3, # Identify 0x4, # Groups 0x5, # Scenes 0x1D, # Descriptor 0x1E, # Binding 0x1F, # AccessControl 0x25, # Actions 0x28, # BasicInformation 0x29, # OtaSoftwareUpdateProvider 0x2A, # OtaSoftwareUpdateRequestor 0x2B, # LocalizationConfiguration 0x2C, # TimeFormatLocalization 0x2D, # UnitLocalization 0x2E, # PowerSourceConfiguration 0x9C, # PowerTopology 0x30, # GeneralCommissioning 0x31, # NetworkCommissioning 0x32, # DiagnosticLogs 0x33, # GeneralDiagnostics 0x34, # SoftwareDiagnostics 0x35, # ThreadNetworkDiagnostics 0x36, # WiFiNetworkDiagnostics 0x37, # EthernetNetworkDiagnostics 0x38, # TimeSynchronization 0x3C, # AdministratorCommissioning 0x3E, # OperationalCredentials 0x3F, # GroupKeyManagement 0x40, # FixedLabel 0x41, # UserLabel 0x62, # ScenesManagement } SYSTEM_ATTRIBUTES: set[int] = { 0x0000FFF8, # generatedCommandList 0x0000FFF9, # acceptedCommandList 0x0000FFFA, # eventList 0x0000FFFB, # attributeList 0x0000FFFC, # featureMap 0x0000FFFD, # clusterRevision } class ValueRange(NamedTuple): min: int max: int MIN_MAX_VALUE: dict[str, ValueRange] = { "Unsigned Integer 1-byte value": ValueRange(0, UINT8_MAX), "Unsigned Integer 2-byte value": ValueRange(0, UINT16_MAX), "Unsigned Integer 4-byte value": ValueRange(0, UINT32_MAX), "Unsigned Integer 8-byte value": ValueRange(0, UINT64_MAX), "Signed Integer 1-byte value": ValueRange(INT8_MIN, INT8_MAX), "Signed Integer 2-byte value": ValueRange(INT16_MIN, INT16_MAX), "Signed Integer 4-byte value": ValueRange(INT32_MIN, INT32_MAX), "Signed Integer 8-byte value": ValueRange(INT64_MIN, INT64_MAX), } class AttributeKey(NamedTuple): """Identifies a single attribute on a cluster — the shared dict key shape for ATTRIBUTE_UNITS / ATTRIBUTE_SCALE / ATTRIBUTE_MIN_STEPS below.""" cluster_id: int attribute_id: int ATTRIBUTE_UNITS: dict[AttributeKey, ParameterUnit] = { AttributeKey(0x8, 0x0): ParameterUnit.percentage, # LevelControl.CurrentLevel AttributeKey(0x102, 0x8): ParameterUnit.percentage, # WindowCovering.LiftPercentage AttributeKey(0x102, 0x9): ParameterUnit.percentage, # WindowCovering.TiltPercentage AttributeKey(0x201, 0x0): ParameterUnit.celsius, # Thermostat.LocalTemperature AttributeKey(0x201, 0x11): ParameterUnit.celsius, # Thermostat.OccupiedCoolingSetpoint AttributeKey(0x201, 0x12): ParameterUnit.celsius, # Thermostat.OccupiedHeatingSetpoint AttributeKey(0x202, 0x2): ParameterUnit.percentage, # FanControl.PercentSetting AttributeKey(0x202, 0x3): ParameterUnit.percentage, # FanControl.PercentCurrent AttributeKey( 0x300, 0x0 ): ParameterUnit.arcdegree, # ColorControl.CurrentHue (raw 0-254, degrees = value * 360 / 254) AttributeKey(0x300, 0x1): ParameterUnit.percentage, # ColorControl.CurrentSaturation AttributeKey(0x300, 0x7): ParameterUnit.mired, # ColorControl.ColorTemperatureMireds AttributeKey(0x404, 0x0): ParameterUnit.m3h, # FlowMeasurement.MeasuredValue (deci-m3/h, see scale) AttributeKey(0x400, 0x0): ParameterUnit.lux, # IlluminanceMeasurement.MeasuredValue AttributeKey(0x402, 0x0): ParameterUnit.celsius, # TemperatureMeasurement.MeasuredValue AttributeKey(0x405, 0x0): ParameterUnit.percentage, # RelativeHumidityMeasurement.MeasuredValue AttributeKey(0x40C, 0x0): ParameterUnit.ppm, # CarbonMonoxideMeasurement.MeasuredValue AttributeKey(0x40D, 0x0): ParameterUnit.ppm, # CarbonDioxideMeasurement.MeasuredValue # Pm25Measurement.MeasuredValue: unit is device-configured (mass or molar concentration, # via the cluster's MeasurementUnit attribute) — not necessarily ppm. Left unmapped # (defaults to plain) rather than asserting a possibly-wrong unit. AttributeKey(0x56, 0x0): ParameterUnit.celsius, # TemperatureControl.TemperatureSetpoint AttributeKey(0x90, 0x8): ParameterUnit.watt, # ElectricalPowerMeasurement.ActivePower (raw mW, see ATTRIBUTE_SCALE) AttributeKey(0x90, 0x4): ParameterUnit.volt, # ElectricalPowerMeasurement.Voltage (raw mV, see ATTRIBUTE_SCALE) AttributeKey( 0x90, 0x5 ): ParameterUnit.ampere, # ElectricalPowerMeasurement.ActiveCurrent (raw mA, see ATTRIBUTE_SCALE) AttributeKey( 0x91, 0x1, # ElectricalEnergyMeasurement.CumulativeEnergyImported (struct; .energy in mWh, see ATTRIBUTE_SCALE) ): ParameterUnit.kwh, AttributeKey( 0x403, 0x0 ): ParameterUnit.pascal, # PressureMeasurement.MeasuredValue (raw deci-kPa, see ATTRIBUTE_SCALE) AttributeKey(0x2F, 0xC): ParameterUnit.percentage, # PowerSource.BatPercentRemaining } # Multiplier applied to a raw attribute value to convert it into ATTRIBUTE_UNITS' base unit # (e.g. Matter reports power in mW; watt is the base unit, so scale = 0.001). ATTRIBUTE_SCALE: dict[AttributeKey, float] = { AttributeKey(0x90, 0x8): 0.001, # ElectricalPowerMeasurement.ActivePower: mW -> W AttributeKey(0x90, 0x4): 0.001, # ElectricalPowerMeasurement.Voltage: mV -> V AttributeKey(0x90, 0x5): 0.001, # ElectricalPowerMeasurement.ActiveCurrent: mA -> A AttributeKey(0x91, 0x1): 1e-6, # ElectricalEnergyMeasurement.CumulativeEnergyImported: mWh -> kWh AttributeKey(0x404, 0x0): 0.1, # FlowMeasurement.MeasuredValue: deci-m3/h -> m3/h AttributeKey(0x403, 0x0): 100, # PressureMeasurement.MeasuredValue: deci-kPa -> Pa } ATTRIBUTE_MIN_STEPS: dict[AttributeKey, int | float] = { AttributeKey(0x0008, 0x0000): 1, # CurrentLevel (uint8, 0-254) AttributeKey(0x0300, 0x0000): 1, # CurrentHue (verified: id 0x0000, uint8, step 1) AttributeKey(0x0300, 0x0001): 1, # CurrentSaturation (verified: id 0x0001, uint8, step 1) AttributeKey(0x0300, 0x0007): 1, # ColorTemperatureMireds (verified: id 0x0007, uint16, step 1) AttributeKey(0x0201, 0x0010): 0.01, # LocalTemperatureCalibration (SignedTemperature, -2.5°C to 2.5°C) AttributeKey(0x0201, 0x0011): 0.01, # OccupiedCoolingSetpoint (temperature type, 0.01°C resolution) AttributeKey(0x0201, 0x0012): 0.01, # OccupiedHeatingSetpoint (temperature type, 0.01°C resolution) AttributeKey(0x0201, 0x0013): 0.01, # UnoccupiedCoolingSetpoint (temperature type, 0.01°C resolution) AttributeKey(0x0201, 0x0014): 0.01, # UnoccupiedHeatingSetpoint (temperature type, 0.01°C resolution) AttributeKey(0x0402, 0x0000): 0.01, # MeasuredValue (temperature type, 0.01°C resolution) AttributeKey(0x0405, 0x0000): 0.01, # MeasuredValue (0.01% resolution) AttributeKey(0x0403, 0x0000): 10, # MeasuredValue (raw step 0.1 deci-kPa, scaled x100 to Pa -> 10 Pa) AttributeKey(0x0102, 0x0008): 1, # CurrentPositionLiftPercentage (plain percent 0-100, not Percent100ths) AttributeKey(0x0102, 0x0009): 1, # CurrentPositionTiltPercentage (plain percent 0-100, not Percent100ths) AttributeKey(0x0102, 0x000B): 0.01, # TargetPositionLiftPercent100ths (percent100ths, 0.01%) AttributeKey(0x0102, 0x000C): 0.01, # TargetPositionTiltPercent100ths (percent100ths, 0.01%) } class MetadataSource(NamedTuple): """Sibling attributes whose runtime VALUES provide a parameter's min/max — the device's own limit attributes (the ones we hide from the UI as metadata). Priority 1 in the resolver: runtime sibling value > spec table > wire-type default.""" min_attr: int | None = None max_attr: int | None = None METADATA_SOURCES: dict[AttributeKey, MetadataSource] = { AttributeKey(0x008, 0x00): MetadataSource(0x02, 0x03), # LevelControl.CurrentLevel <- Min/MaxLevel AttributeKey(0x402, 0x00): MetadataSource(0x01, 0x02), # TemperatureMeasurement <- Min/MaxMeasuredValue AttributeKey(0x405, 0x00): MetadataSource(0x01, 0x02), # RelativeHumidity <- Min/MaxMeasuredValue AttributeKey(0x400, 0x00): MetadataSource(0x01, 0x02), # Illuminance <- Min/MaxMeasuredValue AttributeKey(0x403, 0x00): MetadataSource(0x01, 0x02), # Pressure <- Min/MaxMeasuredValue AttributeKey(0x404, 0x00): MetadataSource(0x01, 0x02), # Flow <- Min/MaxMeasuredValue AttributeKey(0x201, 0x11): MetadataSource(0x05, 0x06), # OccupiedCoolingSetpoint <- AbsMin/MaxCoolSetpointLimit AttributeKey(0x201, 0x12): MetadataSource(0x03, 0x04), # OccupiedHeatingSetpoint <- AbsMin/MaxHeatSetpointLimit AttributeKey(0x300, 0x07): MetadataSource(0x400B, 0x400C), # ColorTemperatureMireds <- physical min/max mireds } FIELD_TYPE_TO_DATA_TYPE: dict[type, ParameterDataType] = { bool: ParameterDataType.bool, int: ParameterDataType.integer, float: ParameterDataType.decimal, str: ParameterDataType.string, } # --- Visibility curation (see docs/device-integration/parameter-ux recipe) ------------ # The mapper defaults a read-only attribute to `system` (hidden) and only promotes it to `user` # if it's an explicitly curated live reading (USER_READINGS). Writable attributes default to # `setting`, promoted to `user` only if they're an everyday control (EVERYDAY_CONTROL_ATTRIBUTES). # This inverts the old "every read-only -> user" flood; anything not curated stays hidden and can # be surfaced by the user, or added here. Bounds/capabilities/counts fall through to `system` and # double as metadata sources (see ATTRIBUTE_MIN_STEPS / min-max resolution). # Writable attributes that are everyday, main-surface controls (-> user) rather than # configure-once settings. EVERYDAY_CONTROL_ATTRIBUTES: set[AttributeKey] = { AttributeKey(0x202, 0x0), # FanControl.FanMode (off/low/med/high/auto) AttributeKey(0x202, 0x2), # FanControl.PercentSetting AttributeKey(0x202, 0x5), # FanControl.SpeedSetting AttributeKey(0x201, 0x1C), # Thermostat.SystemMode (off/heat/cool/auto) AttributeKey(0x201, 0x11), # Thermostat.OccupiedCoolingSetpoint (the everyday "set the temp") AttributeKey(0x201, 0x12), # Thermostat.OccupiedHeatingSetpoint AttributeKey(0x056, 0x00), # TemperatureControl.TemperatureSetpoint } # Read-only attributes that ARE the live, everyday reading for their cluster (-> user). Anything # read-only and NOT listed here stays `system` (bounds, capabilities, counts, diagnostics). USER_READINGS: set[AttributeKey] = { AttributeKey(0x006, 0x00), # OnOff.OnOff AttributeKey(0x008, 0x00), # LevelControl.CurrentLevel AttributeKey(0x300, 0x00), # ColorControl.CurrentHue (human color model) AttributeKey(0x300, 0x01), # ColorControl.CurrentSaturation AttributeKey(0x300, 0x07), # ColorControl.ColorTemperatureMireds # CurrentX/CurrentY (0x03/0x04) are the CIE machine encoding of the same colour -> system # (redundant with hue/sat for the user; still available to patch to user if wanted). AttributeKey(0x201, 0x00), # Thermostat.LocalTemperature AttributeKey(0x202, 0x03), # FanControl.PercentCurrent AttributeKey(0x202, 0x06), # FanControl.SpeedCurrent (0x04 is SpeedMax, a bound -> stays system) AttributeKey(0x402, 0x00), # TemperatureMeasurement.MeasuredValue AttributeKey(0x405, 0x00), # RelativeHumidityMeasurement.MeasuredValue AttributeKey(0x400, 0x00), # IlluminanceMeasurement.MeasuredValue AttributeKey(0x403, 0x00), # PressureMeasurement.MeasuredValue AttributeKey(0x404, 0x00), # FlowMeasurement.MeasuredValue AttributeKey(0x406, 0x00), # OccupancySensing.Occupancy AttributeKey(0x045, 0x00), # BooleanState.StateValue (contact/water leak) AttributeKey(0x40C, 0x00), # CarbonMonoxideConcentrationMeasurement.MeasuredValue AttributeKey(0x40D, 0x00), # CarbonDioxideConcentrationMeasurement.MeasuredValue AttributeKey(0x42A, 0x00), # Pm25ConcentrationMeasurement.MeasuredValue AttributeKey(0x101, 0x00), # DoorLock.LockState AttributeKey(0x101, 0x03), # DoorLock.DoorState AttributeKey(0x102, 0x08), # WindowCovering.CurrentPositionLiftPercentage AttributeKey(0x102, 0x09), # WindowCovering.CurrentPositionTiltPercentage AttributeKey(0x02F, 0x0C), # PowerSource.BatPercentRemaining AttributeKey(0x02F, 0x0E), # PowerSource.BatChargeLevel AttributeKey(0x060, 0x04), # OperationalState.OperationalState AttributeKey(0x061, 0x04), # RvcOperationalState.OperationalState AttributeKey(0x05C, 0x00), # SmokeCoAlarm.ExpressedState AttributeKey(0x05C, 0x01), # SmokeCoAlarm.SmokeState AttributeKey(0x05C, 0x02), # SmokeCoAlarm.COState AttributeKey(0x050, 0x03), # ModeSelect.CurrentMode # Energy metering readings (the primaries; min/max/overload/phase variants stay system) AttributeKey(0x090, 0x04), # ElectricalPowerMeasurement.Voltage AttributeKey(0x090, 0x05), # ElectricalPowerMeasurement.ActiveCurrent AttributeKey(0x090, 0x08), # ElectricalPowerMeasurement.ActivePower AttributeKey(0x091, 0x01), # ElectricalEnergyMeasurement.CumulativeEnergyImported } # Attribute-name prefixes that carry security material and must never be shown to the user # (-> system regardless of role). Matter DoorLock's Aliro* attributes are cryptographic keys / # identifiers that the current "read-only -> user" rule was leaking straight into the tap-view. SENSITIVE_ATTRIBUTE_NAME_PREFIXES: tuple[str, ...] = ("Aliro",) # Commands that are everyday one-tap actions (-> user). Every other command on a non-system # cluster defaults to `setting` — advanced management (schedules, credentials, logs, calibration). EVERYDAY_COMMANDS: set[tuple[int, int]] = { (0x006, 0x0), (0x006, 0x1), (0x006, 0x2), # OnOff Off / On / Toggle (0x008, 0x0), (0x008, 0x4), # LevelControl MoveToLevel / MoveToLevelWithOnOff (0x300, 0x0), (0x300, 0x3), (0x300, 0x6), (0x300, 0x7), (0x300, 0xA), # ColorControl hue/sat/hue+sat/color/temp (0x102, 0x0), (0x102, 0x1), (0x102, 0x2), (0x102, 0x5), # WindowCovering up / down / stop / goToLift% (0x101, 0x0), (0x101, 0x1), # DoorLock Lock / Unlock (credential/schedule cmds stay setting) (0x201, 0x0), # Thermostat SetpointRaiseLower (0x056, 0x0), # TemperatureControl SetTemperature (0x060, 0x0), (0x060, 0x1), (0x060, 0x2), (0x060, 0x3), # OperationalState Pause/Stop/Start/Resume (0x061, 0x0), (0x061, 0x3), # RvcOperationalState Pause / Resume (0x050, 0x0), # ModeSelect ChangeToMode (0x506, 0x0), (0x506, 0x1), (0x506, 0x2), # MediaPlayback Play / Pause / Stop } # Arguments to send along with a main-parameter command/attribute when the parameter itself # doesn't carry an obvious "activate" value (e.g. a mode/setpoint command). None means the # command takes no arguments; an int means a raw attribute value (only used for the FanControl # case below, where command_or_attribute_id is actually an attribute id). DefaultParams = dict[str, Any] | int | None class MainParameterSpec(NamedTuple): """Value of MAIN_PARAMETER_BY_CLUSTER, keyed by cluster_id: which command (or — for FanControl specifically — attribute) makes a sensible main_parameter for that cluster, and what default_params to send with it if the parameter has no obvious "activate" value. Iteration order is priority order — the first cluster below that's present on the device wins.""" command_or_attribute_id: int default_params: DefaultParams MAIN_PARAMETER_BY_CLUSTER: dict[int, MainParameterSpec] = { 0x00000006: MainParameterSpec(0x00000002, None), # OnOff.Toggle # Thermostat intentionally has NO one-tap: "raise or lower?" isn't a sensible tile action — # tapping a thermostat should open its screen, not nudge a setpoint blind. 0x00000202: MainParameterSpec(0x00000000, 0x04), # FanControl.FanMode.On(attribute) 0x00000056: MainParameterSpec( 0x00000000, {"targetTemperature": 22}, # TemperatureControl.SetTemperature (targetTemperature/targetTemperatureLevel are # feature-gated & mutually exclusive; targetTemperature covers the common "TN" case) ), 0x00000060: MainParameterSpec(0x00000002, None), # OperationalState.Start 0x00000061: MainParameterSpec(0x00000003, None), # RVCOperationalState.Resume 0x0000005F: MainParameterSpec(0x00000001, {"timeToAdd": 30}), # MicrowaveOvenControl.AddMoreTime 0x00000050: MainParameterSpec(0x00000000, {"newMode": 0}), # ModeSelect.ChangeToMode 0x00000101: MainParameterSpec(0x00000001, {"PINCode": None}), # DoorLock.UnlockDoor # 0x0000005C: MainParameterSpec(0x00000000, None), # SmokeCoAlarm.SelfTestRequest 0x00000506: MainParameterSpec(0x00000000, None), # MediaPlayback.Play 0x00000509: MainParameterSpec(0x00000000, {"keyCode": 0x44}), # KeyPadInpud.SendKey.Play 0x00000102: MainParameterSpec( 0x00000000, None, # WindowCovering.UpOrOpen — the everyday one-tap for a cover (open); StopMotion # (0x02) only matters mid-motion, so it's not the primary action ), 0x00000104: MainParameterSpec( 0x00000001, {"position": 1}, # ClosureControl.MoveTo{position=MoveToFullyOpen} — open is the everyday one-tap; # ClosureControl has no no-arg Open command (a latched closure may also need latch=False) ), 0x00000099: MainParameterSpec(0x00000001, None), # EnergyEvse.Disable 0x0000009E: MainParameterSpec(0x00000000, {"newMode": 0}), # WaterHeaterMode.ChangeToMode 0x00000553: MainParameterSpec( 0x00000000, {"streamUsage": 0, "originatingEndpointID": 0} ), # WebRTCTransportProvider.SolicitOffer 0x00000556: MainParameterSpec(0x00000000, None), # Chime.PlayChimeSound 0x00000081: MainParameterSpec(0x00000000, None), # ValveConfigurationAndControl.Open } # --- Merged UX classification ladder (mirror of the zigbee integration; see the matter README) --- # Matter has no runtime quirk layer, so the ladder is: our hand overrides > harvested HA judgment > # fallback. Safety rules (system cluster, sensitive crypto material) are forced hidden on top. class UxSpec(NamedTuple): visibility: ParameterVisibility role: ParameterRole | None = None unit: ParameterUnit | None = None def _our_attribute_ux() -> dict[AttributeKey, UxSpec]: out: dict[AttributeKey, UxSpec] = {} for key in USER_READINGS: out[key] = UxSpec(ParameterVisibility.user, ParameterRole.sensor) for key in EVERYDAY_CONTROL_ATTRIBUTES: out[key] = UxSpec(ParameterVisibility.user, ParameterRole.control) return out OUR_ATTRIBUTE_UX: dict[AttributeKey, UxSpec] = _our_attribute_ux() def _ha_uxspec(key: AttributeKey) -> UxSpec | None: t = MATTER_HA_ATTRIBUTE_UX.get((key.cluster_id, key.attribute_id)) if t is None: return None return UxSpec(ParameterVisibility(t[0]), ParameterRole(t[1]), ParameterUnit(t[2])) # Flip once harvested coverage is validated on real devices: unmatched writable attrs then hide # (system) instead of defaulting to a settings toggle. See the matter README (fallback). _FALLBACK_HIDE_UNCURATED = False def classify_attribute( cluster_id: int, attribute_id: int, name: str, *, writable: bool, in_system_cluster: bool, ) -> tuple[UxSpec, str]: """Resolve an attribute's (visibility, role, unit) by the priority ladder, returning the spec and a source tag. First match wins: - system cluster / sensitive crypto material -> system (safety, top priority) 1. OUR_ATTRIBUTE_UX — hand curation 2. MATTER_HA_ATTRIBUTE_UX — harvested HA entity judgment 3. fallback policy — writable -> setting, else system; WARNS (uncurated) """ if in_system_cluster: return UxSpec(ParameterVisibility.system), "system-cluster" if name.startswith(SENSITIVE_ATTRIBUTE_NAME_PREFIXES): return UxSpec(ParameterVisibility.system), "sensitive" key = AttributeKey(cluster_id, attribute_id) if (spec := OUR_ATTRIBUTE_UX.get(key)) is not None: return spec, "ours" if (spec := _ha_uxspec(key)) is not None: return spec, "ha" if not _FALLBACK_HIDE_UNCURATED and writable: return UxSpec(ParameterVisibility.setting, ParameterRole.control), "fallback-writable" return UxSpec(ParameterVisibility.system), "fallback-system" ================================================================================ # FILE: majordom_matter/matter_spec_ha.py ================================================================================ """GENERATED by scripts/harvest_matter_ha.py — DO NOT EDIT BY HAND. Source: home-assistant/core@dev homeassistant/components/matter (Apache-2.0), parsed via AST (no homeassistant dependency). Attribute ids resolved via chip. Harvested entity judgment only (entity_category -> visibility, platform -> role, unit); the Matter Data Model supplies names/types/bounds. Hand overrides live in matter_spec.py and win on merge. """ # (cluster_id, attribute_id) -> (visibility, role, unit) MATTER_HA_ATTRIBUTE_UX: dict[tuple[int, int], tuple[str, str, str]] = { (0x0003, 0x0001): ('setting', 'control', 'plain'), (0x0006, 0x0000): ('user', 'control', 'plain'), (0x0006, 0x4003): ('setting', 'control', 'plain'), (0x0008, 0x0000): ('user', 'control', 'percentage'), (0x0008, 0x0010): ('setting', 'control', 'plain'), (0x0008, 0x0011): ('setting', 'control', 'plain'), (0x0008, 0x0012): ('setting', 'control', 'plain'), (0x0008, 0x0013): ('setting', 'control', 'plain'), (0x0008, 0x4000): ('setting', 'control', 'plain'), (0x002f, 0x000b): ('setting', 'sensor', 'volt'), (0x002f, 0x000c): ('setting', 'sensor', 'percentage'), (0x002f, 0x000d): ('setting', 'sensor', 'plain'), (0x002f, 0x000e): ('setting', 'sensor', 'percentage'), (0x002f, 0x0013): ('setting', 'sensor', 'plain'), (0x002f, 0x001a): ('setting', 'sensor', 'plain'), (0x002f, 0x001b): ('setting', 'sensor', 'plain'), (0x0033, 0x0001): ('setting', 'sensor', 'plain'), (0x0033, 0x0002): ('setting', 'sensor', 'plain'), (0x0033, 0x0004): ('setting', 'sensor', 'plain'), (0x0033, 0x0005): ('setting', 'sensor', 'plain'), (0x0033, 0x0006): ('setting', 'sensor', 'plain'), (0x0033, 0x0007): ('setting', 'sensor', 'plain'), (0x0035, 0x0000): ('setting', 'sensor', 'plain'), (0x0035, 0x0001): ('setting', 'sensor', 'plain'), (0x0035, 0x0002): ('setting', 'sensor', 'plain'), (0x0036, 0x0004): ('setting', 'sensor', 'plain'), (0x003b, 0x0001): ('user', 'sensor', 'plain'), (0x0045, 0x0000): ('user', 'sensor', 'plain'), (0x0048, 0x0001): ('user', 'sensor', 'plain'), (0x0048, 0x0004): ('user', 'sensor', 'plain'), (0x0049, 0x0001): ('user', 'control', 'plain'), (0x0050, 0x0003): ('setting', 'control', 'plain'), (0x0051, 0x0001): ('user', 'control', 'plain'), (0x0052, 0x0001): ('user', 'control', 'plain'), (0x0053, 0x0001): ('user', 'control', 'plain'), (0x0053, 0x0002): ('user', 'control', 'plain'), (0x0055, 0x0001): ('user', 'control', 'plain'), (0x0056, 0x0000): ('user', 'control', 'celsius'), (0x0056, 0x0004): ('user', 'control', 'plain'), (0x0057, 0x0002): ('setting', 'sensor', 'plain'), (0x0059, 0x0001): ('user', 'control', 'plain'), (0x005b, 0x0000): ('user', 'sensor', 'plain'), (0x005c, 0x0001): ('user', 'sensor', 'plain'), (0x005c, 0x0002): ('user', 'sensor', 'plain'), (0x005c, 0x0003): ('setting', 'sensor', 'percentage'), (0x005c, 0x0004): ('setting', 'sensor', 'plain'), (0x005c, 0x0005): ('setting', 'sensor', 'plain'), (0x005c, 0x0006): ('setting', 'sensor', 'plain'), (0x005c, 0x0007): ('setting', 'sensor', 'plain'), (0x005c, 0x0008): ('user', 'sensor', 'plain'), (0x005c, 0x0009): ('user', 'sensor', 'plain'), (0x005c, 0x000a): ('user', 'sensor', 'plain'), (0x005c, 0x000b): ('setting', 'control', 'plain'), (0x005c, 0x000c): ('user', 'sensor', 'plain'), (0x005c, 0xfff9): ('setting', 'control', 'plain'), (0x005d, 0x0002): ('setting', 'sensor', 'plain'), (0x005f, 0x0000): ('user', 'control', 'plain'), (0x005f, 0x0007): ('user', 'control', 'plain'), (0x0060, 0x0001): ('user', 'sensor', 'plain'), (0x0060, 0x0002): ('user', 'sensor', 'plain'), (0x0060, 0x0004): ('user', 'sensor', 'plain'), (0x0060, 0x0005): ('setting', 'sensor', 'plain'), (0x0060, 0xfff9): ('user', 'control', 'plain'), (0x0061, 0x0001): ('user', 'sensor', 'plain'), (0x0061, 0x0004): ('user', 'sensor', 'plain'), (0x0061, 0x0005): ('setting', 'sensor', 'plain'), (0x0071, 0x0000): ('user', 'sensor', 'percentage'), (0x0071, 0xfff9): ('user', 'control', 'plain'), (0x0072, 0x0000): ('user', 'sensor', 'percentage'), (0x0072, 0xfff9): ('user', 'control', 'plain'), (0x0080, 0x0000): ('setting', 'control', 'plain'), (0x0081, 0x0001): ('setting', 'control', 'plain'), (0x0081, 0x0002): ('user', 'sensor', 'plain'), (0x0081, 0x0009): ('setting', 'sensor', 'plain'), (0x0090, 0x0004): ('setting', 'sensor', 'volt'), (0x0090, 0x0005): ('setting', 'sensor', 'ampere'), (0x0090, 0x0006): ('setting', 'sensor', 'ampere'), (0x0090, 0x0007): ('setting', 'sensor', 'ampere'), (0x0090, 0x0008): ('user', 'sensor', 'watt'), (0x0090, 0x0009): ('setting', 'sensor', 'plain'), (0x0090, 0x000a): ('setting', 'sensor', 'plain'), (0x0090, 0x000b): ('setting', 'sensor', 'volt'), (0x0090, 0x000c): ('setting', 'sensor', 'ampere'), (0x0091, 0x0001): ('user', 'sensor', 'kwh'), (0x0091, 0x0002): ('user', 'sensor', 'kwh'), (0x0094, 0x0002): ('user', 'sensor', 'plain'), (0x0094, 0x0003): ('setting', 'sensor', 'kwh'), (0x0094, 0x0004): ('user', 'sensor', 'percentage'), (0x0094, 0x0005): ('user', 'sensor', 'plain'), (0x0094, 0xfff9): ('user', 'control', 'plain'), (0x0098, 0x0002): ('setting', 'sensor', 'plain'), (0x0098, 0x0007): ('setting', 'sensor', 'plain'), (0x0099, 0x0000): ('user', 'sensor', 'plain'), (0x0099, 0x0001): ('user', 'sensor', 'plain'), (0x0099, 0x0002): ('setting', 'sensor', 'plain'), (0x0099, 0x0005): ('setting', 'sensor', 'ampere'), (0x0099, 0x0006): ('setting', 'sensor', 'ampere'), (0x0099, 0x0007): ('setting', 'sensor', 'ampere'), (0x0099, 0x0009): ('setting', 'sensor', 'ampere'), (0x0099, 0x0030): ('user', 'sensor', 'percentage'), (0x009d, 0x0001): ('user', 'control', 'plain'), (0x009f, 0x0001): ('user', 'control', 'plain'), (0x0101, 0x0000): ('user', 'control', 'plain'), (0x0101, 0x0002): ('setting', 'sensor', 'plain'), (0x0101, 0x0003): ('user', 'sensor', 'plain'), (0x0101, 0x0004): ('setting', 'sensor', 'plain'), (0x0101, 0x0005): ('setting', 'sensor', 'plain'), (0x0101, 0x0023): ('setting', 'control', 'plain'), (0x0101, 0x0024): ('setting', 'control', 'plain'), (0x0101, 0x0025): ('setting', 'control', 'plain'), (0x0101, 0x002b): ('setting', 'control', 'plain'), (0x0101, 0x0030): ('setting', 'control', 'plain'), (0x0101, 0x0031): ('setting', 'control', 'plain'), (0x0102, 0x0007): ('setting', 'sensor', 'plain'), (0x0102, 0x000a): ('user', 'control', 'plain'), (0x0102, 0x000b): ('setting', 'sensor', 'percentage'), (0x0150, 0x0004): ('user', 'sensor', 'plain'), (0x0200, 0x0010): ('user', 'sensor', 'plain'), (0x0200, 0x0014): ('user', 'sensor', 'plain'), (0x0200, 0x0020): ('user', 'control', 'plain'), (0x0200, 0x0021): ('user', 'sensor', 'plain'), (0x0201, 0x0000): ('user', 'sensor', 'celsius'), (0x0201, 0x0001): ('user', 'sensor', 'celsius'), (0x0201, 0x0002): ('user', 'sensor', 'plain'), (0x0201, 0x0008): ('setting', 'sensor', 'percentage'), (0x0201, 0x0010): ('setting', 'control', 'celsius'), (0x0201, 0x001a): ('setting', 'sensor', 'plain'), (0x0201, 0x0034): ('setting', 'control', 'celsius'), (0x0202, 0x0000): ('user', 'control', 'plain'), (0x0204, 0x0000): ('setting', 'control', 'plain'), (0x0204, 0x0001): ('setting', 'control', 'plain'), (0x0400, 0x0000): ('user', 'sensor', 'lux'), (0x0402, 0x0000): ('user', 'sensor', 'celsius'), (0x0403, 0x0000): ('user', 'sensor', 'pascal'), (0x0404, 0x0000): ('user', 'sensor', 'plain'), (0x0405, 0x0000): ('user', 'sensor', 'percentage'), (0x0406, 0x0000): ('user', 'sensor', 'plain'), (0x0406, 0x0003): ('setting', 'control', 'plain'), (0x0406, 0x0010): ('setting', 'control', 'plain'), (0x0406, 0x0011): ('setting', 'control', 'plain'), (0x0406, 0x0012): ('setting', 'control', 'plain'), (0x040c, 0x0000): ('user', 'sensor', 'ppm'), (0x040d, 0x0000): ('user', 'sensor', 'ppm'), (0x0413, 0x0000): ('user', 'sensor', 'ppm'), (0x0415, 0x0000): ('user', 'sensor', 'ppm'), (0x042a, 0x0000): ('user', 'sensor', 'ugm3'), (0x042c, 0x0000): ('user', 'sensor', 'ugm3'), (0x042d, 0x0000): ('user', 'sensor', 'ugm3'), (0x042e, 0x0000): ('user', 'sensor', 'ppm'), (0x042e, 0x000a): ('user', 'sensor', 'plain'), (0x042f, 0x0000): ('user', 'sensor', 'plain'), (0x0430, 0x0001): ('user', 'sensor', 'percentage'), } ================================================================================ # FILE: majordom_matter/model.py ================================================================================ from enum import StrEnum from typing import Any from uuid import UUID from majordom_integration_sdk.schemas.base import Base from majordom_integration_sdk.schemas.device import Device, DeviceState, Parameter, ParameterState from pydantic import BaseModel, Field class MatterParameterTypeEnum(StrEnum): attribute = "attribute" command = "command" class MatterDeviceIntegrationData(Base): # Defaults to 0 (the "not yet commissioned" sentinel): the Hub seeds a provisional device # with empty integration_data before pair_device runs, and the read-back must validate as a # MatterDeviceState. pair_device sets the real node_id once matter-server commissions the node. node_id: int = 0 black_list: list[UUID] = Field(default_factory=list) class MatterParameterIntegrationData(BaseModel): endpoint_id: int cluster_id: int is_client: bool = False command_id: int | None = None attribute_id: int | None = None type: MatterParameterTypeEnum # "command" or "attribute" # Args to send when this command is used as the device's one-tap main parameter and needs # them (e.g. a setpoint). A command parameter's data_type is `none`, which already satisfies # ParameterState.can_be_main_parameter, so no `default_value` is needed for the flag — this # only carries *what to send*. send_command applies it when a command arrives with no value # (i.e. the user tapped the main parameter). See the zigbee model for the same note. default_arguments: dict[str, Any] | None = None class MatterDevice(Device): integration_data: MatterDeviceIntegrationData @property def node_id(self) -> int: assert self.integration_data if isinstance(self.integration_data, dict): return self.integration_data["node_id"] return self.integration_data.node_id class MatterParameter(Parameter): integration_data: MatterParameterIntegrationData class MatterParameterState(ParameterState): integration_data: MatterParameterIntegrationData class MatterDeviceState(MatterDevice, DeviceState): parameters: list[MatterParameterState] ================================================================================ # FILE: majordom_matter/readme.md ================================================================================ # Matter integration The hub talks to a **Matter controller server** over its WebSocket API (`ws://:5580/ws`) via `matter_server.client.MatterClient` (server URL overridable via the `MATTER_SERVER_URL` env var). The server owns the Matter fabric, the Thread/BLE radios, and commissioning; the hub discovers, commissions, and controls devices through it. ## Files | File | Responsibility | |---|---| | `controller.py` | `AbstractController` implementation — lifecycle, commissioning, control, and the mDNS + BLE discovery/subscription paths | | `mapper.py` | Matter ↔ MajorDom conversions: UUIDs (via the framework helpers), node/cluster → parameter parsing, data-type/unit/scale mapping, credentials options | | `model.py` | Typed `integration_data` schemas (`MatterDevice`, `MatterParameter`, …) | | `matter_spec.py` | Static spec metadata: system clusters/attributes, units/steps/scale, and the main-parameter map | | `exceptions.py` | Integration-specific exceptions | ## Discovery, pairing & availability - **Discovery** runs over two transports. On-network (mDNS) devices come from matter-server's `discover_commissionable_nodes()`; BLE-only commissionable devices (a factory-fresh device not yet on any network) are found via the Hub's shared **BLE discovery service** and matched by their commissioning identity. matter-server is kept for on-network discovery because it already parses the Matter TXT records into clean structured data (duplicating that on the raw Zeroconf service would add a second parser and, for BLE, a second scanner contending with the chip stack during commissioning). - **Pairing** validates the provided credentials type against the discovery's `expected_credentials_options`, then commissions through matter-server (`commission_with_code` for QR / BLE, `commission_on_network` for an on-network manual code). - **Availability** rides on `EventType.NODE_UPDATED`: `node.available` transitions emit the framework's connect/lose callbacks (deduped in `_make_availability_callback`). Every parameter id is derived through the framework UUID helpers as `parameter_uuid(device_id, "_//")`. Two test tiers: - **Virtual** — `tests/test_controllers/test_matter/test_matter_controller.py`, driven by `docker-compose.matter-tests.yml`. Prebuilt **x86-64** Matter Virtual Device (MVD) binaries under `bins/` act as fake devices, commissioned over the network (no BLE). Runs in CI on x86-64 runners. - **Hardware** — `tests/test_controllers/test_matter/test_matter_controller_hardware.py`, on the self-hosted `lab-pi5` runner: a real Thread bulb commissioned over **BLE → Thread** through a real OpenThread Border Router (OTBR + SkyConnect RCP), verified physically via the IoT-cage photoresistor. Manual only (`workflow_dispatch`). The target device is selectable in one line — `_TARGET` in the test, or the `MATTER_HW_TARGET=ikea|nanoleaf` env var (default `ikea`); both bulbs pass. --- ## Issues we hit (and their true causes) - **Stale OTBR image was the whole ballgame.** A months-old cached `openthread/border-router:latest` had a broken RCP serial init: otbr-agent failed/hung at `spinel_driver.cpp:87`, `wpan0` never came up, and it crash-looped — producing red-herring `cp210x … -110` USB timeouts and uninterruptible `D`-state `open()` hangs. With no Thread network, **every** device's commissioning failed at `NetworkCommissioning.scanNetworks` ("Failure"). The RCP firmware/hardware were fine the whole time (the flasher + raw pyserial talked SPINEL reliably). - **`uart-init-deassert` breaks this build.** Adding it to `OT_RCP_DEVICE` makes otbr-agent reject the URL (`otSysInit … InvalidArgument`). It's a valid param in other OT builds — not this one. - **OTBR state isn't persisted by default** → the border router factory-resets on every restart. And mounting a volume over `/var/lib/thread` fails (`ln: cannot overwrite directory`) — the image symlinks `/var/lib/thread → /data`, so persist at **`/data`**. - **Docker `userns-remap`**: privileged containers are rejected without `--userns=host`, and the OTBR image's s6-overlay refuses to start unless `/run` is a fresh root-owned tmpfs. - **The x86-64 MVD binaries can't run on the Pi** (16 KB-page kernel; qemu-user can't map the 4 KB-aligned segments) → the virtual suite is x86-64 CI only. - **BLE discovery misses static-address devices.** Matter controllers discover by BlueZ's `InterfacesAdded` signal; a device with a *static* BLE address (IKEA) already in BlueZ's cache never re-fires it → "no commissionable device discovered." Nanoleaf uses a resolvable address, so it's fine. - **The hub does not push the Thread dataset.** `MatterController.pair_device` just calls `commission_with_code`; the server must *already* hold the operational dataset. - **The Arduino cage resets all relays OFF on every serial open** (DTR pulse) — bulbs lose power unless one fd holds the port open for the whole run. - **The "Nanoleaf RX-wedge" was a broken-OTBR artifact, not firmware.** We'd blamed Nanoleaf fw 4.1.3 for wedging its Thread RX after one `Invoke`. Once the border router was healthy (fresh image, above), it never reproduced: 25 back-to-back Toggle+read+ping cycles with an active subscription, plus Level/Color commands and a 90 s idle soak, all clean — `ot-ctl ping` never dropped a packet. The old symptom was a degraded Thread mesh from the spinel-failing RCP. **Both the IKEA and the Nanoleaf are CI-usable.** (The Nanoleaf's shared A0-photoresistor light and its slot-3 power are wired in the test.) ## What it takes to make it work 1. **Fresh OTBR image**: `docker pull openthread/border-router:latest`, then `OT_RCP_DEVICE=spinel+hdlc+uart:///dev/ttyUSB1?uart-baudrate=460800&uart-flow-control` (no `uart-init-deassert`), `--network host --privileged --cap-add NET_ADMIN --userns=host`, tmpfs `/run`+`/tmp`, `--device :/dev/ttyUSB1 --device /dev/net/tun`, and **`-v :/data`** for persistence. 2. **Form a network once**: `ot-ctl dataset init new; dataset commit active; ifconfig up; thread start` → `state: leader`. 3. **Give the Matter server the dataset** before commissioning: read it from OTBR (`ot-ctl dataset active -x`, or the OTBR REST `GET /node/dataset/active`) and `set_thread_dataset`. 4. **Flush the BlueZ cache immediately before each commission** and do not scan afterward: `for m in $(bluetoothctl devices | awk '{print $2}'); do bluetoothctl remove "$m"; done; systemctl restart bluetooth`. 5. **Power the device on** (IKEA opens a ~5-min pairing window on any power-on; a decommissioned Nanoleaf re-advertises on power-on), driving the cage from a single held serial fd. 6. **Pick the target**: IKEA (`2455-383-5850`, cage slot 2) is the default CI device; switch to the Nanoleaf (`1321-631-8363`, cage slot 3) with `MATTER_HW_TARGET=nanoleaf`. Both pass the full discover → pair → OnOff-with-photoresistor → unpair flow. See `otbr-skyconnect-fix` in the assistant memory for the exact commands and recovery steps. ================================================================================ # FILE: majordom_matter/testing/__init__.py ================================================================================ """In-process test doubles for the Matter integration — no matter-server, no MVD binaries. `FakeMatterClient` stands in for `matter_server.client.MatterClient`, backed by a single canned node captured from a real MVD on-off-light (`fixtures/on_off_light_node.json`). It rebuilds a **real** `MatterNode` exactly as the live client does — `MatterNode(dataclass_from_dict( MatterNodeData, ...))` — so the controller and mapper run against a faithful node; only the transport (the websocket to matter-server) is faked. Use it to exercise the controller's wiring — discovery, pairing/mapping, commands, events — in plain CI without docker. The heavy, real-device coverage stays in the dockerized MVD suite. from majordom_matter.testing import FakeMatterClient monkeypatch.setattr("majordom_matter.controller.MatterClient", FakeMatterClient) """ from __future__ import annotations import asyncio import json from pathlib import Path from typing import Any from matter_server.client.models.node import MatterNode from matter_server.common.helpers.util import dataclass_from_dict from matter_server.common.models import CommissionableNodeData, EventType, MatterNodeData _FIXTURE = Path(__file__).parent / "fixtures" / "on_off_light_node.json" def load_on_off_light_node() -> MatterNode: """A real `MatterNode` for a captured MVD on-off-light — the same construction the live client applies to node data received over the websocket.""" data = json.loads(_FIXTURE.read_text()) return MatterNode(dataclass_from_dict(MatterNodeData, data)) class _Subscription: __slots__ = ("callback", "event_filter", "node_filter", "attr_path_filter") def __init__(self, callback, event_filter, node_filter, attr_path_filter): self.callback = callback self.event_filter = event_filter self.node_filter = node_filter self.attr_path_filter = attr_path_filter class FakeMatterClient: """Drop-in for `matter_server.client.MatterClient`, backed by one canned node. Constructed with the same `(url, session)` signature the controller uses. The node is not "on the fabric" until commissioned; before that it is advertised via `discover_commissionable_nodes()`, so the full discover → pair → map flow runs. Outgoing commands/attribute writes are recorded (`sent_commands`, `written_attributes`, `removed_nodes`) and `fire_attribute_update()` lets a test simulate a device-side change. """ def __init__(self, url: str | None = None, session: Any = None, *, node: MatterNode | None = None): self._url = url self._session = session self._node = node if node is not None else load_on_off_light_node() self._commissioned = False self._subscriptions: list[_Subscription] = [] self.sent_commands: list[tuple[int, int, Any]] = [] self.written_attributes: list[tuple[int, str, Any]] = [] self.removed_nodes: list[int] = [] # --- lifecycle --- async def connect(self) -> None: return None async def start_listening(self, init_ready: asyncio.Event | None = None) -> None: if init_ready is not None: init_ready.set() # The real client runs its websocket loop forever; block until the controller's stop() # cancels this task. await asyncio.Event().wait() async def disconnect(self) -> None: return None # --- nodes --- def get_nodes(self) -> list[MatterNode]: return [self._node] if self._commissioned else [] def get_node(self, node_id: int) -> MatterNode | None: if self._commissioned and node_id == self._node.node_id: return self._node return None async def remove_node(self, node_id: int) -> None: self.removed_nodes.append(node_id) if node_id == self._node.node_id: self._commissioned = False # --- discovery --- async def discover_commissionable_nodes(self) -> list[CommissionableNodeData]: if self._commissioned: return [] info = self._node.device_info return [ CommissionableNodeData( instance_name="FAKEONOFF0001", vendor_id=getattr(info, "vendorID", None), product_id=getattr(info, "productID", None), commissioning_mode=1, # advertising for commissioning device_name=getattr(info, "productName", None) or "Fake On/Off Light", addresses=["fd00::1"], # on-network → the code path uses commission_on_network ) ] # --- commissioning --- async def commission_with_code(self, code: str, network_only: bool = False) -> MatterNodeData: self._commissioned = True return self._node.node_data async def commission_on_network(self, setup_pin_code: int, *args: Any, **kwargs: Any) -> MatterNodeData: self._commissioned = True return self._node.node_data # --- commands / attributes --- async def send_device_command(self, node_id: int, endpoint_id: int, command: Any, **kwargs: Any) -> None: self.sent_commands.append((node_id, endpoint_id, command)) return None async def write_attribute(self, node_id: int, attribute_path: str, value: Any) -> Any: self.written_attributes.append((node_id, attribute_path, value)) # Reflect the write into the node so reads and subscribers see it, like a real device. self._node.update_attribute(attribute_path, value) self._emit(EventType.ATTRIBUTE_UPDATED, node_id, attribute_path, value) return value # --- subscriptions --- def subscribe_events(self, callback, event_filter=None, node_filter=None, attr_path_filter=None): sub = _Subscription(callback, event_filter, node_filter, attr_path_filter) self._subscriptions.append(sub) def unsubscribe() -> None: if sub in self._subscriptions: self._subscriptions.remove(sub) return unsubscribe # --- test helpers --- def fire_attribute_update(self, attribute_path: str, value: Any) -> None: """Simulate the device pushing a new attribute value to its subscribers.""" self._node.update_attribute(attribute_path, value) self._emit(EventType.ATTRIBUTE_UPDATED, self._node.node_id, attribute_path, value) def _emit(self, event_type: EventType, node_id: int, attribute_path: str, value: Any) -> None: for sub in list(self._subscriptions): if sub.event_filter is not None and sub.event_filter != event_type: continue if sub.node_filter is not None and sub.node_filter != node_id: continue if sub.attr_path_filter is not None and sub.attr_path_filter != attribute_path: continue sub.callback(event_type, value) __all__ = ["FakeMatterClient", "load_on_off_light_node"] ================================================================================ # FILE: majordom_matter/testing/fixtures/on_off_light_node.json ================================================================================ { "attribute_subscriptions": [], "attributes": { "0/29/0": [ { "0": 22, "1": 1 } ], "0/29/1": [ 29, 31, 40, 42, 48, 49, 50, 51, 52, 60, 62, 63, 64 ], "0/29/2": [ 41 ], "0/29/3": [ 13 ], "0/29/65528": [], "0/29/65529": [], "0/29/65531": [ 0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533 ], "0/29/65532": 0, "0/29/65533": 2, "0/31/0": [ { "1": 5, "2": 2, "254": 1, "3": [ 112233 ], "4": null } ], "0/31/1": [], "0/31/2": 4, "0/31/3": 3, "0/31/4": 4, "0/31/65528": [], "0/31/65529": [], "0/31/65531": [ 0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533 ], "0/31/65532": 0, "0/31/65533": 2, "0/40/0": 18, "0/40/1": "TEST_VENDOR", "0/40/10": "1.0", "0/40/11": "20200101", "0/40/12": "", "0/40/13": "", "0/40/14": "", "0/40/15": "TEST_SN", "0/40/16": false, "0/40/18": "6D78CCBDAFD85AD1", "0/40/19": { "0": 3, "1": 65535 }, "0/40/2": 65521, "0/40/21": 17039616, "0/40/22": 1, "0/40/3": "TEST_PRODUCT", "0/40/4": 32768, "0/40/5": "", "0/40/6": "XX", "0/40/65528": [], "0/40/65529": [], "0/40/65531": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 21, 22, 65528, 65529, 65531, 65532, 65533 ], "0/40/65532": 0, "0/40/65533": 3, "0/40/7": 0, "0/40/8": "TEST_VERSION", "0/40/9": 1, "0/42/0": [], "0/42/1": true, "0/42/2": 0, "0/42/3": 0, "0/42/65528": [], "0/42/65529": [ 0 ], "0/42/65531": [ 0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533 ], "0/42/65532": 0, "0/42/65533": 1, "0/48/0": 0, "0/48/1": { "0": 60, "1": 900 }, "0/48/2": 0, "0/48/3": 2, "0/48/4": true, "0/48/65528": [ 1, 3, 5 ], "0/48/65529": [ 0, 2, 4 ], "0/48/65531": [ 0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533 ], "0/48/65532": 0, "0/48/65533": 1, "0/49/0": 1, "0/49/1": [ { "0": "", "1": true } ], "0/49/2": 0, "0/49/3": 0, "0/49/4": true, "0/49/5": null, "0/49/6": null, "0/49/65528": [], "0/49/65529": [], "0/49/65531": [ 0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533 ], "0/49/65532": 4, "0/49/65533": 2, "0/49/7": null, "0/50/65528": [ 1 ], "0/50/65529": [ 0 ], "0/50/65531": [ 65528, 65529, 65531, 65532, 65533 ], "0/50/65532": 0, "0/50/65533": 1, "0/51/0": [ { "0": "eth0", "1": true, "2": null, "3": null, "4": "AkKsEO4D", "5": [ "rBDuAw==" ], "6": [ "/QANuAABAAAAAAAAAAAAAw==", "/oAAAAAAAAAAQqz//hDuAw==" ], "7": 0 }, { "0": "ip6tnl0", "1": false, "2": null, "3": null, "4": "AAAAAAAA", "5": [], "6": [], "7": 0 }, { "0": "tunl0", "1": false, "2": null, "3": null, "4": "AAAAAAAA", "5": [], "6": [], "7": 0 }, { "0": "lo", "1": true, "2": null, "3": null, "4": "AAAAAAAA", "5": [ "fwAAAQ==" ], "6": [ "AAAAAAAAAAAAAAAAAAAAAQ==" ], "7": 0 } ], "0/51/1": 1, "0/51/2": 6, "0/51/3": 0, "0/51/4": 0, "0/51/5": [], "0/51/6": [], "0/51/65528": [ 2 ], "0/51/65529": [ 0, 1 ], "0/51/65531": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 ], "0/51/65532": 0, "0/51/65533": 2, "0/51/7": [], "0/51/8": false, "0/52/0": [ { "0": 55, "1": "55" }, { "0": 54, "1": "54" }, { "0": 53, "1": "53" }, { "0": 52, "1": "52" }, { "0": 50, "1": "50" } ], "0/52/1": 358288, "0/52/2": 710768, "0/52/3": 710768, "0/52/65528": [], "0/52/65529": [ 0 ], "0/52/65531": [ 0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533 ], "0/52/65532": 1, "0/52/65533": 1, "0/60/0": 0, "0/60/1": null, "0/60/2": null, "0/60/65528": [], "0/60/65529": [ 0, 1, 2 ], "0/60/65531": [ 0, 1, 2, 65528, 65529, 65531, 65532, 65533 ], "0/60/65532": 0, "0/60/65533": 1, "0/62/0": [ { "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRLhgkBwEkCAEwCUEEY7W1K6kpx4vZWVLIU73qp/kW9pyLNDQuBV6RLpvdIkVTbb29I6Xzm7itl+MOnHXKMtlRHRkLh64D5XlIb7XRVTcKNQEoARgkAgE2AwQCBAEYMAQUjtcdbm56tODHaNoV/ioyU9SpxREwBRSo+UzPP0I0E1K8a1hlCujFdiWqzxgwC0BxmMahSG1S3y5vyKFL58777UBKPFwP66Qm3EV80n2LZbWy9nWWkYhWZCFr7Y6fkJ3a5wG5N6RJWdNXXMBZZnxLGA==", "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE0RSu778u6pYZsH0NF1KbMVkRx6WCDubvsdq0FMGbdXjruqXkI2lQ6J4UPR6oR0ksaD2R2KvRQIzErBXbmIwHlzcKNQEpARgkAmAwBBSo+UzPP0I0E1K8a1hlCujFdiWqzzAFFNN+KRsnAn7+Qs9DAq38/3NJvWCVGDALQOIIDhZiG24xAI3J0yI4IHE7al9lIt/iEHhpWWlKqu4k3/o83hn3l/w9QeTi+/VmeexEnKdqgOHMxcWyM6PKjcQY", "254": 1 } ], "0/62/1": [ { "1": "BITlbMUi+wQk6xm6NN5escj+cIPQ1hBZmViEs4R9dzxzwP2RamKdP3Gyop8yGiIFCTGwWZIoiENYiRPloYymxZE=", "2": 65521, "254": 1, "3": 1, "4": 46, "5": "" } ], "0/62/2": 16, "0/62/3": 1, "0/62/4": [ "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEhOVsxSL7BCTrGbo03l6xyP5wg9DWEFmZWISzhH13PHPA/ZFqYp0/cbKinzIaIgUJMbBZkiiIQ1iJE+WhjKbFkTcKNQEpARgkAmAwBBTTfikbJwJ+/kLPQwKt/P9zSb1glTAFFNN+KRsnAn7+Qs9DAq38/3NJvWCVGDALQPDg3Kj5rxI91fXdW2CvG+HsbMGtRBPIagmzYb4Mq4VUkyQfjrTZvLCHKcvRxwZmJDDGYr0EVUf5M4l377vPfjAY" ], "0/62/5": 1, "0/62/65528": [ 1, 3, 5, 8 ], "0/62/65529": [ 0, 2, 4, 6, 7, 9, 10, 11 ], "0/62/65531": [ 0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533 ], "0/62/65532": 0, "0/62/65533": 1, "0/63/0": [], "0/63/1": [], "0/63/2": 4, "0/63/3": 3, "0/63/65528": [ 2, 5 ], "0/63/65529": [ 0, 1, 3, 4 ], "0/63/65531": [ 0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533 ], "0/63/65532": 0, "0/63/65533": 2, "0/64/0": [ { "0": "room", "1": "bedroom 2" }, { "0": "orientation", "1": "North" }, { "0": "floor", "1": "2" }, { "0": "direction", "1": "up" } ], "0/64/65528": [], "0/64/65529": [], "0/64/65531": [ 0, 65528, 65529, 65531, 65532, 65533 ], "0/64/65532": 0, "0/64/65533": 1, "13/29/0": [ { "0": 256, "1": 1 } ], "13/29/1": [ 3, 4, 6, 8, 29 ], "13/29/2": [ 30 ], "13/29/3": [], "13/29/65528": [], "13/29/65529": [], "13/29/65531": [ 0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533 ], "13/29/65532": 0, "13/29/65533": 2, "13/3/0": 0, "13/3/1": 0, "13/3/65528": [], "13/3/65529": [ 0 ], "13/3/65531": [ 0, 1, 65528, 65529, 65531, 65532, 65533 ], "13/3/65532": 0, "13/3/65533": 2, "13/4/0": 128, "13/4/65528": [ 0, 1, 2, 3 ], "13/4/65529": [ 0, 1, 2, 3, 4, 5 ], "13/4/65531": [ 0, 65528, 65529, 65531, 65532, 65533 ], "13/4/65532": 1, "13/4/65533": 3, "13/6/0": false, "13/6/16384": true, "13/6/16385": 0, "13/6/16386": 0, "13/6/16387": 0, "13/6/65528": [], "13/6/65529": [ 0, 1, 2 ], "13/6/65531": [ 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 ], "13/6/65532": 1, "13/6/65533": 5, "13/8/0": 1, "13/8/1": 0, "13/8/15": 1, "13/8/16384": 1, "13/8/17": 254, "13/8/2": 1, "13/8/3": 254, "13/8/65528": [], "13/8/65529": [ 0, 1, 2, 3, 4, 5, 6, 7 ], "13/8/65531": [ 0, 1, 2, 3, 15, 17, 16384, 65528, 65529, 65531, 65532, 65533 ], "13/8/65532": 3, "13/8/65533": 6 }, "available": true, "date_commissioned": "2026-07-18T00:41:44.182162", "interview_version": 6, "is_bridge": false, "last_interview": "2026-07-18T00:41:44.182176", "node_id": 46 } ================================================================================ # FILE: pyproject.toml ================================================================================ [project] name = "majordom-matter" version = "0.1.4" description = "Matter integration for MajorDom — bridges Matter devices into the MajorDom language." authors = [{ name = "Mark Parker", email = "mark@parker-programs.com" }] readme = "README.md" license = "LicenseRef-PolyForm-Noncommercial-1.0.0" license-files = ["LICENSE"] keywords = ["majordom", "matter", "thread", "smart-home", "home-automation", "iot", "integration", "csa", "connectedhomeip"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Topic :: Home Automation", "Programming Language :: Python :: 3", "Operating System :: POSIX :: Linux", ] requires-python = ">=3.12,<3.14" dependencies = [ "majordom-integration-sdk (>=0.1.5,<1.0)", "python-matter-server (>=8.1.2,<9.0.0)", "home-assistant-chip-core (>=2025.7.0)", "home-assistant-chip-clusters (>=2025.7.0)", ] [project.urls] Homepage = "https://majordom.io" Documentation = "https://docs.majordom.io/device-integration" Repository = "https://github.com/MajorDom-Systems/integration-matter" Issues = "https://github.com/MajorDom-Systems/integration-matter/issues" "Commercial licensing" = "https://parker-industries.org/partnership" [dependency-groups] dev = [ "poethepoet (>=0.36.0,<1.0.0)", "ty", "ruff", "pip-audit", "pytest", "pytest-asyncio", "pytest-cov", "pytest-timeout", "pytest-repeat", "pytest-benchmark", "pytest-profiling", "pytest-resource-usage", "coverage", "pytest-rerunfailures", "pympler", "psutil", "faker", ] [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" [tool.poetry] requires-poetry = ">=2.0" packages = [{ include = "majordom_matter" }] [tool.pytest.ini_options] asyncio_mode = "auto" pythonpath = ["."] filterwarnings = ["once"] markers = ["flaky: retried via pytest-rerunfailures (matter coverage tests are flaky under emulation load)"] [tool.coverage.run] omit = ["tests/*"] [tool.ruff] line-length = 120 indent-width = 4 [tool.ruff.lint] select = ["E", "F", "UP", "B", "SIM", "I"] fixable = ["ALL"] [tool.ruff.format] quote-style = "double" indent-style = "space" line-ending = "auto" docstring-code-format = true [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] "tests/*" = ["E402"] [tool.poe.tasks.install] shell = """ poetry install echo '#!/bin/sh\npoetry run poe check' > .git/hooks/pre-commit chmod +x .git/hooks/pre-commit echo "Pre-commit hook installed." """ [tool.poe.tasks.check] shell = """ code=0 echo "\nRunning ruff lint:" poetry run ruff check --fix || code=1 echo "\nRunning ruff format:" poetry run ruff format || code=1 echo "\nRunning ty:" poetry run ty check || code=1 echo "\nRunning pytest:" poetry run pytest --cov=majordom_matter --cov-report=xml || code=1 echo "\nRunning poetry build:" poetry build || code=1 echo "\nRunning poetry check:" poetry check || code=1 ${ci:+git diff --exit-code} exit $code """ args = [{ name = "ci", type = "boolean" }] ================================================================================ # FILE: scripts/check_matter_ha_drift.py ================================================================================ #!/usr/bin/env python3 """CI drift check for the vendored matter-HA harvest — the Dependabot-style refresher. Re-runs the AST harvest against home-assistant/core and diffs vs the committed ``matter_spec_ha.py`` via the SDK's diff_specs. Exit 0 (none) / 1 (ADD/REMOVE) / 2 (RECLASSIFY). Run: python scripts/check_matter_ha_drift.py --ref dev """ from __future__ import annotations import argparse import sys from majordom_integration_sdk.spec_drift import diff_specs from majordom_matter.matter_spec_ha import MATTER_HA_ATTRIBUTE_UX as COMMITTED from scripts.harvest_matter_ha import harvest def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--ref", default="dev") args = ap.parse_args() current, _skipped = harvest(args.ref) report = diff_specs(current, COMMITTED) print(report.render(source="matter-ha"), file=sys.stderr) if report.is_empty: return 0 return 2 if report.has_high_risk else 1 if __name__ == "__main__": raise SystemExit(main()) ================================================================================ # FILE: scripts/fetch_mvd.sh ================================================================================ #!/usr/bin/env bash # Fetch Google's Matter Virtual Device (MVD) `chef` binaries into tests/mvd/ (gitignored). # # Each binary is connectedhomeip's `chef` example app compiled per device type, shipped inside # Google's MVD package (https://developers.home.google.com/tools/virtual-device) as a GPG-signed # Debian archive. A .deb is a plain `ar` archive, so we extract without installing (portable — # works on macOS/CI without dpkg). Only an amd64 Linux build exists upstream (no arm64 Linux), # which is why the matter suite runs in an x86-64 container (Rosetta locally, native on CI). set -euo pipefail # The lowest version this integration is known to support. Probing for "latest" starts here, and # it is the default when no version is requested (reproducible PR/push runs pin to a known build). MVD_FLOOR="${MVD_FLOOR:-1.7.0}" MVD_VERSION="${MVD_VERSION:-$MVD_FLOOR}" BASE_URL="${MVD_BASE_URL:-https://dl.google.com/mvd}" GOOGLE_KEY_URL="${GOOGLE_KEY_URL:-https://dl.google.com/linux/linux_signing_key.pub}" # Google Inc. (Linux Packages Signing Authority) — the key the MVD .deb is signed with. EXPECTED_KEY_FPR="${MVD_KEY_FPR:-EB4C1BFD4F042F6DDDCCEC917721F63BD38B4796}" OUT="$(cd "$(dirname "$0")/.." && pwd)/tests/mvd" # Resolve MVD_VERSION=latest by probing the CDN. Google publishes no directory listing, `latest` # alias, or manifest — but every version lives at a deterministic `mvd__amd64.deb` URL # that returns 200 (exists) or 404. So walk major, then minor, then patch upward from the floor, # taking the highest that still exists. Bounded so a gap never loops forever. This is what lets # the monthly canary discover a NEW upstream release; the GPG check below still gates integrity. _exists() { [ "$(curl -s -o /dev/null -w '%{http_code}' "${BASE_URL}/mvd_${1}_amd64.deb")" = "200" ]; } if [ "$MVD_VERSION" = "latest" ]; then IFS=. read -r M m p <<<"$MVD_FLOOR" for _ in $(seq 1 5); do _exists "$((M+1)).0.0" && { M=$((M+1)); m=0; p=0; } || break; done for _ in $(seq 1 20); do _exists "${M}.$((m+1)).0" && { m=$((m+1)); p=0; } || break; done for _ in $(seq 1 50); do _exists "${M}.${m}.$((p+1))" && p=$((p+1)) || break; done MVD_VERSION="${M}.${m}.${p}" echo "Resolved latest MVD version: ${MVD_VERSION} (floor ${MVD_FLOOR})" fi DEB="mvd_${MVD_VERSION}_amd64.deb" work="$(mktemp -d)"; trap 'rm -rf "$work"' EXIT; cd "$work" echo "Downloading ${DEB} (+ signature)…" curl -fsSLO "${BASE_URL}/${DEB}" curl -fsSLO "${BASE_URL}/${DEB}.asc" echo "Verifying GPG signature against Google's Linux package key…" export GNUPGHOME="$work/gnupg"; mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME" curl -fsSL "$GOOGLE_KEY_URL" | gpg --import 2>/dev/null gpg --list-keys --with-colons | grep -q "$EXPECTED_KEY_FPR" \ || { echo "ERROR: imported key does not match the pinned fingerprint ${EXPECTED_KEY_FPR}"; exit 1; } gpg --verify "${DEB}.asc" "${DEB}" # hard-fails on a bad/absent signature echo "Extracting (ar + tar — no dpkg needed)…" ar x "${DEB}" mkdir -p data && tar -xf data.tar.* -C data # The per-device chef binaries live under the Electron app's prebuilt tree, named # `rootnode__`. Map them to clean, hash-free names; device types that ship # twice (on-off-light, generic-switch) get a `-2` on the second. A device type we don't have a # nice name for is NOT an error — the integration maps device types flexibly, so we auto-name it # from its prefix and still ship it, letting the test sweep commission it like any other. That is # the point of the canary: a brand-new upstream device gets exercised automatically, and only a # genuine mapping/commissioning failure (surfaced by the tests) flags it for a human. python3 - "$OUT" <<'PY' import os, re, sys, shutil, pathlib out = pathlib.Path(sys.argv[1]); out.mkdir(parents=True, exist_ok=True) src = pathlib.Path("data/usr/lib/mvd/resources/app/prebuilt/linux_x64") NAME = { "airpurifier": "air-purifier", "airqualitysensor": "air-quality-sensor", "basicvideoplayer": "basic-video-player", "colortemperaturelight": "color-temperature-light", "contactsensor": "contact-sensor", "dimmablelight": "dimmable-light", "dimmablepluginunit": "dimmable-plug", "dishwasher": "dishwasher", "doorlock": "door-lock", "extendedcolorlight": "extended-color-light", "fan": "fan", "flowsensor": "flow-sensor", "genericswitch": "generic-switch", "humiditysensor": "humidity-sensor", "laundrywasher": "laundry-washer", "lightsensor": "light-sensor", "occupancysensor": "occupancy-sensor", "onofflight": "on-off-light", "onoffpluginunit": "on-off-plug", "pressuresensor": "pressure-sensor", "pump": "pump", "refrigerator_temperaturecontrolledcabinet_temperaturecontrolledcabinet": "refrigerator", "roboticvacuumcleaner": "robotic-vacuum", "roomairconditioner": "room-air-conditioner", "smokecoalarm": "smoke-co-alarm", "temperaturesensor": "temperature-sensor", "thermostat": "thermostat", "windowcovering": "window-covering", } # group by device-type prefix (strip the trailing _), sorted for deterministic -2 order groups: dict[str, list[str]] = {} for f in sorted(p.name for p in src.glob("rootnode_*")): prefix = re.sub(r"^rootnode_", "", f) prefix = re.sub(r"_[^_]+$", "", prefix) # drop the hash segment groups.setdefault(prefix, []).append(f) unknown = sorted(set(groups) - set(NAME)) if unknown: # Not fatal — auto-name and ship them so the flexible mapping is still exercised. Consider # adding a nice name to NAME above once the type is known-good. print(f"Note: auto-naming {len(unknown)} unmapped device type(s): {unknown}") n = 0 for prefix, files in groups.items(): base = NAME.get(prefix, prefix.replace("_", "-")) for i, f in enumerate(files): target = out / (base if i == 0 else f"{base}-{i+1}") shutil.copy(src / f, target) os.chmod(target, 0o755) n += 1 print(f"Extracted {n} MVD binaries -> {out}") PY count="$(find "$OUT" -type f | wc -l | tr -d ' ')" echo "Done: ${count} MVD binaries (MVD ${MVD_VERSION})" ================================================================================ # FILE: scripts/fetch_paa_certs.py ================================================================================ #!/usr/bin/env python3 """Fetch Matter PAA (Product Attestation Authority) root certificates. Uses python-matter-server's own fetcher — DCL production + test ledgers and the connectedhomeip git mirror — the same sources that produced the certificate store the matter tests run against (including the Chip-Test development PAA the MVD chef devices commission against). python scripts/fetch_paa_certs.py [dest_dir] # default: matter_data/credentials The DCL occasionally serves a single malformed certificate; upstream's fetcher aborts the whole source when one fails to parse. We wrap the per-cert writer to skip a malformed cert instead, so every source completes fully. """ import asyncio import sys from pathlib import Path import matter_server.server.helpers.paa_certificates as paa MIN_CERTS = 30 _orig_write = paa.write_paa_root_cert async def _resilient_write(*args, **kwargs): try: return await _orig_write(*args, **kwargs) except Exception as err: # noqa: BLE001 — skip a malformed cert, keep fetching the rest print(f" (skipping malformed certificate: {type(err).__name__})") return False paa.write_paa_root_cert = _resilient_write # ty: ignore[invalid-assignment] # monkeypatch, module global async def main() -> None: dest = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("matter_data/credentials") dest.mkdir(parents=True, exist_ok=True) (dest / ".version").unlink(missing_ok=True) # force a full fetch total = await paa.fetch_certificates(dest) count = len(list(dest.glob("*.der"))) + len(list(dest.glob("*.pem"))) if count < MIN_CERTS: sys.exit(f"Only {count} PAA certs fetched (< {MIN_CERTS}) — treating as a failed fetch") print(f"PAA certificate store ready: {count} certs (fetcher counted {total}) -> {dest}") if __name__ == "__main__": asyncio.run(main()) ================================================================================ # FILE: scripts/harvest_matter_ha.py ================================================================================ #!/usr/bin/env python3 """Harvest Home Assistant's Matter entity judgment into ``majordom_matter/matter_spec_ha.py``. DEV / BUILD TOOL. Unlike zha, HA's Matter discovery is **not** a standalone library — it lives in `homeassistant.components.matter`. So instead of importing it (which would drag in all of HA core), this downloads only the handful of platform files and parses them with **AST** (no execution, no `homeassistant` install). Attribute references (``clusters.X.Attributes.Y``) are resolved to ``(cluster_id, attribute_id)`` via the ``chip`` bindings we already depend on. What it takes: the genuinely-additive judgment (``entity_category`` -> visibility, platform -> role, unit/device_class -> unit). The Matter Data Model already gives us names/types/bounds via ``chip``, so those are dropped. Value-transform lambdas (``device_to_ha``) can't be read statically and are logged as skipped. Usage: python scripts/harvest_matter_ha.py # writes the module (ref=dev) python scripts/harvest_matter_ha.py --ref 2025.1.0 # pin a HA release tag python scripts/harvest_matter_ha.py --stdout """ from __future__ import annotations import argparse import ast import sys import urllib.request import chip.clusters as clusters _PLATFORM_FILES = ( "sensor", "binary_sensor", "switch", "number", "select", "climate", "cover", "fan", "light", "lock", "button", "event", ) _RAW = "https://raw.githubusercontent.com/home-assistant/core/{ref}/homeassistant/components/matter/{f}.py" # HA unit-enum leaf / device_class leaf -> our ParameterUnit value. Unmapped -> "plain". _UNIT_BY_LEAF = { "CELSIUS": "celsius", "PERCENTAGE": "percentage", "KELVIN": "kelvin", "WATT": "watt", "VOLT": "volt", "AMPERE": "ampere", "KILO_WATT_HOUR": "kwh", "HERTZ": "hertz", "LUX": "lux", "PASCAL": "pascal", "KILO_PASCAL": "pascal", "HECTOPASCAL": "pascal", "PARTS_PER_MILLION": "ppm", "MICROGRAMS_PER_CUBIC_METER": "ugm3", } _UNIT_BY_DEVICE_CLASS = { "TEMPERATURE": "celsius", "HUMIDITY": "percentage", "BATTERY": "percentage", "ILLUMINANCE": "lux", "POWER": "watt", "VOLTAGE": "volt", "CURRENT": "ampere", "ENERGY": "kwh", "PRESSURE": "pascal", "FREQUENCY": "hertz", } _CONTROL_PLATFORMS = {"switch", "number", "select", "climate", "cover", "fan", "light", "lock", "button"} def _leaf(node: ast.expr | None) -> str | None: """Leaf name of a node: ``Platform.SENSOR`` -> 'SENSOR', bare ``PERCENTAGE`` -> 'PERCENTAGE'.""" if isinstance(node, ast.Attribute): return node.attr if isinstance(node, ast.Name): return node.id if isinstance(node, ast.Constant) and isinstance(node.value, str): return node.value return None def _dotted(node: ast.expr) -> list[str] | None: """Flatten ``clusters.X.Attributes.Y`` (an Attribute chain) into ['clusters','X','Attributes','Y'].""" parts: list[str] = [] cur: ast.expr = node while isinstance(cur, ast.Attribute): parts.append(cur.attr) cur = cur.value if isinstance(cur, ast.Name): parts.append(cur.id) return list(reversed(parts)) return None def _resolve_attr(dotted: list[str]) -> tuple[int, int] | None: """['clusters','TemperatureMeasurement','Attributes','MeasuredValue'] -> (cluster_id, attr_id).""" if not dotted or dotted[0] != "clusters": return None obj: object = clusters for part in dotted[1:]: obj = getattr(obj, part, None) if obj is None: return None cid, aid = getattr(obj, "cluster_id", None), getattr(obj, "attribute_id", None) return (cid, aid) if cid is not None and aid is not None else None def _kwargs(call: ast.Call) -> dict[str, ast.expr]: return {kw.arg: kw.value for kw in call.keywords if kw.arg} def harvest(ref: str) -> tuple[dict[tuple[int, int], tuple[str, str, str]], list[str]]: out: dict[tuple[int, int], tuple[str, str, str]] = {} skipped: list[str] = [] for f in _PLATFORM_FILES: try: src = urllib.request.urlopen(_RAW.format(ref=ref, f=f), timeout=30).read().decode() except Exception as e: # noqa: BLE001 - a missing platform file is not fatal skipped.append(f"{f}.py: fetch failed ({e})") continue tree = ast.parse(src) for call in ast.walk(tree): if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Name)): continue if call.func.id != "MatterDiscoverySchema": continue kw = _kwargs(call) platform = (_leaf(kw.get("platform")) or "").lower() req = kw.get("required_attributes") if not isinstance(req, ast.Tuple) or not req.elts: skipped.append(f"{f}.py: schema with no required_attributes") continue key = _resolve_attr(_dotted(req.elts[0]) or []) if key is None: skipped.append(f"{f}.py: unresolved {ast.dump(req.elts[0])[:60]}") continue desc = kw.get("entity_description") dkw = _kwargs(desc) if isinstance(desc, ast.Call) else {} entity_category = _leaf(dkw.get("entity_category")) visibility = "user" if entity_category is None else "setting" role = "control" if platform in _CONTROL_PLATFORMS else "sensor" unit = _unit(_leaf(dkw.get("device_class")), _leaf(dkw.get("native_unit_of_measurement"))) # First writer wins; but a user (primary) classification upgrades a prior setting. prev = out.get(key) if prev is None or (prev[0] == "setting" and visibility == "user"): out[key] = (visibility, role, unit) return out, skipped def _unit(device_class: str | None, unit_leaf: str | None) -> str: if unit_leaf and unit_leaf in _UNIT_BY_LEAF: return _UNIT_BY_LEAF[unit_leaf] if device_class and device_class in _UNIT_BY_DEVICE_CLASS: return _UNIT_BY_DEVICE_CLASS[device_class] return "plain" def render(data: dict[tuple[int, int], tuple[str, str, str]], ref: str) -> str: lines = [ '"""GENERATED by scripts/harvest_matter_ha.py — DO NOT EDIT BY HAND.', "", f"Source: home-assistant/core@{ref} homeassistant/components/matter (Apache-2.0), parsed via", "AST (no homeassistant dependency). Attribute ids resolved via chip. Harvested entity", "judgment only (entity_category -> visibility, platform -> role, unit); the Matter Data Model", "supplies names/types/bounds. Hand overrides live in matter_spec.py and win on merge.", '"""', "", "# (cluster_id, attribute_id) -> (visibility, role, unit)", "MATTER_HA_ATTRIBUTE_UX: dict[tuple[int, int], tuple[str, str, str]] = {", ] for (cid, aid), (vis, role, unit) in sorted(data.items()): lines.append(f" ({cid:#06x}, {aid:#06x}): ({vis!r}, {role!r}, {unit!r}),") lines.append("}") return "\n".join(lines) + "\n" def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--ref", default="dev", help="home-assistant/core git ref (tag/branch)") ap.add_argument("--stdout", action="store_true") ap.add_argument("--out", default="majordom_matter/matter_spec_ha.py") args = ap.parse_args() data, skipped = harvest(args.ref) text = render(data, args.ref) if args.stdout: sys.stdout.write(text) else: with open(args.out, "w") as fh: fh.write(text) print(f"[harvest_matter_ha] ref={args.ref}: wrote {len(data)} entries -> {args.out}", file=sys.stderr) print(f"[harvest_matter_ha] {len(skipped)} schemas skipped (composite/transform/unresolved)", file=sys.stderr) return 0 if __name__ == "__main__": raise SystemExit(main()) ================================================================================ # FILE: scripts/param_ux_audit.py ================================================================================ """Run majordom's MATTER visibility mapping across the full chip data model (all clusters' attributes), replicating mapper.parse_attributes, and bucket like the iOS app: user/setting/system.""" import inspect from typing import cast import chip.clusters.Objects as M from chip.clusters.CHIPClusters import ChipClusters from chip.clusters.ClusterObjects import ClusterAttributeDescriptor from majordom_matter.matter_spec import ( EVERYDAY_CONTROL_ATTRIBUTES, MAIN_PARAMETER_BY_CLUSTER, SENSITIVE_ATTRIBUTE_NAME_PREFIXES, SYSTEM_ATTRIBUTES, SYSTEM_CLUSTERS, USER_READINGS, AttributeKey, ) CI = ChipClusters(None) def writable(cid, aid): try: return bool(CI.GetClusterInfoById(cid).get("attributes", {}).get(aid, {}).get("writable")) except Exception: return False clusters = [ c for _, c in inspect.getmembers(M, inspect.isclass) if hasattr(c, "id") and hasattr(c, "Attributes") and isinstance(c.id, int) ] clusters = sorted({c.id: c for c in clusters}.items()) tot = {"user": 0, "setting": 0, "system": 0} rows = [] for cid, cls in clusters: cid = cast("int", cid) # guaranteed by the isinstance guard above; inspect types it object is_sys = cid in SYSTEM_CLUSTERS b = {"user": [], "setting": [], "system": []} for name, attr in inspect.getmembers(cls.Attributes, inspect.isclass): if not issubclass(attr, ClusterAttributeDescriptor): continue aid = getattr(attr, "attribute_id", -1) if aid in SYSTEM_ATTRIBUTES: continue key = AttributeKey(cid, aid) if is_sys or name.startswith(SENSITIVE_ATTRIBUTE_NAME_PREFIXES): vis = "system" elif writable(cid, aid): vis = "user" if key in EVERYDAY_CONTROL_ATTRIBUTES else "setting" elif key in USER_READINGS: vis = "user" else: vis = "system" # read-only, uncurated -> hidden (inverted default) b[vis].append(name) for k in tot: tot[k] += len(b[k]) rows.append((cid, cls.__name__, is_sys, cid in MAIN_PARAMETER_BY_CLUSTER, b)) print(f"=== MATTER full-datamodel mapping: {len(rows)} clusters ===") print(f"total attr-params by bucket: user={tot['user']} setting={tot['setting']} system={tot['system']}") print(f"clusters providing a MAIN param: {sorted(hex(c) for c in MAIN_PARAMETER_BY_CLUSTER)}\n") SPOT = { 0x6: "OnOff", 0x8: "LevelControl", 0x300: "ColorControl", 0x201: "Thermostat", 0x202: "FanControl", 0x102: "WindowCovering", 0x101: "DoorLock", 0x402: "TempMeas", 0x405: "Humidity", 0x2F: "PowerSource", 0x50: "ModeSelect", } for cid, name, _is_sys, has_main, b in rows: if cid in SPOT: main = " [MAIN]" if has_main else "" print( f"--- 0x{cid:04X} {name}{main} — " f"user={len(b['user'])} setting={len(b['setting'])} system={len(b['system'])} ---" ) if b["user"]: print(f" user: {', '.join(b['user'][:16])}{' …' if len(b['user']) > 16 else ''}") # biggest user buckets (over-exposure hotspots) print("\n=== TOP over-exposed 'user' clusters (most read-only attrs shown to user) ===") for cid, name, _is_sys, _has_main, b in sorted(rows, key=lambda r: -len(r[4]["user"]))[:10]: print(f" 0x{cid:04X} {name}: user={len(b['user'])}") ================================================================================ # FILE: tests/conftest.py ================================================================================ """Unit tests for the Matter controller. Drive `MatterController` directly against a live python-matter-server and real MVD `chef` virtual devices — no Hub. The MVD binaries are Linux x86-64, so this runs in the test container (see docker/); on Apple Silicon via Rosetta. The Hub keeps the e2e and real-hardware coverage. """ import asyncio import contextlib import os import signal import subprocess import tempfile from pathlib import Path import pytest import pytest_asyncio from aiohttp import ClientSession from majordom_integration_sdk.controller import AbstractController from majordom_integration_sdk.repository import DeviceRepositoryMemory from majordom_integration_sdk.testing import ( FakeBLEDiscoveryService, FakeSSDPDiscoveryService, FakeZeroconfDiscoveryService, RecordingControllerOutput, ) from matter_server.client import MatterClient from majordom_matter import MatterController from majordom_matter.config import matter_server_url BIN_DIR = Path(__file__).parent / "mvd" _MVD_LIVENESS_CHECK_S = 0.3 async def _start_mvd(device_type: str = "on-off-light") -> subprocess.Popen: stderr_file = tempfile.TemporaryFile() # noqa: SIM115 (handed to Popen; outlives this fn) proc = subprocess.Popen( [ str(BIN_DIR / device_type), "--discriminator", "3840", "--passcode", "20202021", "--capabilities", "4", "--interface-id", "eth0", ], stdout=subprocess.DEVNULL, stderr=stderr_file, preexec_fn=os.setsid, ) await asyncio.sleep(_MVD_LIVENESS_CHECK_S) if proc.poll() is not None: stderr_file.seek(0) pytest.fail( f"MVD '{device_type}' exited immediately (code {proc.returncode}): " f"{stderr_file.read().decode(errors='replace')}" ) return proc def _kill_mvd(proc: subprocess.Popen) -> None: try: os.killpg(os.getpgid(proc.pid), signal.SIGTERM) except ProcessLookupError: return try: proc.wait(timeout=2) except subprocess.TimeoutExpired: try: os.killpg(os.getpgid(proc.pid), signal.SIGKILL) proc.wait(timeout=5) except (ProcessLookupError, subprocess.TimeoutExpired): pass async def _stop_mvd(proc: subprocess.Popen) -> None: await asyncio.get_running_loop().run_in_executor(None, _kill_mvd, proc) async def _unpair_all() -> None: """Remove every node from matter-server so each test starts from a clean fabric.""" session = ClientSession() client = MatterClient(matter_server_url, session) try: await client.connect() event = asyncio.Event() task = asyncio.create_task(client.start_listening(init_ready=event)) await event.wait() for node in list(client.get_nodes()): with contextlib.suppress(Exception): await client.remove_node(node.node_id) task.cancel() finally: await client.disconnect() await session.close() @pytest_asyncio.fixture async def mvd(request): """A running virtual device on the Matter network. Defaults to on-off-light; indirectly parametrize (`@pytest.mark.parametrize("mvd", [...], indirect=True)`) to sweep other device types — see test_all_devices.py. """ device_type = getattr(request, "param", "on-off-light") await _unpair_all() proc = await _start_mvd(device_type) yield proc await _unpair_all() await _stop_mvd(proc) @pytest_asyncio.fixture async def matter(mvd): """Started MatterController (connected to matter-server) + recording output + repository.""" repository = DeviceRepositoryMemory(integration="Matter") output = RecordingControllerOutput() deps = AbstractController.Dependencies( output=output, make_device_repository=repository.session, documents_folder=Path(tempfile.mkdtemp()), zeroconf_discovery_service=FakeZeroconfDiscoveryService(), ssdp_discovery_service=FakeSSDPDiscoveryService(), ble_discovery_service=FakeBLEDiscoveryService(), ) controller = MatterController(deps) await controller.start() yield controller, output, repository, mvd await controller.stop() ================================================================================ # FILE: tests/test_controller.py ================================================================================ """Unit tests driving MatterController against a live matter-server + real MVD devices. Skipped unless MATTER_INTEGRATION_TESTS=1 (set in docker/); they need a running matter-server and the x86-64 MVD binaries, so they don't run on a bare dev machine. """ import asyncio import os import pathlib import pytest pytestmark = pytest.mark.skipif( os.environ.get("MATTER_INTEGRATION_TESTS") != "1", reason="needs matter-server + x86-64 MVD binaries — run via docker/ (plan §3.5)", ) from uuid import UUID from majordom_integration_sdk.schemas.device import CredentialsType, ProvidedCredentials async def _wait_for(predicate, timeout: float | None = None): timeout = timeout or float(os.environ.get("MATTER_DISCOVERY_TIMEOUT_S", "12")) async with asyncio.timeout(timeout): while not predicate(): await asyncio.sleep(0.1) async def test_discovers_a_matter_device(matter): controller, output, _repo, _mvd = matter await _wait_for(lambda: bool(output.received_discoveries)) discovery = output.received_discoveries[-1] assert discovery.integration == "Matter" assert discovery.transport == "IP" async def _provisional(repository, discovery): from majordom_matter.model import MatterDeviceIntegrationData, MatterDeviceState # node_id gets its real value during commissioning; the Hub seeds the row first. async with repository.session() as repo: await repo.save( MatterDeviceState( id=discovery.id, name="Test Device", room_id=UUID(int=1), transport="IP", integration="Matter", manufacturer=None, parameters=[], integration_data=MatterDeviceIntegrationData(node_id=0), ) ) async def test_pairs_a_discovered_device(matter): from majordom_matter.model import MatterDevice controller, output, repository, _mvd = matter await _wait_for(lambda: bool(controller.discoveries)) discovery = next(iter(controller.discoveries.values())) await _provisional(repository, discovery) await controller.pair_device(discovery, ProvidedCredentials(type=CredentialsType.code, value="20202021")) # pairing renames the row from the provisional discovery id to the real device id (derived # from the commissioned node), and reports the connect under that id. assert output.connected_devices, "controller should report the connect" device_id = output.connected_devices[-1] async with repository.session() as repo: device = await repo.get(device_id, as_=MatterDevice) state = await repo.state(device_id) assert device is not None and device.integration_data.node_id assert state is not None and len(state.parameters) > 0 async def test_unpairs_a_device(matter): from majordom_matter.model import MatterDevice controller, output, repository, _mvd = matter await _wait_for(lambda: bool(controller.discoveries)) discovery = next(iter(controller.discoveries.values())) await _provisional(repository, discovery) await controller.pair_device(discovery, ProvidedCredentials(type=CredentialsType.code, value="20202021")) device_id = output.connected_devices[-1] async with repository.session() as repo: device = await repo.get(device_id, as_=MatterDevice) assert device is not None await controller.unpair(device) # removes the node from the fabric without error async def _pair(controller, output, repository): from majordom_matter.model import MatterDevice await _wait_for(lambda: bool(controller.discoveries)) discovery = next(iter(controller.discoveries.values())) await _provisional(repository, discovery) await controller.pair_device(discovery, ProvidedCredentials(type=CredentialsType.code, value="20202021")) device_id = output.connected_devices[-1] async with repository.session() as repo: return await repo.get(device_id, as_=MatterDevice) async def test_fetch_emits_events(matter): controller, output, repository, _mvd = matter device = await _pair(controller, output, repository) before = len(output.events) await controller.fetch(device) assert len(output.events) > before, "fetch should re-read the device and emit parameter-change events" async def test_identify_runs_against_the_device(matter): controller, output, repository, _mvd = matter device = await _pair(controller, output, repository) # The on-off-light carries the Identify cluster; identify() should complete without error. await controller.identify(device) def _all_device_types() -> list[str]: """Every MVD binary fetch_mvd.sh dropped into tests/mvd — the full upstream device set.""" d = pathlib.Path(__file__).parent / "mvd" return sorted(p.name for p in d.iterdir() if p.is_file()) if d.is_dir() else [] @pytest.mark.parametrize("mvd", _all_device_types(), indirect=True) async def test_commissions_every_device_type(matter): """Commission every MVD device the canary fetched — known types and any brand-new one Google ships — so the integration's flexible mapping is exercised across the full set automatically. A new binary added by fetch_mvd.sh is covered here with no code change; the canary goes red only on a genuine commissioning/mapping failure, which is the signal to go look. (See §3.5.)""" from majordom_matter.model import MatterDevice controller, output, repository, _mvd = matter await _wait_for(lambda: bool(controller.discoveries)) discovery = next(iter(controller.discoveries.values())) await _provisional(repository, discovery) await controller.pair_device(discovery, ProvidedCredentials(type=CredentialsType.code, value="20202021")) assert output.connected_devices, "controller should report the connect" device_id = output.connected_devices[-1] async with repository.session() as repo: device = await repo.get(device_id, as_=MatterDevice) state = await repo.state(device_id) assert device is not None and device.integration_data.node_id, "device should commission" assert state is not None and len(state.parameters) > 0, "mapping should yield parameters" await controller.unpair(device) ================================================================================ # FILE: tests/test_controller_stub.py ================================================================================ """Controller wiring tests against the in-process FakeMatterClient — no matter-server, no MVD. These run in the default (non-docker) CI. They exercise discovery, pairing + parameter mapping, commands, and attribute events through the REAL controller and mapper against a faithful canned on-off-light node. The heavy real-device coverage (all 30 device types, real commissioning) lives in the dockerized MVD suite, test_controller.py. """ import asyncio import tempfile from pathlib import Path from uuid import UUID import pytest_asyncio from majordom_integration_sdk.schemas.command import DeviceCommand from majordom_integration_sdk.schemas.device import CredentialsType, ProvidedCredentials from majordom_integration_sdk.testing import build_test_dependencies from majordom_matter import MatterController from majordom_matter.model import MatterDevice, MatterDeviceIntegrationData, MatterDeviceState, MatterParameterTypeEnum from majordom_matter.testing import FakeMatterClient FAKE_NODE_ID = 46 # matches fixtures/on_off_light_node.json @pytest_asyncio.fixture async def controller(monkeypatch): """A started MatterController whose matter-server client is the in-process fake.""" monkeypatch.setattr("majordom_matter.controller.MatterClient", FakeMatterClient) deps = build_test_dependencies(documents_folder=Path(tempfile.mkdtemp()), integration="Matter") controller = MatterController(deps) await controller.start() yield controller, deps.output await controller.stop() async def _wait(predicate, timeout: float = 5.0): async with asyncio.timeout(timeout): while not predicate(): await asyncio.sleep(0.02) async def _commission(controller, repository) -> UUID: """Seed the provisional row the Hub would create, then pair — returns the real device id.""" await _wait(lambda: bool(controller.discoveries)) discovery = next(iter(controller.discoveries.values())) async with repository() as repo: await repo.save( MatterDeviceState( id=discovery.id, name="Test", room_id=UUID(int=1), transport="IP", integration="Matter", manufacturer=None, parameters=[], integration_data=MatterDeviceIntegrationData(node_id=0), ) ) await controller.pair_device(discovery, ProvidedCredentials(type=CredentialsType.code, value="20202021")) return controller.dependencies.output.connected_devices[-1] async def test_discovers_the_canned_node(controller): ctrl, _output = controller await _wait(lambda: bool(ctrl.discoveries)) discovery = next(iter(ctrl.discoveries.values())) assert discovery.integration == "Matter" assert discovery.transport == "IP" assert CredentialsType.code in discovery.expected_credentials_options async def test_pairs_and_maps_parameters(controller): ctrl, output = controller device_id = await _commission(ctrl, ctrl.dependencies.make_device_repository) assert output.connected_devices == [device_id] async with ctrl.dependencies.make_device_repository() as repo: device = await repo.get(device_id, as_=MatterDevice) state = await repo.state(device_id) assert device is not None and device.integration_data.node_id == FAKE_NODE_ID # The on-off-light exposes the OnOff cluster (endpoint 13) → at least a toggle command maps. assert state is not None and len(state.parameters) > 0 async def test_send_command_reaches_the_client(controller): ctrl, _output = controller device_id = await _commission(ctrl, ctrl.dependencies.make_device_repository) async with ctrl.dependencies.make_device_repository() as repo: device = await repo.get(device_id, as_=MatterDevice) state = await repo.state(device_id, MatterDeviceState) command_param = next(p for p in state.parameters if p.integration_data.type is MatterParameterTypeEnum.command) await ctrl.send_command( DeviceCommand(device_id=device_id, parameter_id=command_param.id, value=None), device, command_param ) # The fake records the outgoing command against the node's endpoint. assert ctrl._matter_client.sent_commands, "send_command should reach the matter client" node_id, endpoint_id, _cmd = ctrl._matter_client.sent_commands[-1] assert node_id == FAKE_NODE_ID and endpoint_id == command_param.integration_data.endpoint_id async def test_attribute_update_becomes_an_event(controller): ctrl, output = controller device_id = await _commission(ctrl, ctrl.dependencies.make_device_repository) before = len(output.events) # Simulate the device pushing a new OnOff value; the subscription should surface an event. ctrl._matter_client.fire_attribute_update("13/6/0", True) await _wait(lambda: len(output.events) > before) event = output.events[-1] assert event.device_id == device_id async def test_unpair_removes_the_node(controller): ctrl, _output = controller device_id = await _commission(ctrl, ctrl.dependencies.make_device_repository) async with ctrl.dependencies.make_device_repository() as repo: device = await repo.get(device_id, as_=MatterDevice) await ctrl.unpair(device) assert FAKE_NODE_ID in ctrl._matter_client.removed_nodes async def test_fetch_emits_events(controller): ctrl, output = controller device_id = await _commission(ctrl, ctrl.dependencies.make_device_repository) async with ctrl.dependencies.make_device_repository() as repo: device = await repo.get(device_id, as_=MatterDevice) before = len(output.events) await ctrl.fetch(device) assert len(output.events) > before, "fetch should emit a DeviceParameterChange per attribute" async def test_identify_sends_identify_command(controller): from chip.clusters.Objects import Identify ctrl, _output = controller device_id = await _commission(ctrl, ctrl.dependencies.make_device_repository) async with ctrl.dependencies.make_device_repository() as repo: device = await repo.get(device_id, as_=MatterDevice) await ctrl.identify(device) # The on-off-light carries the Identify cluster (3) on its application endpoint. assert any(isinstance(cmd, Identify.Commands.Identify) for _n, _ep, cmd in ctrl._matter_client.sent_commands), ( "identify should send an Identify command to the node's Identify cluster" ) async def test_start_populates_discoveries_and_stop_clears(controller): # start() ran in the fixture and should have surfaced the canned node as a discovery; # stop() must clear that state (background tasks + discoveries) without error. ctrl, _output = controller await _wait(lambda: bool(ctrl.discoveries)) assert ctrl.discoveries, "start() should surface the discovered node" await ctrl.stop() assert ctrl.discoveries == {}, "stop() should clear discoveries" ================================================================================ # FILE: tests/test_param_ux.py ================================================================================ """Light probes for the parameter-UX mapping — exercise the code paths (visibility curation, metadata resolver) without re-hardcoding every device's expected parameters (that stays a manual review; see the audit script + the docs recipe).""" from uuid import uuid4 from majordom_matter.mapper import MatterMapper from majordom_matter.matter_spec import ( EVERYDAY_CONTROL_ATTRIBUTES, METADATA_SOURCES, USER_READINGS, AttributeKey, ) def _mapper() -> MatterMapper: return MatterMapper(lambda s: uuid4(), lambda d, s: uuid4()) class _FakeEndpoint: def __init__(self, values): self._values = values def get_attribute_value(self, cluster_id, attribute_id): return self._values.get((cluster_id, attribute_id)) def test_runtime_bounds_prefer_device_limit_attributes(): # priority 1: CurrentLevel's min/max come from the device's own Min/MaxLevel attrs ep = _FakeEndpoint({(0x008, 0x02): 5, (0x008, 0x03): 200}) lo, hi = _mapper().resolve_runtime_bounds(AttributeKey(0x008, 0x00), 0x008, ep, "CurrentLevel", 0, 254) assert (lo, hi) == (5, 200) def test_runtime_bounds_fall_back_to_defaults_when_device_silent(): ep = _FakeEndpoint({}) # device reports no limit attrs lo, hi = _mapper().resolve_runtime_bounds(AttributeKey(0x008, 0x00), 0x008, ep, "CurrentLevel", 0, 254) assert (lo, hi) == (0, 254) def test_no_metadata_source_returns_defaults_unchanged(): ep = _FakeEndpoint({(0x008, 0x02): 5}) lo, hi = _mapper().resolve_runtime_bounds(AttributeKey(0x999, 0x99), 0x999, ep, "X", 1, 2) assert (lo, hi) == (1, 2) def test_curation_sets_are_consistent(): # a parameter can't be both an everyday writable control and a read-only user reading assert USER_READINGS.isdisjoint(EVERYDAY_CONTROL_ATTRIBUTES) # every metadata source key is a curated user reading/control (we only resolve bounds for shown params) shown = USER_READINGS | EVERYDAY_CONTROL_ATTRIBUTES assert set(METADATA_SOURCES).issubset(shown) # --- classification ladder probes (see classify_attribute) -------------------------------------- def test_ladder_system_cluster_and_sensitive_forced_hidden(): from majordom_integration_sdk.schemas.parameter import ParameterVisibility from majordom_matter.matter_spec import classify_attribute spec, source = classify_attribute(0x0028, 0x0000, "VendorName", writable=False, in_system_cluster=True) assert source == "system-cluster" and spec.visibility is ParameterVisibility.system spec, source = classify_attribute( 0x0006, 0x0000, "AliroReaderVerificationKey", writable=True, in_system_cluster=False ) assert source == "sensitive" and spec.visibility is ParameterVisibility.system def test_ladder_our_override_beats_ha(): from majordom_matter.matter_spec import OUR_ATTRIBUTE_UX, classify_attribute key = next(iter(OUR_ATTRIBUTE_UX)) spec, source = classify_attribute(key.cluster_id, key.attribute_id, "x", writable=False, in_system_cluster=False) assert source == "ours" def test_ladder_harvested_ha_and_fallback(): from majordom_matter.matter_spec import MATTER_HA_ATTRIBUTE_UX, OUR_ATTRIBUTE_UX, classify_attribute assert len(MATTER_HA_ATTRIBUTE_UX) > 50 # the vendored HA harvest loaded our_keys = {(x.cluster_id, x.attribute_id) for x in OUR_ATTRIBUTE_UX} ha_only = next(k for k in MATTER_HA_ATTRIBUTE_UX if k not in our_keys) _, source = classify_attribute(ha_only[0], ha_only[1], "x", writable=True, in_system_cluster=False) assert source == "ha" # a wholly-uncurated attribute falls to the warning fallback _, source = classify_attribute(0x0ABC, 0x0001, "mystery", writable=True, in_system_cluster=False) assert source.startswith("fallback") ================================================================================ # FILE: tests/test_smoke.py ================================================================================ """Smoke tests — no matter-server or binaries required (run everywhere).""" from majordom_integration_sdk.controller import AbstractController from majordom_matter import MatterController def test_is_an_integration_controller(): assert issubclass(MatterController, AbstractController) def test_integration_identity_is_class_level(): assert MatterController.name == "Matter" assert MatterController.slug() == "matter" # integration-zigbee — full source # repo: https://github.com/MajorDom-Systems/integration-zigbee branch: master commit: 8c75509d0c84d332948d675cdfce1473178e0964 # generated: 2026-07-24 — AUTO-GENERATED, do not edit by hand ## File tree .gitignore LICENSE README.md majordom_zigbee/__init__.py majordom_zigbee/_serial.py majordom_zigbee/controller.py majordom_zigbee/exceptions.py majordom_zigbee/listener.py majordom_zigbee/mapper.py majordom_zigbee/model.py majordom_zigbee/readme.md majordom_zigbee/zigbee_spec.py majordom_zigbee/zigbee_spec_zha.py pyproject.toml scripts/check_zha_drift.py scripts/harvest_zha.py scripts/param_ux_audit.py tests/conftest.py tests/test_controller.py tests/test_param_ux.py ================================================================================ # FILE: .gitignore ================================================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] *$py.class # C extensions *.so # Distribution / packaging build/ dist/ *.egg-info/ *.egg # Unit test / coverage reports .coverage .coverage.* coverage.xml htmlcov/ .pytest_cache/ .hypothesis/ # Environments .env .envrc .venv env/ venv/ # Poetry # poetry.lock # uncomment for applications, keep commented for libraries # Type checkers .mypy_cache/ .dmypy.json .pyre/ .pytype/ # Tools .ruff_cache/ .ropeproject # mkdocs (only relevant if you kept mkdocs.yml + docs/) /site .cache/ # IDEs .idea/ .vscode/ # OS .DS_Store Thumbs.db ================================================================================ # FILE: LICENSE ================================================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================================================ # FILE: README.md ================================================================================ # integration-zigbee A [MajorDom](https://majordom.io) integration — bridges **Zigbee** devices into the MajorDom language. Built for the **MajorDom Hub**, but it doesn't need it: this is a standalone, standardized library for Zigbee that you can use on its own (see **Run it standalone** below). Built on the [MajorDom Integration SDK](https://github.com/MajorDom-Systems/integration-sdk). The entry point is `ZigBeeController` (`majordom_zigbee/controller.py`), which the Hub — or the SDK's dev runner — instantiates and drives through its lifecycle: pairing → commands → teardown. - **Other protocols:** browse the [MajorDom integrations](https://github.com/orgs/MajorDom-Systems/repositories?q=integration-). - **Create your own:** start from the [integration template](https://github.com/MajorDom-Systems/integration-template). ## Documentation Full integration-author docs — the controller lifecycle, data models, storing data, discovery, and a worked example — live at **[docs.majordom.io](https://docs.majordom.io/device-integration)**. ## Development ```sh poetry install && poetry run poe install ``` | Task | Description | |------|-------------| | `poe check` | Full quality pipeline (ruff, ty, pytest, poetry build/check) | | `poe check --ci` | Same, plus `git diff --exit-code` | Work lands on `develop`; `master` is protected and released via **Actions → Release**. Tests drive the controller with the SDK's test doubles against a simulated `zigpy` device — no radio required (see `tests/`). ## Run it standalone (without the Hub) `majordom-zigbee` is a standalone library — import it into your own app, or run **just this integration** interactively (discover, pair, control, and inspect devices from a prompt) with no Hub. It needs a Zigbee coordinator radio (a SkyConnect, ConBee, or a zigpy-znp/bellows-supported dongle) at a serial path. See **[Standalone mode](https://docs.majordom.io/device-integration/standalone)** for the interactive CLI, watch mode, and the programmatic API. ## About this integration - **Protocol / platform:** Zigbee via `zigpy` (with `bellows` / `zigpy-znp` radio libraries). - **Transport(s):** Zigbee (IEEE 802.15.4). - **Supported devices:** Zigbee Home Automation devices — lights, plugs, switches, sensors. - **Credentials needed to pair:** none — devices join during an explicit pairing window. ### Required harness - **Hardware adapters:** a Zigbee coordinator radio (SkyConnect / ConBee / a `zigpy`-supported dongle) — the Hub assigns its OS device path via `dependencies.hardware_interfaces` (e.g. `/dev/ttyACM0`). - **Third-party software services:** none — `zigpy` speaks to the radio directly. - **OS / permissions:** serial-port access to the radio. ### Protocol stack (OSI) | OSI layer | Protocol | Implemented by | |-----------|----------|----------------| | Application (7) | Zigbee Cluster Library (ZCL) | **this integration** (via `zigpy`) | | Network (3) | Zigbee NWK / APS | library (`zigpy` · radio firmware) | | Data link / Physical (1–2) | IEEE 802.15.4 | radio adapter (harness) | ### Progress - [x] `start_pairing_window` implemented (Zigbee requires an explicit join window) - [x] Discovery of joining devices; `controller_did_receive_discovery` called - [x] Re-discovery of already-paired devices on reconnect (`controller_did_connect_device`) - [x] Device pairing - [x] Device schema mapped: endpoints/clusters → parameter list with per-parameter metadata - [x] Hub → Device control (`send_command` — commands and attribute writes) - [x] Device → Hub event subscription (`controller_did_receive_events`) - [x] `identify` - [x] `unpair` - [x] `fetch` - [x] Availability tracking while running (`controller_did_lose_device` / `last_error`) - [x] Graceful shutdown in `stop` - [x] Tests pass against a simulated `zigpy` device (`tests/`) ### Parameter metadata sources & priority Every parameter's UX metadata is resolved from several sources. Two independent axes, each with its own priority ladder (first match wins). See also the [parameter-ux recipe](https://docs.majordom.io/device-integration/parameter-ux). **Visibility / role / unit** — resolved by `classify_attribute()` in `zigbee_spec.py`: | # | Source | What it is | |---|--------|-----------| | 1 | `OUR_ATTRIBUTE_UX` (`VISIBILITY_OVERRIDES`, `USER_READINGS`, `EVERYDAY_CONTROL_ATTRIBUTES`) | our hand curation — a human's call wins over everything | | — | metadata / manufacturer-on-system-cluster | forced **system** (safety; scaling constants & bounds stay hidden) | | 2 | **v2 quirk entity metadata** | per-device judgment from a loaded `zhaquirks` `QuirkBuilder` (`quirk_ux_map()`), runtime | | 3 | `ZHA_ATTRIBUTE_UX` | standard-cluster judgment **harvested** from `zha` (`scripts/harvest_zha.py`, vendored — `zha` is not a runtime dep) | | 4 | **fallback policy** | heuristic (reportable → user, writable → setting); **logs a warning** so uncurated attrs surface. Flip `_FALLBACK_HIDE_UNCURATED` to hide-by-default once coverage is validated on real devices. | **Bounds (`min`/`max`/`step`)** — a separate ladder (`resolve_metadata_bounds()`): 1. the device's own limit attributes' **runtime values** (`METADATA_SOURCES`) — ground truth for *this* device; 2. spec tables (`ATTRIBUTE_MIN_STEP`, wire-type range); 3. wire-type default. A missing expected limit is logged (quirk detection). **Quirks.** `zhaquirks.setup()` runs once at controller startup so joined devices are presented in quirked form (manufacturer clusters decoded into named/typed attributes; v2 entity metadata attached). This requires the `zigpy` 2.x stack. **Drift.** `scripts/check_zha_drift.py` re-harvests `zha` and diffs against the vendored artifact via the SDK's `diff_specs`, tiering changes ADD / REMOVE / **RECLASSIFY** (high-risk — changes what current users already see). CI opens a Dependabot-style refresh PR on drift. ### Notes The device/parameter ids are derived from the device's IEEE address via the SDK's UUID helpers, so they're stable across restarts and namespaced per integration. ## License See [LICENSE](LICENSE). For commercial licensing or partnership inquiries regarding MajorDom, contact us via [parker-industries.org/partnership](https://parker-industries.org/partnership). ================================================================================ # FILE: majordom_zigbee/__init__.py ================================================================================ """Zigbee integration for MajorDom. Bridges Zigbee devices into the MajorDom language via zigpy. `ZigBeeController` is the entry point the Hub (or the SDK's standalone dev runner) instantiates and drives. """ from majordom_zigbee.controller import ZigBeeController __all__ = ["ZigBeeController"] ================================================================================ # FILE: majordom_zigbee/_serial.py ================================================================================ import subprocess def port_holder(port: str) -> str: """Return human-readable description of which process holds the port, or empty string.""" try: # fuser prints the device name to stderr, PIDs to stdout — capture separately result = subprocess.run(["fuser", port], capture_output=True, text=True) out = result.stdout.strip() pids = [p for p in out.split() if p.isdigit()] names = [] for pid in pids: try: comm = subprocess.check_output(["cat", f"/proc/{pid}/comm"], text=True).strip() names.append(f"pid={pid} ({comm})") except (subprocess.CalledProcessError, FileNotFoundError): names.append(f"pid={pid}") return ", ".join(names) except (subprocess.CalledProcessError, FileNotFoundError): return "" ================================================================================ # FILE: majordom_zigbee/controller.py ================================================================================ import asyncio import contextlib import json import logging from enum import Enum from typing import ClassVar, Literal, cast, override from uuid import UUID import zigpy.endpoint import zigpy.zcl from majordom_integration_sdk.controller import AbstractController from majordom_integration_sdk.schemas.command import DeviceCommand from majordom_integration_sdk.schemas.device import CredentialsType, Discovery, NonEmptyStr, ProvidedCredentials from majordom_integration_sdk.schemas.event import DeviceParameterChange from majordom_integration_sdk.schemas.parameter import ( ParameterDataType, ParameterRole, ParameterUnit, ParameterVisibility, ) from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH from zigpy.device import Device as ZPDevice # ZP - ZigPy from zigpy.types import EUI64 from zigpy.zcl.clusters.general import Identify from zigpy.zcl.foundation import Status as ZCLStatus from zigpy.zcl.foundation import ZCLAttributeAccess, ZCLAttributeDef from majordom_zigbee._serial import port_holder from .exceptions import ZBConnectionError, ZBOperationError, ZBUnexpectedError from .listener import ZBAttributeUpdatedListener from .mapper import ZigBeeMapper from .model import ( Parameter, ZBDevice, ZBDeviceIntegrationData, ZBDeviceState, ZBParameter, ZBParameterIntegrationData, ZBParameterState, ZBParameterType, ) from .zigbee_spec import ( EVERYDAY_COMMANDS, MAIN_PARAMETER_BY_CLUSTER, SYSTEM_CLUSTERS, MainParameterSpec, UxSpec, classify_attribute, get_min_step, get_unit, resolve_metadata_bounds, unit_from_zha, ) log = logging.getLogger(__name__) # ZCL read_attributes request max payload: 254 bytes (EZSP LVBytes limit) # Header=3 bytes + 2 bytes/attr → theoretical max 125 attr IDs per request. # In practice, reading too many attributes at once keeps the serial line busy # long enough to trigger NCP ACK timeouts. _MAX_ATTRS_PER_REQUEST = 25 # Delay between attribute read chunks to let bellows send ASH ACKs. _INTER_CHUNK_DELAY = 0.05 _quirks_loaded = False _SETTING_ENTITY_TYPES = frozenset({"config", "diagnostic"}) _SENSOR_PLATFORMS = frozenset({"sensor", "binary_sensor"}) def quirk_ux_map(zbdevice: ZPDevice) -> dict[tuple[int, int, str], UxSpec]: """Per-attribute UX judgment carried by a v2 QuirkBuilder, keyed (endpoint_id, cluster_id, attribute_name). This is the runtime, device-specific tier of the classification ladder (above harvested zha, below our hand overrides). Empty for non-quirked / v1-only devices. entity_type (standard/config/diagnostic) -> visibility; entity_platform -> role; unit/ device_class -> ParameterUnit. Only metadata entries that target a single attribute are used. """ quirk_def = getattr(zbdevice, "_quirk_definition", None) if quirk_def is None: return {} out: dict[tuple[int, int, str], UxSpec] = {} for meta in getattr(quirk_def, "entity_metadata", ()): attr_name = getattr(meta, "attribute_name", None) if not attr_name: continue # command buttons / composite entities carry no single attribute entity_type = getattr(getattr(meta, "entity_type", None), "value", None) platform = getattr(getattr(meta, "entity_platform", None), "value", None) visibility = ParameterVisibility.setting if entity_type in _SETTING_ENTITY_TYPES else ParameterVisibility.user role = ParameterRole.sensor if platform in _SENSOR_PLATFORMS else ParameterRole.control unit = unit_from_zha( getattr(getattr(meta, "device_class", None), "value", getattr(meta, "device_class", None)), getattr(meta, "unit", None), ) out[(meta.endpoint_id, meta.cluster_id, attr_name)] = UxSpec(visibility, role, unit) return out def _ensure_quirks_loaded() -> None: """Register zhaquirks into zigpy's process-global registry, once. Must run before the radio interviews devices so a joined device is presented in its quirked form — manufacturer clusters decoded into named/typed attributes and v2 entity metadata attached. setup() imports every zhaquirks module, so guard against repeat cost.""" global _quirks_loaded if _quirks_loaded: return import zhaquirks zhaquirks.setup() _quirks_loaded = True log.debug("[QUIRKS] zhaquirks registered into zigpy registry") def _zb_path( device: ZBDevice | None = None, zbdevice: ZPDevice | None = None, endpoint: zigpy.endpoint.Endpoint | None = None, cluster: zigpy.zcl.Cluster | None = None, attr_id: int | None = None, attr_ids: list[int] | None = None, error: Exception | None = None, attr_only: bool = False, ) -> str: parts: list[str] = [] if device is not None: model = zbdevice.model if zbdevice is not None else None parts.append(f"device={device.id}" + (f"({model})" if model else "")) if endpoint is not None: ep_type = getattr(endpoint.device_type, "name", None) parts.append(f"endpoint={endpoint.endpoint_id}" + (f"({ep_type})" if ep_type else "")) if cluster is not None and not attr_only: parts.append(f"cluster={cluster.cluster_id}({cluster.name})") if attr_id is not None: attr_name = getattr(cluster.attributes.get(attr_id) if cluster else None, "name", None) parts.append(f"attr={attr_id}" + (f"({attr_name})" if attr_name else "")) if error is not None: parts.append(f"error={type(error).__name__}{' details=' + str(error) if str(error) else ''}") if attr_ids is not None: attr_ids = sorted(attr_ids) names = [getattr(cluster.attributes.get(a) if cluster else None, "name", None) for a in attr_ids] long = len(names) > 1 glue = ",\n\t" if long else " " parts.append( f"attrs={'\n\t' if long else ''}" f"{glue.join(f'{a}({n})' if n else str(a) for a, n in zip(attr_ids, names, strict=False))}" ) return " ".join(parts) def _check_zcl_failures( failures: dict[int | None, ZCLStatus], cluster: zigpy.zcl.Cluster, log_prefix: str, *, device: "ZBDevice | None" = None, zbdevice: "ZPDevice | None" = None, endpoint: "zigpy.endpoint.Endpoint | None" = None, raise_errors: bool = True, ) -> None: """Handle ZCL-level failures from read_attributes or write_attributes. Registers unsupported attributes on the cluster and logs them at DEBUG. Raises ZBOperationError for any other non-SUCCESS status. Pass failures as {attr_id: status} (read_attributes) or {record.attrid: record.status for record in result[0]} (write_attributes). A None key means a global device-level status (write_attributes global failure). """ cluster_path = _zb_path(device, zbdevice, endpoint, cluster) unsupported = [ attr_id for attr_id, status in failures.items() if attr_id is not None and status == ZCLStatus.UNSUPPORTED_ATTRIBUTE ] errors = { attr_id: status for attr_id, status in failures.items() if status != ZCLStatus.SUCCESS and status != ZCLStatus.UNSUPPORTED_ATTRIBUTE } for attr_id in unsupported: # zigpy 2.0 resolves the id via find_attribute, which raises for ids the cluster # definition doesn't know (a device can report unsupported for a nonstandard id). with contextlib.suppress(KeyError, ValueError): cluster.add_unsupported_attribute(attr_id) if unsupported: names = [_zb_path(cluster=cluster, attr_id=a, attr_only=True) for a in unsupported] long = len(names) > 1 log.debug( f"{log_prefix} unsupported attributes {cluster_path}: {'\n\t' if long else ''}{(',\n\t').join(names)}" ) if not errors: return details = ",\n\t".join( f"{_zb_path(cluster=cluster, attr_id=attr_id, attr_only=True) if attr_id is not None else 'global'}" f"={getattr(status, 'name', status)}" # ZCLStatus enum, or a raw int for unknown codes for attr_id, status in errors.items() ) if raise_errors: raise ZBOperationError(f"{log_prefix} ZCL failures:\n\t{details}") else: log.error(f"{log_prefix} ZCL failures:\n\t{details}") class ZigBeeController(AbstractController): """Bridges the Hub to Zigbee devices through a USB coordinator radio via zigpy. zigpy owns the Zigbee network, the ZCL, and device interviews; this controller adapts it to the Hub's AbstractController contract. Zigbee has no separate discovery step — a device is on the network the moment it joins the permit-join window. See readme.md. """ _ZIGBEE_STACK: ClassVar[Literal["bellows", "znp"]] = "bellows" _zigbee_device_path: str _zigbe_db: str _majordom_discoveries: dict[UUID, Discovery] # discovery metadata surfaced to the Hub _awaiting_zb_discoveries: dict[UUID, ZPDevice] # on the network, discovered, not yet paired in majordom _connected_devices: dict[UUID, ZPDevice] # paired in majordom # (the ZPDevice values could be shrunk to just the IEEE address to save memory, if needed.) _mapper: ZigBeeMapper _tasks: set[asyncio.Task] def __init__(self, dependencies: AbstractController.Dependencies): super().__init__(dependencies) # Mapper is wired with the framework's UUID generators so every Zigbee id is namespaced # consistently under the integration and device (see ZigBeeMapper). self._mapper = ZigBeeMapper(self.device_uuid, self.parameter_uuid) self._majordom_discoveries: dict[UUID, Discovery] = {} self._awaiting_zb_discoveries: dict[UUID, ZPDevice] = {} self._connected_devices: dict[UUID, ZPDevice] = {} self._availability: dict[UUID, bool] = {} # device_id -> last signalled availability self._tasks: set[asyncio.Task] = set() # ------------------------------------------------------------------------- # AbstractController interface # ------------------------------------------------------------------------- name = "ZigBee" @property def discoveries(self) -> dict[UUID, Discovery]: return self._majordom_discoveries @property @override def device_type(self) -> type[ZBDevice]: return ZBDevice @property @override def parameter_type(self) -> type[ZBParameter]: return ZBParameter # ------------------------------------------------------------------------- # Lifecycle # ------------------------------------------------------------------------- async def start(self): self._zigbee_device_path = self.dependencies.hardware_interfaces[0] self._zigbe_db = str(self.documents_folder / "zigbee.db") log.debug("[START] port=%s db=%s", self._zigbee_device_path, self._zigbe_db) config = { CONF_DEVICE: {CONF_DEVICE_PATH: self._zigbee_device_path}, CONF_DATABASE: self._zigbe_db, } # Register per-device quirks before the radio starts interviewing devices. _ensure_quirks_loaded() # Starting zigbee stack match self._ZIGBEE_STACK: case "bellows": from bellows.zigbee.application import ControllerApplication case "znp": from zigpy_znp.zigbee.application import ControllerApplication try: self._application = await ControllerApplication.new(config=config, auto_form=True) except Exception as e: if "locked" in str(e).lower() or "permission" in str(e).lower(): holder = port_holder(self._zigbee_device_path) msg = f"{self._zigbee_device_path} is locked" if holder: msg += f" by: {holder}" raise PermissionError(msg) from e raise self._application.add_listener(self) log.debug("[READY] connected to %s", self._zigbee_device_path) async with self.dependencies.make_device_repository() as device_repo: # Subscribe to attribute updates and add the device to _connected_devices # if the Zigbee device is in the majordom database, otherwise start a discovery cycle. for zbdevice in self._application.devices.values(): if zbdevice.nwk == 0x0000: continue device_id = self._mapper.device_uuid_from_ieee(self._mapper.convert_eui64_to_str(zbdevice.ieee)) if await device_repo.get(device_id, ZBDevice): self._connected_devices[device_id] = zbdevice self._availability[device_id] = True # baseline for mid-session transitions await self._subscribe(device_id, zbdevice) log.debug("[KNOWN] ieee=%s nwk=0x%04X", zbdevice.ieee, zbdevice.nwk) else: self._create_task(self._disconnect_unpaired_discovery(device_id, zbdevice.ieee)) log.debug( "[UNKNOWN] ieee=%s nwk=0x%04X — not in DB, starting disconnect timer", zbdevice.ieee, zbdevice.nwk, ) try: await zbdevice.initialize() except Exception: log.exception("[UNKNOWN] initialize failed for ieee=%s", zbdevice.ieee) # Any device in our DB that isn't on the network anymore is marked unavailable on boot. for device in await device_repo.get_all(as_=ZBDevice): ieee = self._mapper.convert_str_to_eui64(device.integration_data.ieee) if self._application.get_device(ieee): continue device.available = False device.last_error = f"Device {device.name} is no longer connected to the ZigBee network" self._availability[device.id] = False # baseline for mid-session transitions await device_repo.save(device, device.id) async def stop(self): log.debug("[STOP] shutting down") for task in self._tasks: task.cancel() if self._tasks: await asyncio.gather(*self._tasks, return_exceptions=True) self._tasks.clear() if self._application: await self._application.shutdown() self._majordom_discoveries.clear() self._awaiting_zb_discoveries.clear() self._connected_devices.clear() # ------------------------------------------------------------------------- # Hub -> device operations # ------------------------------------------------------------------------- async def start_pairing_window(self, duration_sec: int) -> None: if not self._application: raise ZBConnectionError("ZigBee application is not started") log.debug("[PERMIT-JOIN] opening for %ds", duration_sec) await self._application.permit(duration_sec) async def pair_device( self, discovery: Discovery, credentials: ProvidedCredentials | None ): # Break down into submethods async with self.dependencies.make_device_repository() as device_repository: device = await device_repository.state(discovery.id, ZBDeviceState) self._majordom_discoveries.pop(discovery.id) zbdevice = self._awaiting_zb_discoveries.pop(discovery.id) self._connected_devices[discovery.id] = zbdevice assert device assert zbdevice if not device.integration_data.ieee: device.integration_data = ZBDeviceIntegrationData(ieee=self._mapper.convert_eui64_to_str(zbdevice.ieee)) # Manufacturer-provided, read-only description — the ZCL model string. device.description = zbdevice.model or None parameters: list[ZBParameterState] = list() # v2 QuirkBuilder entity judgment for this device (empty for non-quirked devices). quirk_ux = quirk_ux_map(zbdevice) for endpoint in zbdevice.non_zdo_endpoints: for cluster in endpoint.clusters: for attribute_id, attribute in cluster.attributes.items(): if cluster.is_attribute_unsupported(attribute_id): continue value = None # Classification via the priority ladder (see the zigbee README): our hand # overrides > v2 quirk metadata > harvested zha judgment > fallback heuristic # (which warns). Metadata and manufacturer-on-system-cluster attrs are hidden. spec, source = classify_attribute( cluster.cluster_id, attribute_id, attribute.name, writable=bool(attribute.access & ZCLAttributeAccess.Write), reportable=bool(attribute.access & ZCLAttributeAccess.Report), quirk_ux=quirk_ux.get((endpoint.endpoint_id, cluster.cluster_id, attribute.name)), ) visibility = spec.visibility role = ( spec.role if spec.role is not None else self._mapper.parse_zigbee_attribute_access(attribute.access) ) if source.startswith("fallback"): log.warning( "[PAIR] uncurated attribute %s -> %s (%s); add to OUR_ATTRIBUTE_UX " "or refresh the zha harvest", _zb_path(cluster=cluster, attr_id=attribute_id, attr_only=True), visibility.value, source, ) data_type = self._mapper.parse_zigbee_data_type(attribute.zcl_type) min_value = None max_value = None # Annotated so the untyped-enum comprehension below (member.name is Unknown) # matches Parameter.valid_values' declared type. valid_values: dict[int | float | str, str] | None = None min_step = get_min_step(cluster.cluster_id, attribute_id) # Our spec tables win; the harvested/quirk unit fills gaps get_unit() leaves plain. unit = get_unit(cluster.cluster_id, attribute_id) if unit is ParameterUnit.plain and spec.unit is not None: unit = spec.unit if hasattr(attribute.type, "min_value"): min_value = attribute.type.min_value if hasattr(attribute.type, "max_value"): max_value = attribute.type.max_value # Metadata priority 1: the device's own limit attributes (runtime values) win # over the wire-type default. cluster.get returns the cached sibling value. min_value, max_value, missing_bounds = resolve_metadata_bounds( cluster.cluster_id, attribute_id, cluster.get, min_value, max_value ) for missing_attr in missing_bounds: log.debug( "[PAIR] metadata source %s not reported for %s — quirk or unsupported; " "using wire-type default", _zb_path(cluster=cluster, attr_id=missing_attr, attr_only=True), _zb_path(cluster=cluster, attr_id=attribute_id, attr_only=True), ) if issubclass(attribute.type, Enum) and data_type != ParameterDataType.bool: valid_values = {member.name: str(member.value) for member in attribute.type} parameters.append( ZBParameterState( id=self._mapper.attribute_parameter_uuid( device.id, endpoint.endpoint_id, cluster.cluster_id, attribute_id ), name=attribute.name, data_type=data_type, visibility=visibility, min_value=min_value, max_value=max_value, min_step=min_step, unit=unit, valid_values=valid_values, role=role, integration_data=ZBParameterIntegrationData( endpoint_id=endpoint.endpoint_id, cluster_id=cluster.cluster_id, attribute_id=attribute_id, type=ZBParameterType.attribute, ), value=value, ) ) for command in cluster.commands: fields: list[Parameter] = [] # Command visibility: system-cluster commands hidden; everyday one-tap # actions -> user; every other command (schedule/credential/log # management) -> setting. if cluster.cluster_id in SYSTEM_CLUSTERS: visibility = ParameterVisibility.system elif (cluster.cluster_id, command.id) in EVERYDAY_COMMANDS: visibility = ParameterVisibility.user else: visibility = ParameterVisibility.setting for i, field in enumerate(command.schema.fields): min_value = None max_value = None valid_values: dict[int | float | str, str] | None = None if hasattr(field.type, "min_value"): min_value = field.type.min_value if hasattr(field.type, "max_value"): max_value = field.type.max_value if isinstance(field.type, type) and issubclass(field.type, Enum): valid_values = {member.name: str(member.value) for member in field.type} fields.append( Parameter( id=self._mapper.command_field_uuid( device.id, endpoint.endpoint_id, cluster.cluster_id, command.id, i ), name=field.name, data_type=self._mapper.parse_zigbee_data_type(field.type), role=ParameterRole.control, visibility=ParameterVisibility.setting, min_value=min_value, max_value=max_value, valid_values=valid_values, integration_data=None, ) ) parameters.append( ZBParameterState( id=self._mapper.command_parameter_uuid( device.id, endpoint.endpoint_id, cluster.cluster_id, command.id ), name=command.name, data_type=ParameterDataType.none, role=ParameterRole.control, fields=json.loads(json.dumps([f.model_dump(mode="json") for f in fields])) if fields else None, visibility=visibility, integration_data=ZBParameterIntegrationData( endpoint_id=endpoint.endpoint_id, cluster_id=cluster.cluster_id, command_id=command.id, type=ZBParameterType.command, ), value=None, ) ) device.parameters = parameters main_parameter_id, main_spec = self._get_device_main_parameter(device.id, zbdevice) device.main_parameter = main_parameter_id if main_parameter_id and main_spec is not None: # Attach the one-tap send info to the chosen main parameter; drop the main parameter # if the target command/attribute wasn't actually exposed. main_parameter = next((p for p in parameters if p.id == main_parameter_id), None) if main_parameter is None: device.main_parameter = None elif main_spec.is_attribute and main_spec.cycle: # Attribute main (e.g. fan_mode): a tap cycles through a value subset. Store it # as the parameter's default_value — the generic, relay-readable cycle source # (the relay derives the next value via Parameter.main_cycle). A spec with no # explicit subset leaves the param to cycle its full valid_values, which the # relay already derives, so there's nothing to set here. main_parameter.default_value = set(main_spec.cycle) elif main_spec.default_arguments is not None: main_parameter.integration_data.default_arguments = main_spec.default_arguments log.debug( f"[PAIR] mapped schema {_zb_path(device, zbdevice)}\n\t" + "\n\t".join( f" {p.role.value:8} {p.visibility.value:8} {p.data_type.value:10} {p.name} id={p.id}" for p in parameters ), ) await device_repository.save(device, discovery.id) # fetch runs in background; controller_did_connect_device fires after fetch completes self._create_task(self._fetch_after_pair(device, zbdevice)) async def unpair(self, device: ZBDevice): if not self._application: raise ZBConnectionError("ZigBee application is not started") log.debug("[UNPAIR] ieee=%s", device.integration_data.ieee) await self._application.remove(self._mapper.convert_str_to_eui64(device.integration_data.ieee)) self._connected_devices.pop(self._mapper.device_uuid_from_ieee(device.integration_data.ieee)) async def identify(self, device: ZBDevice): if not self._application: raise ZBConnectionError("ZigBee application is not started") ieee = self._mapper.convert_str_to_eui64(device.integration_data.ieee) if not (zbdevice := self._application.devices.get(ieee)): raise ZBUnexpectedError(f"Device {ieee} not found in ZigBee network") if not zbdevice.is_initialized: raise ZBUnexpectedError(f"Device {ieee} is not initialized") for endpoint in zbdevice.non_zdo_endpoints: cluster = endpoint.in_clusters.get(Identify.cluster_id) if not cluster: continue await cluster.identify(10) # 10 - identification time async def fetch(self, device: ZBDevice) -> None: if not self._application: raise ZBConnectionError("ZigBee application is not started") ieee = self._mapper.convert_str_to_eui64(device.integration_data.ieee) if not (zbdevice := self._application.devices.get(ieee)): raise ZBUnexpectedError(f"Device {ieee} not found in ZigBee network") if not zbdevice.is_initialized: raise ZBUnexpectedError(f"Device {ieee} is not initialized") events: list[DeviceParameterChange] = list() log.debug("[FETCH] start device=%s(%s)", device.id, zbdevice.model) t0 = asyncio.get_event_loop().time() for endpoint in zbdevice.non_zdo_endpoints: for cluster_id, cluster in endpoint.in_clusters.items(): readable_ids = [ attr_id for attr_id in cluster.attributes if not cluster.is_attribute_unsupported(attr_id) ] attr_values = await self._read_cluster_attributes( device, zbdevice, endpoint, cluster, readable_ids, log_prefix="[FETCH]", timeout=2 ) for attribute_id in cluster.attributes: if cluster.is_attribute_unsupported(attribute_id): continue events.append( DeviceParameterChange( device_id=device.id, parameter_id=self._mapper.attribute_parameter_uuid( device.id, endpoint.endpoint_id, cluster_id, attribute_id ), value=self._mapper.normalize_zigbee_value(attr_values.get(attribute_id)), ) ) for command in cluster.commands: events.append( DeviceParameterChange( device_id=device.id, parameter_id=self._mapper.command_parameter_uuid( device.id, endpoint.endpoint_id, cluster_id, command.id ), value=None, ) ) log.debug( "[FETCH] done device=%s(%s) duration=%.2fs", device.id, zbdevice.model, asyncio.get_event_loop().time() - t0 ) await self.dependencies.output.controller_did_receive_events(self, events) async def send_command(self, command: DeviceCommand, device: ZBDevice, parameter: ZBParameter): if not self._application: raise ZBConnectionError("ZigBee application is not started") ieee = self._mapper.convert_str_to_eui64(device.integration_data.ieee) if not (zbdevice := self._application.devices.get(ieee)): raise ZBUnexpectedError(f"Device {ieee} not found in ZigBee network") if not (endpoint := zbdevice.endpoints.get(parameter.integration_data.endpoint_id)): raise ZBUnexpectedError(f"Endpoint {parameter.integration_data.endpoint_id} not found") # endpoints[0] is the device's ZDO; a real parameter always lives on a numbered Endpoint. # Narrowing here fixes it for every downstream use (in_clusters, _zb_path, _check_zcl_failures). if not isinstance(endpoint, zigpy.endpoint.Endpoint): raise ZBUnexpectedError( f"Endpoint {parameter.integration_data.endpoint_id} is the ZDO, not a device endpoint" ) if not (cluster := endpoint.in_clusters.get(parameter.integration_data.cluster_id)): raise ZBUnexpectedError(f"Cluster {parameter.integration_data.cluster_id} not found") if parameter.integration_data.type is ZBParameterType.attribute: if parameter.role != ParameterRole.control: raise ZBUnexpectedError(f"Parameter '{parameter.name}' is not a control parameter") attr_id = parameter.integration_data.attribute_id # The relay pre-derives a main-tap's value (bool / cycle / button) before dispatch, so # an attribute write just sends it — the cycle logic lives in the relay, not here. value = command.value if value is None: raise ZBUnexpectedError(f"No value to send for '{parameter.name}'") try: result = await cluster.write_attributes({attr_id: value}) except Exception as e: raise ZBConnectionError( f"[CMD] write_attributes transport error " f"{_zb_path(device, zbdevice, endpoint, cluster, attr_id, error=e)}" ) from None _check_zcl_failures( {r.attrid: r.status for r in result[0]}, cluster, "[CMD]", device=device, zbdevice=zbdevice, endpoint=endpoint, ) log.info(f"[CMD] write_attributes {_zb_path(device, zbdevice, endpoint, cluster, attr_id)}") else: zbcommand = cluster.commands_by_name.get(parameter.name) if not zbcommand: raise ZBUnexpectedError(f"Command {parameter.name} not found in cluster") # A value-less send (e.g. tapping the main parameter) falls back to the arguments this # command was set up with as a main parameter — see integration_data.default_arguments. arguments = command.value if command.value is not None else parameter.integration_data.default_arguments try: if isinstance(arguments, dict): result = await cluster.command(zbcommand.id, **arguments) elif arguments is not None: result = await cluster.command(zbcommand.id, arguments) else: result = await cluster.command(zbcommand.id) except Exception as e: raise ZBConnectionError( f"[CMD] command error {_zb_path(device, zbdevice, endpoint, cluster, error=e)}" ) from None # cluster.command returns the DefaultResponse or cluster-specific response; # check status field if present (DefaultResponse carries it) if hasattr(result, "status") and result.status != ZCLStatus.SUCCESS: raise ZBOperationError( f"[CMD] command failure {_zb_path(device, zbdevice, endpoint, cluster)}: " f"status={result.status.name}" ) # ------------------------------------------------------------------------- # Device -> Hub: Zigbee network events (zigpy listener) & availability # ------------------------------------------------------------------------- def device_joined(self, device: ZPDevice): """A device joined the Zigbee network (only happens while the permit-join window is open).""" log.debug("[JOIN] ieee=%s nwk=0x%04X", device.ieee, device.nwk) device_id = self._mapper.device_uuid_from_ieee(self._mapper.convert_eui64_to_str(device.ieee)) # Zigbee has no separate "discovery": a device is on the network as soon as it joins. # We hold it in the awaiting list and disconnect it if it isn't paired in majordom within # the window (see _disconnect_unpaired_discovery). An already-paired device that merely # rejoined is handled in device_initialized, not here. self._create_task(self._disconnect_unpaired_discovery(device_id, device.ieee)) def device_initialized(self, device: ZPDevice): """A device finished interviewing and is ready to talk. Fires both for a brand-new join and when an already-paired device rejoins the network after being offline.""" log.debug( "[INIT] ieee=%s nwk=0x%04X model=%r manufacturer=%r", device.ieee, device.nwk, device.model, device.manufacturer, ) device_id = self._mapper.device_uuid_from_ieee(self._mapper.convert_eui64_to_str(device.ieee)) # Already paired in majordom -> a mid-session reconnect: re-subscribe and mark it back # online instead of surfacing it as a fresh discovery. if device_id in self._connected_devices: self._connected_devices[device_id] = device self._create_task(self._subscribe(device_id, device)) self._create_task(self._set_availability(device_id, True)) return # Awaiting pairing and re-initialized -> just (re)subscribe. if device_id in self.discoveries: self._create_task(self._subscribe(device_id, device)) return # Otherwise it's a new, not-yet-paired device -> surface it as a discovery. discovery = Discovery( id=device_id, integration=NonEmptyStr(self.name), expected_credentials_options=[CredentialsType.none], expiration=None, transport=NonEmptyStr("ZIGBEE"), device_manufacturer=None, device_name=NonEmptyStr(device.name), device_category=None, device_icon=None, ) self._majordom_discoveries[device_id] = discovery self._awaiting_zb_discoveries[device_id] = device log.debug("[DISCOVERY] ieee=%s discovery_id=%s", device.ieee, device_id) self._create_task(self.dependencies.output.controller_did_receive_discovery(self, discovery)) def device_left(self, device: ZPDevice): """A device left the Zigbee network. If it's one of ours, mark it unavailable so the app reflects it; the pairing stays in the DB so it comes back online on rejoin.""" log.debug("[LEFT] ieee=%s nwk=0x%04X", device.ieee, device.nwk) device_id = self._mapper.device_uuid_from_ieee(self._mapper.convert_eui64_to_str(device.ieee)) if device_id in self._connected_devices: self._create_task(self._set_availability(device_id, False)) async def _set_availability(self, device_id: UUID, available: bool) -> None: """Single funnel for availability transitions — dedupes so the Hub is only told on an actual change, and translates it into the framework's connect / lose callbacks.""" if self._availability.get(device_id) == available: return self._availability[device_id] = available if available: await self.dependencies.output.controller_did_connect_device(self, device_id) else: await self.dependencies.output.controller_did_lose_device(self, device_id) # ------------------------------------------------------------------------- # Private helpers # ------------------------------------------------------------------------- async def _fetch_after_pair(self, device: ZBDevice, zbdevice: ZPDevice) -> None: log.debug("[PAIR] starting background fetch device=%s(%s)", device.id, zbdevice.model) try: await self.fetch(device) except Exception as e: log.warning( "[PAIR] fetch failed for device=%s(%s), skipping connect signal: %s", device.id, zbdevice.model, e ) return await self._set_availability(device.id, True) log.debug("[PAIR] background fetch done, device connected device=%s(%s)", device.id, zbdevice.model) async def _read_cluster_attributes( self, device: ZBDevice, zbdevice: ZPDevice, endpoint: zigpy.endpoint.Endpoint, cluster: zigpy.zcl.Cluster, ids: list[int], *, only_cache: bool = False, log_prefix: str = "[READ]", timeout: float | None = None, ) -> dict[int, object]: attr_values: dict[int, object] = {} chunks = [ids[i : i + _MAX_ATTRS_PER_REQUEST] for i in range(0, len(ids), _MAX_ATTRS_PER_REQUEST)] for chunk in chunks: try: # read_attributes accepts attribute names/ids/defs; we only pass ids, and list # invariance is the only reason the plain list[int] doesn't fit the wider param type. values, failures = await cluster.read_attributes( cast("list[int | str | ZCLAttributeDef]", chunk), only_cache=only_cache, timeout=timeout ) except Exception as e: log.error( f"{log_prefix} read_attributes error " f"{_zb_path(device, zbdevice, endpoint, cluster, attr_ids=chunk, error=e)}" ) await asyncio.sleep(_INTER_CHUNK_DELAY) # let bellows send pending ASH ACKs continue await asyncio.sleep(_INTER_CHUNK_DELAY) # pace chunks to prevent NCP ACK timeout attr_values.update(values) _check_zcl_failures( failures, cluster, log_prefix, device=device, zbdevice=zbdevice, endpoint=endpoint, raise_errors=False, ) return attr_values def _create_task(self, coro) -> asyncio.Task: task = asyncio.create_task(coro) self._tasks.add(task) task.add_done_callback(self._tasks.discard) return task async def _subscribe(self, device_id: UUID, device: ZPDevice): for endpoint in device.non_zdo_endpoints: for cluster in endpoint.in_clusters.values(): listener = ZBAttributeUpdatedListener(self, device_id, cluster) cluster.add_listener(listener) async def _disconnect_unpaired_discovery(self, discovery_id: UUID, ieee: EUI64): await asyncio.sleep(300) # 5 minutes if discovery_id not in self._majordom_discoveries and discovery_id in self._connected_devices: # if the device was connected return await self._application.remove(ieee) self._majordom_discoveries.pop(discovery_id) self._awaiting_zb_discoveries.pop(discovery_id) def _get_device_main_parameter( self, device_id: UUID, zbdevice: ZPDevice ) -> tuple[UUID | None, MainParameterSpec | None]: """Pick the parameter used for the device's one-tap action on the room view, in cluster priority order (see MAIN_PARAMETER_BY_CLUSTER). Returns the parameter id and its spec (command or attribute main), or (None, None) if the device has no sensible one-tap action.""" for endpoint in zbdevice.non_zdo_endpoints: for cluster_id, spec in MAIN_PARAMETER_BY_CLUSTER.items(): if endpoint.in_clusters.get(cluster_id): if spec.is_attribute: param_id = self._mapper.attribute_parameter_uuid( device_id, endpoint.endpoint_id, cluster_id, spec.target_id ) else: param_id = self._mapper.command_parameter_uuid( device_id, endpoint.endpoint_id, cluster_id, spec.target_id ) return param_id, spec return None, None ================================================================================ # FILE: majordom_zigbee/exceptions.py ================================================================================ class ZBConnectionError(Exception): """Raised when ZigBee application is not started or not available.""" pass class ZBUnexpectedError(Exception): """Raised when an unexpected internal error occurs.""" pass class ZBOperationError(Exception): """Raised when a ZCL operation succeeds at the transport level but the device returns a non-SUCCESS status.""" pass ================================================================================ # FILE: majordom_zigbee/listener.py ================================================================================ import asyncio from uuid import UUID from majordom_integration_sdk.schemas.event import DeviceParameterChange from zigpy.zcl import Cluster class ZBAttributeUpdatedListener: def __init__(self, controller, device_id: UUID, cluster: Cluster): self._controller = controller self._device_id = device_id self._cluster = cluster def attribute_updated(self, attribute_id, value, time): cluster = self._cluster endpoint_id = cluster.endpoint.endpoint_id parameter_id = self._controller._mapper.attribute_parameter_uuid( self._device_id, endpoint_id, cluster.cluster_id, attribute_id ) event = DeviceParameterChange( device_id=self._device_id, parameter_id=parameter_id, value=self._controller._mapper.normalize_zigbee_value(value), ) asyncio.create_task( self._controller.dependencies.output.controller_did_receive_events(self._controller, [event]) ) ================================================================================ # FILE: majordom_zigbee/mapper.py ================================================================================ from collections.abc import Callable from enum import Enum, Flag from uuid import UUID import zigpy.types as t from majordom_integration_sdk.schemas.parameter import ParameterDataType, ParameterRole from zigpy.types import EUI64 from zigpy.zcl.foundation import DataTypeId, ZCLAttributeAccess from .exceptions import ZBUnexpectedError class ZigBeeMapper: def __init__( self, device_uuid: Callable[[str], UUID], parameter_uuid: Callable[[UUID, str], UUID], ): # The controller's framework UUID generators (see AbstractController). Every Zigbee # id — device from its IEEE address, parameters from their endpoint/cluster/attribute # path — is derived through these, so a device's parameters are namespaced under the # device and stay identical whether built at pairing, on fetch, or from a live report. self._device_uuid = device_uuid self._parameter_uuid = parameter_uuid # ------------------------------------------------------------------------- # Identity: Zigbee addresses/paths -> MajorDom UUIDs # ------------------------------------------------------------------------- def device_uuid_from_ieee(self, ieee: str | None) -> UUID: # ieee is Optional on the domain model, but a paired Zigbee device always has one; a # missing address here means a corrupt/half-written record, not a normal path. if ieee is None: raise ZBUnexpectedError("Zigbee device is missing its IEEE address") return self._device_uuid(ieee) def normalize_zigbee_value(self, value): """Coerce zigpy wire values to plain python types for the SDK's typed encoder. zigpy hands attribute values back as its own types — ``t.Bool`` is an int-enum (NOT a python bool), enum8/16 and bitmaps are int-enums, ints/floats/strings are subclasses. ``ParameterState`` values are pythonic, so coerce zigpys wire types here — the single choke point for both the fetch path and attribute reports. """ if value is None or isinstance(value, bool): return value if isinstance(value, t.Bool): return bool(value) if isinstance(value, Enum | Flag): return int(value) # zigpy ZCL enums/bitmaps are int-based if isinstance(value, int): return int(value) if isinstance(value, float): return float(value) if isinstance(value, str): return str(value) return value def attribute_parameter_uuid(self, device_id: UUID, endpoint_id: int, cluster_id: int, attribute_id: int) -> UUID: return self._parameter_uuid(device_id, f"attribute_{endpoint_id}/{cluster_id}/{attribute_id}") def command_parameter_uuid(self, device_id: UUID, endpoint_id: int, cluster_id: int, command_id: int) -> UUID: return self._parameter_uuid(device_id, f"command_{endpoint_id}/{cluster_id}/{command_id}") def command_field_uuid( self, device_id: UUID, endpoint_id: int, cluster_id: int, command_id: int, field_index: int ) -> UUID: return self._parameter_uuid(device_id, f"field_{endpoint_id}/{cluster_id}/{command_id}/{field_index}") # ------------------------------------------------------------------------- # Value / type conversions # ------------------------------------------------------------------------- def convert_eui64_to_str(self, data: EUI64) -> str: return EUI64.__str__(data) def convert_str_to_eui64(self, data: str | None) -> EUI64: # Same as device_uuid_from_ieee: the domain field is Optional but must be present here. if data is None: raise ZBUnexpectedError("Zigbee device is missing its IEEE address") return EUI64.convert(data) def parse_zigbee_attribute_access(self, access) -> ParameterRole: can_read = bool(access & ZCLAttributeAccess.Read) can_write = bool(access & ZCLAttributeAccess.Write) if can_write: return ParameterRole.control if can_read: return ParameterRole.sensor return ParameterRole.event def parse_zigbee_data_type(self, zcl_type: int | type) -> ParameterDataType: if isinstance(zcl_type, type): if issubclass(zcl_type, t.Bool): zcl_type = DataTypeId.bool_ elif issubclass(zcl_type, Flag): zcl_type = DataTypeId.map8 elif issubclass(zcl_type, Enum): zcl_type = DataTypeId.enum8 elif issubclass(zcl_type, t.FixedIntType): zcl_type = DataTypeId.uint8 elif issubclass(zcl_type, float): zcl_type = DataTypeId.single else: zcl_type = DataTypeId.nodata type_id = DataTypeId(zcl_type) if type_id in {DataTypeId.unk, DataTypeId.nodata}: return ParameterDataType.none # DataTypeId, not DataType — zigpy 2.0's DataType.bool_ is a rich descriptor that never # equals a DataTypeId, which silently sent every bool attribute to the `none` fallthrough. if type_id == DataTypeId.bool_: return ParameterDataType.bool if type_id in {DataTypeId.enum8, DataTypeId.enum16}: return ParameterDataType.enum if type_id in {DataTypeId.string, DataTypeId.string16}: return ParameterDataType.string if type_id in {DataTypeId.semi, DataTypeId.single, DataTypeId.double}: return ParameterDataType.decimal if type_id in { DataTypeId.uint8, DataTypeId.uint16, DataTypeId.uint24, DataTypeId.uint32, DataTypeId.uint40, DataTypeId.uint48, DataTypeId.uint56, DataTypeId.uint64, DataTypeId.int8, DataTypeId.int16, DataTypeId.int24, DataTypeId.int32, DataTypeId.int40, DataTypeId.int48, DataTypeId.int56, DataTypeId.int64, DataTypeId.map8, DataTypeId.map16, DataTypeId.map24, DataTypeId.map32, DataTypeId.map40, DataTypeId.map48, DataTypeId.map56, DataTypeId.map64, DataTypeId.ToD, DataTypeId.date, DataTypeId.UTC, DataTypeId.clusterId, DataTypeId.attribId, DataTypeId.bacOID, }: return ParameterDataType.integer if type_id in { DataTypeId.data8, DataTypeId.data16, DataTypeId.data24, DataTypeId.data32, DataTypeId.data40, DataTypeId.data48, DataTypeId.data56, DataTypeId.data64, DataTypeId.octstr, DataTypeId.octstr16, DataTypeId.array, DataTypeId.struct, DataTypeId.set, DataTypeId.bag, DataTypeId.EUI64, DataTypeId.key128, }: return ParameterDataType.data return ParameterDataType.none ================================================================================ # FILE: majordom_zigbee/model.py ================================================================================ from enum import StrEnum from majordom_integration_sdk.schemas.base import Base from majordom_integration_sdk.schemas.device import Device, DeviceState, Parameter, ParameterState from pydantic import BaseModel class ZBParameterType(StrEnum): attribute = "attribute" command = "command" class ZBDeviceIntegrationData(Base): ieee: str | None = None class ZBParameterIntegrationData(BaseModel): endpoint_id: int cluster_id: int attribute_id: int | None = None command_id: int | None = None type: ZBParameterType # Args to send when this command is the device's one-tap main parameter and needs them (e.g. a # brightness level). A command parameter's data_type is `none`, which already satisfies # ParameterState.can_be_main_parameter, so no `default_value` is needed for the flag — this only # carries *what to send*. send_command applies it when a command arrives with no value (i.e. the # user tapped the main parameter). Mirrors Matter's default_arguments. default_arguments: dict | None = None class ZBDevice(Device): integration_data: ZBDeviceIntegrationData class ZBParameter(Parameter): integration_data: ZBParameterIntegrationData class ZBParameterState(ParameterState): integration_data: ZBParameterIntegrationData class ZBDeviceState(ZBDevice, DeviceState): parameters: list[ZBParameterState] ================================================================================ # FILE: majordom_zigbee/readme.md ================================================================================ # Zigbee integration Bridges the Hub to Zigbee devices through a USB coordinator radio, built on [`zigpy`](https://github.com/zigpy/zigpy) with the `bellows` (EmberZNet/Silabs) stack by default (`znp`/Texas Instruments is selectable via `_ZIGBEE_STACK`). zigpy owns the Zigbee network, the ZCL, and device interviews; this integration adapts it to the Hub's `AbstractController` contract. The radio's serial port comes from `dependencies.hardware_interfaces[0]` (overridable via the `ZIGBEE_DEVICE_PATH` env var); zigpy's device database lives under the integration's `documents_folder` as `zigbee.db`. ## Files | File | Responsibility | |---|---| | `controller.py` | `AbstractController` implementation — lifecycle, pairing, control, fetch, and the zigpy network-event listener | | `mapper.py` | Zigbee ↔ MajorDom conversions: UUIDs (via the framework helpers), IEEE address handling, ZCL data-type and access-permission mapping | | `model.py` | Typed `integration_data` schemas (`ZBDevice`, `ZBParameter`, …) | | `listener.py` | Per-cluster attribute-report listener that forwards live changes to the Hub | | `zigbee_spec.py` | Static spec metadata: system clusters, attribute units/steps, and the main-parameter map | ## Discovery & pairing Zigbee has no separate discovery step — a device is on the network the moment it joins. `start_pairing_window` opens permit-join; a joining device is held as a discovery and **disconnected from the network if it isn't paired within 5 minutes** (`_disconnect_unpaired_discovery`). Pairing takes no credentials (`CredentialsType.none`). At pairing, the device's endpoints/clusters are walked to build the parameter list (attributes and commands), and a main (one-tap) parameter is chosen per `MAIN_PARAMETER_BY_CLUSTER`. ## Availability The zigpy listener drives availability: `device_left` marks a paired device unavailable, and `device_initialized` on a rejoin marks it back online (both via `_set_availability`, which dedupes and emits the framework's connect/lose callbacks). On boot, devices missing from the network are marked unavailable. The pairing always stays in the DB so a device comes back on rejoin. ## Parameter identity Every parameter id is derived through the framework UUID helpers as `parameter_uuid(device_id, "_//")`, so the id a parameter gets at pairing is identical to the one used on fetch and in live reports. ## Tests - `test_zigbee_controller_mocked.py` — an in-memory zigpy stub, no radio or devices needed (runs in CI). - `test_zigbee_controller_hardware.py` — a real device in the self-hosted IoT cage; manual only. Its hardcoded device/parameter ids must be regenerated for your DUT (see the note at the top of that file). ## Notes - ZCL reads are chunked (`_MAX_ATTRS_PER_REQUEST`) and paced (`_INTER_CHUNK_DELAY`) to avoid NCP ACK timeouts on busy serial links. ================================================================================ # FILE: majordom_zigbee/zigbee_spec.py ================================================================================ from typing import Any, NamedTuple from majordom_integration_sdk.schemas.parameter import ParameterRole, ParameterUnit, ParameterVisibility from .zigbee_spec_zha import ZHA_ATTRIBUTE_UX class MainParameterSpec(NamedTuple): """Value of MAIN_PARAMETER_BY_CLUSTER, keyed by cluster_id: what makes a sensible one-tap main_parameter for a device exposing that cluster. Iteration order is priority order — the first cluster below that a device exposes wins. - command main (default): ``target_id`` is a command id; ``default_arguments`` are sent with it (None = the command takes no arguments, e.g. OnOff.toggle). - attribute main (``is_attribute=True``): ``target_id`` is an attribute id; a tap writes it. For an enum attribute (e.g. FanControl.fan_mode) ``cycle`` is the value subset the tap rotates through (e.g. off/on for a toggle); it's stored on the parameter's ``default_value`` at pairing so the relay derives the next value. ``None`` -> cycle the param's full valid_values. """ target_id: int default_arguments: dict[str, Any] | None = None is_attribute: bool = False cycle: list[int] | None = None # Command/field names are from zigpy's cluster definitions. FanControl (0x0202) has no commands — # it's driven by the fan_mode attribute, so its one-tap is an ATTRIBUTE main that cycles off/on. MAIN_PARAMETER_BY_CLUSTER: dict[int, MainParameterSpec] = { 0x0006: MainParameterSpec(0x02, None), # OnOff.toggle 0x0008: MainParameterSpec( 0x04, {"level": 254, "transition_time": 0} ), # LevelControl.move_to_level_with_on_off (full) 0x0300: MainParameterSpec( 0x06, {"hue": 0, "saturation": 254, "transition_time": 0, "options_mask": 0, "options_override": 0} ), # Color.move_to_hue_and_saturation 0x0102: MainParameterSpec(0x05, {"percentage_lift_value": 100}), # WindowCovering.go_to_lift_percentage (open) 0x0101: MainParameterSpec(0x00, {"pin_code": None}), # DoorLock.lock_door # FanControl.fan_mode attribute — tap cycles Off(0) <-> On(4) (a toggle). 0x0202: MainParameterSpec(0x0000, is_attribute=True, cycle=[0x00, 0x04]), } SYSTEM_CLUSTERS: set[int] = { 0x0000, # Basic 0x0003, # Identify 0x0004, # Groups 0x0005, # Scenes 0x000A, # Time 0x000B, # RSSI 0x0019, # OTA 0x1000, # Touchlink } # --- Visibility curation (see docs/device-integration/parameter-ux recipe) ------------ # Zigbee attributes carry a Report flag, which is a decent "this is a live reading" signal, so the # controller keeps `reportable -> user` as a fallback. Curation refines it: # - EVERYDAY_CONTROL_ATTRIBUTES: writable everyday controls -> user (+ forced control role, since # zigpy sometimes under-declares access, e.g. FanControl.fan_mode has no flags at all) # - USER_READINGS: readings that must show even if a device doesn't mark them reportable -> user # - metadata (divisors/multipliers/bounds/tolerances, see is_metadata_attribute) -> system, even # if reportable, and reused as metadata sources (min/max/step) for the real parameters EVERYDAY_CONTROL_ATTRIBUTES: set[tuple[int, int]] = { (0x0202, 0x0000), # FanControl.fan_mode (zigpy declares no access flags — force it) (0x0201, 0x0011), # Thermostat.occupied_cooling_setpoint (the everyday "set the temp") (0x0201, 0x0012), # Thermostat.occupied_heating_setpoint (0x0201, 0x001C), # Thermostat.system_mode (off/heat/cool/auto) } USER_READINGS: set[tuple[int, int]] = { (0x0006, 0x0000), # OnOff.on_off (0x0008, 0x0000), # LevelControl.current_level (0x0201, 0x0000), # Thermostat.local_temperature (0x0402, 0x0000), # TemperatureMeasurement.measured_value (0x0405, 0x0000), # RelativeHumidity.measured_value (0x0400, 0x0000), # IlluminanceMeasurement.measured_value (0x0102, 0x0008), # WindowCovering.current_position_lift_percentage (0x0102, 0x0009), # WindowCovering.current_position_tilt_percentage (0x0001, 0x0021), # PowerConfiguration.battery_percentage_remaining (0x0500, 0x0002), # IasZone.zone_status (0x0101, 0x0000), # DoorLock.lock_state (0x0101, 0x0003), # DoorLock.door_state # Energy: the primary readings only (see CONFIG_HEAVY_CLUSTERS); the min/max/overload/phase-B/C # variants stay hidden. (0x0B04, 0x0304), # ElectricalMeasurement.ac_frequency (0x0B04, 0x0505), # ElectricalMeasurement.rms_voltage (0x0B04, 0x0508), # ElectricalMeasurement.rms_current (0x0B04, 0x050B), # ElectricalMeasurement.active_power (0x0B04, 0x0510), # ElectricalMeasurement.power_factor (0x0702, 0x0000), # Metering.current_summation_delivered (0x0702, 0x0400), # Metering.instantaneous_demand } # Per-attribute visibility overrides (win over everything). For the handful of readings that would # otherwise land in the wrong bucket via the reportable fallback. VISIBILITY_OVERRIDES: dict[tuple[int, int], ParameterVisibility] = { (0x0300, 0x0003): ParameterVisibility.system, # Color.current_x (CIE machine encoding — redundant with hue/sat) (0x0300, 0x0004): ParameterVisibility.system, # Color.current_y (0x0201, 0x0007): ParameterVisibility.setting, # Thermostat.pi_cooling_demand (diagnostic, not everyday) (0x0201, 0x0008): ParameterVisibility.setting, # Thermostat.pi_heating_demand } # Clusters where `reportable` is a poor "user reading" signal because most reportable attributes # are config/security/metadata: DoorLock (enable_* flags, event masks, credential counts) and the # electrical clusters (dozens of min/max/overload/phase-B/C variants). Only curated USER_READINGS/ # EVERYDAY reach `user`; the rest fall to setting/system. CONFIG_HEAVY_CLUSTERS: set[int] = {0x0101, 0x0B04, 0x0702} # DoorLock, ElectricalMeasurement, Metering # Commands that are everyday one-tap actions (-> user). Every other command on a non-system cluster # defaults to `setting` (advanced: schedule/credential/log management). ids from zigpy definitions. EVERYDAY_COMMANDS: set[tuple[int, int]] = { (0x0006, 0x00), (0x0006, 0x01), (0x0006, 0x02), # OnOff off / on / toggle (0x0008, 0x00), (0x0008, 0x04), # LevelControl move_to_level / _with_on_off (0x0300, 0x06), (0x0300, 0x0A), # Color move_to_hue_and_saturation / move_to_color_temp (0x0102, 0x00), (0x0102, 0x01), (0x0102, 0x02), (0x0102, 0x05), # WindowCovering up/down/stop/go_to_lift% (0x0101, 0x00), (0x0101, 0x01), # DoorLock lock_door / unlock_door (credential/schedule cmds stay setting) } # Attributes that are scaling constants / bounds / counts rather than user-facing readings. They # go to `system` and feed the metadata resolver. Curated set first; a conservative name heuristic # (below) catches the long tail (e.g. ElectricalMeasurement's ~18 *_divisor/*_multiplier attrs). METADATA_ATTRIBUTES: set[tuple[int, int]] = { (0x0400, 0x0001), # Illuminance.min_measured_value (0x0400, 0x0002), # Illuminance.max_measured_value (0x0402, 0x0001), # Temperature.min_measured_value (0x0402, 0x0002), # Temperature.max_measured_value (0x0405, 0x0001), # Humidity.min_measured_value (0x0405, 0x0002), # Humidity.max_measured_value } _METADATA_NAME_SUFFIXES: tuple[str, ...] = ("_divisor", "_multiplier") _METADATA_NAME_EXACT: frozenset[str] = frozenset( {"min_measured_value", "max_measured_value", "tolerance", "min_level", "max_level"} ) def is_metadata_attribute(cluster_id: int, attribute_id: int, name: str) -> bool: """Whether an attribute is a scaling constant / bound / count (metadata) rather than a user-facing reading — curated set first, conservative name heuristic as fallback.""" if (cluster_id, attribute_id) in METADATA_ATTRIBUTES: return True return name.endswith(_METADATA_NAME_SUFFIXES) or name in _METADATA_NAME_EXACT # --- The merged UX classification (priority ladder; see the zigbee README) -------------------- # Every source of a parameter's (visibility, role, unit) judgment funnels through classify_attribute. # role/unit of None mean "keep what the caller derived from zigpy" (access flags / get_unit()). class UxSpec(NamedTuple): visibility: ParameterVisibility role: ParameterRole | None = None unit: ParameterUnit | None = None # zha unit strings / device_classes -> our ParameterUnit. Shared by the runtime quirk-v2 reader # and mirrored (standalone) in scripts/harvest_zha.py. Unmapped -> None (keep get_unit()). _ZHA_UNIT_BY_STRING: dict[str, ParameterUnit] = { "°C": ParameterUnit.celsius, "%": ParameterUnit.percentage, "V": ParameterUnit.volt, "A": ParameterUnit.ampere, "W": ParameterUnit.watt, "Hz": ParameterUnit.hertz, "lx": ParameterUnit.lux, "kWh": ParameterUnit.kwh, "ppm": ParameterUnit.ppm, "µg/m³": ParameterUnit.ugm3, "Pa": ParameterUnit.pascal, "hPa": ParameterUnit.pascal, "kPa": ParameterUnit.pascal, "K": ParameterUnit.kelvin, "mired": ParameterUnit.mired, "m³/h": ParameterUnit.m3h, "s": ParameterUnit.second, } _ZHA_UNIT_BY_DEVICE_CLASS: dict[str, ParameterUnit] = { "temperature": ParameterUnit.celsius, "humidity": ParameterUnit.percentage, "battery": ParameterUnit.percentage, "illuminance": ParameterUnit.lux, "power": ParameterUnit.watt, "voltage": ParameterUnit.volt, "current": ParameterUnit.ampere, "energy": ParameterUnit.kwh, "pressure": ParameterUnit.pascal, "frequency": ParameterUnit.hertz, } def unit_from_zha(device_class: str | None, unit: str | None) -> ParameterUnit | None: """Translate a zha/HA unit string or device_class to our ParameterUnit, or None if unknown.""" if unit and unit in _ZHA_UNIT_BY_STRING: return _ZHA_UNIT_BY_STRING[unit] if device_class and device_class in _ZHA_UNIT_BY_DEVICE_CLASS: return _ZHA_UNIT_BY_DEVICE_CLASS[device_class] return None def _our_attribute_ux() -> dict[tuple[int, int], UxSpec]: """Our hand curation as UX overrides — the single highest static-priority source (a human's call wins over harvested/quirk judgment). Built from the curated sets so there's one source of truth per attribute.""" out: dict[tuple[int, int], UxSpec] = {} for key in USER_READINGS: out[key] = UxSpec(ParameterVisibility.user, ParameterRole.sensor) for key in EVERYDAY_CONTROL_ATTRIBUTES: out[key] = UxSpec(ParameterVisibility.user, ParameterRole.control) for key, vis in VISIBILITY_OVERRIDES.items(): out[key] = UxSpec(vis) return out OUR_ATTRIBUTE_UX: dict[tuple[int, int], UxSpec] = _our_attribute_ux() def _zha_uxspec(key: tuple[int, int]) -> UxSpec | None: t = ZHA_ATTRIBUTE_UX.get(key) if t is None: return None return UxSpec(ParameterVisibility(t[0]), ParameterRole(t[1]), ParameterUnit(t[2])) # Flip to True once harvested + quirk coverage is validated on real devices: unmatched attributes # then hide (system) instead of the reportable->user heuristic. See the zigbee README (fallback). _FALLBACK_HIDE_UNCURATED = False def classify_attribute( cluster_id: int, attribute_id: int, name: str, *, writable: bool, reportable: bool, quirk_ux: UxSpec | None = None, ) -> tuple[UxSpec, str]: """Resolve an attribute's (visibility, role, unit) by the priority ladder, returning the spec and a source tag (for logging / drift). Ladder, first match wins: 1. OUR_ATTRIBUTE_UX — hand curation, a human's call wins over everything 2. metadata / manufacturer-on-system-cluster — forced system (safety) 3. quirk_ux — v2 QuirkBuilder entity metadata (runtime, device-specific) 4. ZHA_ATTRIBUTE_UX — harvested standard-spec judgment 5. fallback policy — heuristic; WARNS (uncurated attribute) """ key = (cluster_id, attribute_id) if (spec := OUR_ATTRIBUTE_UX.get(key)) is not None: return spec, "ours" if is_metadata_attribute(cluster_id, attribute_id, name): return UxSpec(ParameterVisibility.system), "metadata" # Manufacturer-specific attrs (>= 0xF000) on infrastructure clusters stay hidden. if attribute_id >= 0xF000 and cluster_id in SYSTEM_CLUSTERS: return UxSpec(ParameterVisibility.system), "mfr-on-system-cluster" if quirk_ux is not None: return quirk_ux, "quirk-v2" if (spec := _zha_uxspec(key)) is not None: return spec, "zha" # 5. Fallback policy (uncurated) — WARNS. Kept as a heuristic until coverage is validated. if _FALLBACK_HIDE_UNCURATED or cluster_id in SYSTEM_CLUSTERS: return UxSpec(ParameterVisibility.system), "fallback-system" if reportable and cluster_id not in CONFIG_HEAVY_CLUSTERS: return UxSpec(ParameterVisibility.user), "fallback-reportable" if writable: return UxSpec(ParameterVisibility.setting), "fallback-writable" return UxSpec(ParameterVisibility.system), "fallback-system" class MetadataSource(NamedTuple): """Sibling attributes whose runtime VALUES provide a parameter's min/max — the device's own limit attributes (the ones we hide as metadata via ``is_metadata_attribute``). Priority 1 in the resolver: runtime sibling value > wire-type default. Mirrors Matter's ``MetadataSource``.""" min_attr: int | None = None max_attr: int | None = None # (cluster_id, attribute_id) of a shown parameter -> the sibling min/max limit attributes on the # same cluster whose runtime values bound it. The mirror of Matter's METADATA_SOURCES. METADATA_SOURCES: dict[tuple[int, int], MetadataSource] = { (0x0008, 0x0000): MetadataSource(0x0002, 0x0003), # LevelControl.current_level <- min/max_level (0x0400, 0x0000): MetadataSource(0x0001, 0x0002), # Illuminance.measured_value <- min/max_measured_value (0x0402, 0x0000): MetadataSource(0x0001, 0x0002), # Temperature.measured_value <- min/max_measured_value (0x0403, 0x0000): MetadataSource(0x0001, 0x0002), # Pressure.measured_value <- min/max_measured_value (0x0404, 0x0000): MetadataSource(0x0001, 0x0002), # Flow.measured_value <- min/max_measured_value (0x0405, 0x0000): MetadataSource(0x0001, 0x0002), # Humidity.measured_value <- min/max_measured_value # Thermostat setpoints bounded by the device's absolute-limit attributes. (0x0201, 0x0011): MetadataSource(0x0007, 0x0008), # occupied_cooling_setpoint <- abs_min/max_cool_setpoint_limit (0x0201, 0x0012): MetadataSource(0x0003, 0x0004), # occupied_heating_setpoint <- abs_min/max_heat_setpoint_limit } def resolve_metadata_bounds( cluster_id: int, attribute_id: int, get_value, default_min, default_max, ) -> tuple[Any, Any, list[int]]: """Metadata priority 1: override a parameter's min/max with the device's own limit attributes' runtime VALUES. ``get_value(attr_id)`` returns the cached sibling value (or None if the device doesn't report it). Returns ``(min, max, missing)`` where ``missing`` lists the expected source attribute ids that had no value — the caller warns so a quirk / omitted limit surfaces in logs.""" source = METADATA_SOURCES.get((cluster_id, attribute_id)) if source is None: return default_min, default_max, [] min_v, max_v = default_min, default_max missing: list[int] = [] for attr_id, is_min in ((source.min_attr, True), (source.max_attr, False)): if attr_id is None: continue resolved = get_value(attr_id) if resolved is not None: if is_min: min_v = resolved else: max_v = resolved else: missing.append(attr_id) return min_v, max_v, missing # (cluster_id, attribute_id) -> ParameterUnit ATTRIBUTE_UNITS: dict[tuple[int, int], ParameterUnit] = { # ------------------------- # General # ------------------------- # Level Control (0x0008) (0x0008, 0x0000): ParameterUnit.percentage, # current_level (0x0008, 0x0011): ParameterUnit.percentage, # on_level (0x0008, 0x0014): ParameterUnit.percentage, # default_move_rate (0x0008, 0x4000): ParameterUnit.percentage, # start_up_current_level # ------------------------- # Lighting (0x0300) # ------------------------- # Color Control (0x0300) (0x0300, 0x0001): ParameterUnit.percentage, # current_hue (0x0300, 0x0003): ParameterUnit.percentage, # current_saturation (0x0300, 0x0007): ParameterUnit.mired, # color_temperature_mireds (0x0300, 0x4010): ParameterUnit.plain, # color_temp_physical_min_mireds (0x0300, 0x4011): ParameterUnit.plain, # color_temp_physical_max_mireds (0x0300, 0x4012): ParameterUnit.plain, # couple_color_temp_to_level_min_mireds (0x0300, 0x4013): ParameterUnit.plain, # start_up_color_temperature # ------------------------- # Measurement & Sensing # ------------------------- # Illuminance Measurement (0x0400) (0x0400, 0x0000): ParameterUnit.lux, (0x0400, 0x0001): ParameterUnit.lux, (0x0400, 0x0002): ParameterUnit.lux, # Temperature Measurement (0x0402) (0x0402, 0x0000): ParameterUnit.celsius, (0x0402, 0x0001): ParameterUnit.celsius, (0x0402, 0x0002): ParameterUnit.celsius, (0x0402, 0x0010): ParameterUnit.celsius, # tolerance # Pressure Measurement (0x0403) (0x0403, 0x0000): ParameterUnit.pascal, # hPa — nearest in enum (0x0403, 0x0001): ParameterUnit.pascal, (0x0403, 0x0002): ParameterUnit.pascal, (0x0403, 0x0010): ParameterUnit.pascal, (0x0403, 0x0011): ParameterUnit.pascal, (0x0403, 0x0012): ParameterUnit.pascal, # Flow Measurement (0x0404) (0x0404, 0x0000): ParameterUnit.m3h, # flow, m³/h (0x0404, 0x0001): ParameterUnit.mps, (0x0404, 0x0002): ParameterUnit.mps, # Relative Humidity (0x0405) (0x0405, 0x0000): ParameterUnit.percentage, (0x0405, 0x0001): ParameterUnit.percentage, (0x0405, 0x0002): ParameterUnit.percentage, (0x0405, 0x0010): ParameterUnit.percentage, # Leaf Wetness (0x0407) (0x0407, 0x0000): ParameterUnit.percentage, (0x0407, 0x0001): ParameterUnit.percentage, (0x0407, 0x0002): ParameterUnit.percentage, # Soil Moisture (0x0408) (0x0408, 0x0000): ParameterUnit.percentage, (0x0408, 0x0001): ParameterUnit.percentage, (0x0408, 0x0002): ParameterUnit.percentage, # Wind Speed (0x040C) (0x040C, 0x0000): ParameterUnit.mps, (0x040C, 0x0001): ParameterUnit.mps, (0x040C, 0x0002): ParameterUnit.mps, # Carbon Monoxide (0x040A) (0x040A, 0x0000): ParameterUnit.ppm, (0x040A, 0x0001): ParameterUnit.ppm, (0x040A, 0x0002): ParameterUnit.ppm, # Carbon Dioxide / TVOC (0x040D) (0x040D, 0x0000): ParameterUnit.ppm, (0x040D, 0x0001): ParameterUnit.ppm, (0x040D, 0x0002): ParameterUnit.ppm, # PM2.5 (0x042A) (0x042A, 0x0000): ParameterUnit.ugm3, # PM2.5, µg/m³ (0x042A, 0x0001): ParameterUnit.ppm, (0x042A, 0x0002): ParameterUnit.ppm, # Electrical Conductivity (0x040B) (0x040B, 0x0000): ParameterUnit.plain, # µS/cm — not in enum # ------------------------- # HVAC # ------------------------- # Thermostat (0x0201) (0x0201, 0x0000): ParameterUnit.celsius, # local_temperature (0x0201, 0x0001): ParameterUnit.celsius, # outdoor_temperature (0x0201, 0x0003): ParameterUnit.celsius, # abs_min_heat_setpoint_limit (0x0201, 0x0004): ParameterUnit.celsius, # abs_max_heat_setpoint_limit (0x0201, 0x0005): ParameterUnit.celsius, # abs_min_cool_setpoint_limit (0x0201, 0x0006): ParameterUnit.celsius, # abs_max_cool_setpoint_limit (0x0201, 0x0007): ParameterUnit.percentage, # pi_cooling_demand (0x0201, 0x0008): ParameterUnit.percentage, # pi_heating_demand (0x0201, 0x0010): ParameterUnit.celsius, # local_temperature_calibration (0x0201, 0x0011): ParameterUnit.celsius, # occupied_cooling_setpoint (0x0201, 0x0012): ParameterUnit.celsius, # occupied_heating_setpoint (0x0201, 0x0013): ParameterUnit.celsius, # unoccupied_cooling_setpoint (0x0201, 0x0014): ParameterUnit.celsius, # unoccupied_heating_setpoint (0x0201, 0x0015): ParameterUnit.celsius, # min_heat_setpoint_limit (0x0201, 0x0016): ParameterUnit.celsius, # max_heat_setpoint_limit (0x0201, 0x0017): ParameterUnit.celsius, # min_cool_setpoint_limit (0x0201, 0x0018): ParameterUnit.celsius, # max_cool_setpoint_limit (0x0201, 0x0019): ParameterUnit.celsius, # min_setpoint_dead_band # ------------------------- # Electrical Measurement (0x0B04) # ------------------------- (0x0B04, 0x0304): ParameterUnit.hertz, # ac_frequency (0x0B04, 0x0305): ParameterUnit.hertz, # ac_frequency_min (0x0B04, 0x0306): ParameterUnit.hertz, # ac_frequency_max (0x0B04, 0x0505): ParameterUnit.volt, # rms_voltage (0x0B04, 0x0506): ParameterUnit.volt, # rms_voltage_min (0x0B04, 0x0507): ParameterUnit.volt, # rms_voltage_max (0x0B04, 0x0508): ParameterUnit.ampere, # rms_current (0x0B04, 0x0509): ParameterUnit.ampere, # rms_current_min (0x0B04, 0x050A): ParameterUnit.ampere, # rms_current_max (0x0B04, 0x050B): ParameterUnit.watt, # active_power (0x0B04, 0x050C): ParameterUnit.watt, # active_power_min (0x0B04, 0x050D): ParameterUnit.watt, # active_power_max (0x0B04, 0x050E): ParameterUnit.watt, # reactive_power (0x0B04, 0x050F): ParameterUnit.watt, # apparent_power (VA → watt approximation) (0x0B04, 0x0510): ParameterUnit.percentage, # power_factor (0x0B04, 0x0605): ParameterUnit.volt, # phase_b rms_voltage (0x0B04, 0x0608): ParameterUnit.ampere, # phase_b rms_current (0x0B04, 0x060B): ParameterUnit.watt, # phase_b active_power (0x0B04, 0x0705): ParameterUnit.volt, # phase_c rms_voltage (0x0B04, 0x0708): ParameterUnit.ampere, # phase_c rms_current (0x0B04, 0x070B): ParameterUnit.watt, # phase_c active_power # ------------------------- # Metering (0x0702) # ------------------------- (0x0702, 0x0000): ParameterUnit.kwh, # current_summation_delivered (0x0702, 0x0001): ParameterUnit.joule, # current_summation_received (0x0702, 0x0002): ParameterUnit.joule, # current_max_demand_delivered (0x0702, 0x0003): ParameterUnit.joule, # current_max_demand_received (0x0702, 0x0400): ParameterUnit.watt, # instantaneous_demand (0x0702, 0x0200): ParameterUnit.joule, # current_day_consumption_delivered (0x0702, 0x0201): ParameterUnit.joule, # previous_day_consumption_delivered # ------------------------- # Closures # ------------------------- # Window Covering (0x0102) (0x0102, 0x0008): ParameterUnit.percentage, # current_position_lift_percentage (0x0102, 0x0009): ParameterUnit.percentage, # current_position_tilt_percentage (0x0102, 0x000E): ParameterUnit.percentage, # current_position_lift_percent100ths (0x0102, 0x000F): ParameterUnit.percentage, # current_position_tilt_percent100ths # ------------------------- # Device Temperature (0x0002) # ------------------------- (0x0002, 0x0000): ParameterUnit.celsius, # current_temperature (0x0002, 0x0001): ParameterUnit.celsius, # min_temp_experienced (0x0002, 0x0002): ParameterUnit.celsius, # max_temp_experienced (0x0002, 0x0010): ParameterUnit.celsius, # low_temp_threshold (0x0002, 0x0011): ParameterUnit.celsius, # high_temp_threshold # ------------------------- # Power Configuration (0x0001) # ------------------------- (0x0001, 0x0000): ParameterUnit.volt, # mains_voltage (0x0001, 0x0001): ParameterUnit.hertz, # mains_frequency (0x0001, 0x0010): ParameterUnit.volt, # mains_voltage_min_threshold (0x0001, 0x0011): ParameterUnit.volt, # mains_voltage_max_threshold (0x0001, 0x0020): ParameterUnit.volt, # battery_voltage (0x0001, 0x0021): ParameterUnit.percentage, # battery_percentage_remaining } # (cluster_id, attribute_id) -> min_step ATTRIBUTE_MIN_STEP: dict[tuple[int, int], int | float] = { # Level Control (0x0008) (0x0008, 0x0000): 1, # current_level # Color Control (0x0300) (0x0300, 0x0001): 1, # current_hue (0x0300, 0x0003): 1, # current_saturation (0x0300, 0x0007): 1, # color_temperature_mireds # Thermostat (0x0201) (0x0201, 0x0010): 0.1, # local_temperature_calibration (0x0201, 0x0011): 0.5, # occupied_cooling_setpoint (0x0201, 0x0012): 0.5, # occupied_heating_setpoint (0x0201, 0x0013): 0.5, # unoccupied_cooling_setpoint (0x0201, 0x0014): 0.5, # unoccupied_heating_setpoint # Temperature Measurement (0x0402) (0x0402, 0x0000): 0.01, # resolution 0.01°C в ZCL # Relative Humidity (0x0405) (0x0405, 0x0000): 0.01, # resolution 0.01% # Pressure Measurement (0x0403) (0x0403, 0x0000): 0.1, # Window Covering (0x0102) (0x0102, 0x0008): 1, (0x0102, 0x0009): 1, # Electrical Measurement (0x0B04) (0x0B04, 0x0505): 0.1, # rms_voltage (0x0B04, 0x0508): 0.01, # rms_current (0x0B04, 0x050B): 0.1, # active_power # Metering (0x0702) (0x0702, 0x0000): 0.001, # kWh (0x0702, 0x0400): 1, # instantaneous_demand W } def get_unit(cluster_id: int, attribute_id: int) -> ParameterUnit: return ATTRIBUTE_UNITS.get((cluster_id, attribute_id), ParameterUnit.plain) def get_min_step(cluster_id: int, attribute_id: int) -> int | float | None: return ATTRIBUTE_MIN_STEP.get((cluster_id, attribute_id)) ================================================================================ # FILE: majordom_zigbee/zigbee_spec_zha.py ================================================================================ """GENERATED by scripts/harvest_zha.py — DO NOT EDIT BY HAND. Source: zha==2.0.1 (Apache-2.0). Harvested standard-cluster entity judgment (entity_category -> visibility, platform -> role, unit). Refresh by re-running the harvester; put hand overrides in zigbee_spec.py (OUR_ATTRIBUTE_UX), which win on merge. """ # (cluster_id, attribute_id) -> (visibility, role, unit) ZHA_ATTRIBUTE_UX: dict[tuple[int, int], tuple[str, str, str]] = { (0x0000, 0x0007): ("setting", "sensor", "plain"), (0x0001, 0x0021): ("setting", "sensor", "percentage"), (0x0002, 0x0000): ("setting", "sensor", "celsius"), (0x0006, 0x0000): ("user", "control", "plain"), (0x0006, 0x4003): ("setting", "control", "plain"), (0x0008, 0x0010): ("setting", "control", "plain"), (0x0008, 0x0011): ("setting", "control", "plain"), (0x0008, 0x0012): ("setting", "control", "plain"), (0x0008, 0x0013): ("setting", "control", "plain"), (0x0008, 0x0014): ("setting", "control", "plain"), (0x0008, 0x4000): ("setting", "control", "plain"), (0x000C, 0x0055): ("user", "sensor", "plain"), (0x000F, 0x0055): ("user", "sensor", "plain"), (0x0102, 0x0000): ("setting", "sensor", "plain"), (0x0102, 0x0007): ("setting", "control", "plain"), (0x0201, 0x0008): ("setting", "sensor", "percentage"), (0x0201, 0x0010): ("setting", "control", "celsius"), (0x0201, 0x0015): ("setting", "control", "celsius"), (0x0201, 0x0016): ("setting", "control", "celsius"), (0x0201, 0x0030): ("setting", "sensor", "plain"), (0x0201, 0x0032): ("setting", "sensor", "plain"), (0x0204, 0x0001): ("setting", "control", "plain"), (0x0300, 0x4010): ("setting", "control", "plain"), (0x0301, 0x0010): ("setting", "control", "plain"), (0x0301, 0x0011): ("setting", "control", "plain"), (0x0400, 0x0000): ("user", "sensor", "lux"), (0x0402, 0x0000): ("user", "sensor", "celsius"), (0x0403, 0x0000): ("user", "sensor", "pascal"), (0x0404, 0x0000): ("user", "sensor", "m3h"), (0x0405, 0x0000): ("user", "sensor", "percentage"), (0x0406, 0x0000): ("user", "sensor", "plain"), (0x0406, 0x0010): ("setting", "control", "second"), (0x0406, 0x0011): ("setting", "control", "second"), (0x0406, 0x0020): ("setting", "control", "plain"), (0x0406, 0x0022): ("setting", "control", "plain"), (0x0407, 0x0000): ("user", "sensor", "percentage"), (0x0408, 0x0000): ("user", "sensor", "percentage"), (0x040A, 0x0000): ("user", "sensor", "plain"), (0x040B, 0x0000): ("user", "sensor", "plain"), (0x040C, 0x0000): ("user", "sensor", "ppm"), (0x040D, 0x0000): ("user", "sensor", "ppm"), (0x042A, 0x0000): ("user", "sensor", "plain"), (0x042B, 0x0000): ("user", "sensor", "ppm"), (0x0500, 0x0002): ("user", "sensor", "plain"), (0x0702, 0x0000): ("user", "sensor", "plain"), (0x0702, 0x0001): ("user", "sensor", "plain"), (0x0702, 0x0100): ("user", "sensor", "plain"), (0x0702, 0x0102): ("user", "sensor", "plain"), (0x0702, 0x0104): ("user", "sensor", "plain"), (0x0702, 0x0106): ("user", "sensor", "plain"), (0x0702, 0x0108): ("user", "sensor", "plain"), (0x0702, 0x010A): ("user", "sensor", "plain"), (0x0702, 0x0400): ("user", "sensor", "plain"), (0x0B04, 0x0100): ("user", "sensor", "volt"), (0x0B04, 0x0103): ("user", "sensor", "ampere"), (0x0B04, 0x0106): ("user", "sensor", "watt"), (0x0B04, 0x0300): ("user", "sensor", "hertz"), (0x0B04, 0x0304): ("user", "sensor", "watt"), (0x0B04, 0x0505): ("user", "sensor", "volt"), (0x0B04, 0x0508): ("user", "sensor", "ampere"), (0x0B04, 0x050B): ("user", "sensor", "watt"), (0x0B04, 0x050F): ("user", "sensor", "plain"), (0x0B04, 0x0510): ("user", "sensor", "percentage"), (0x0B04, 0x0905): ("user", "sensor", "volt"), (0x0B04, 0x0908): ("user", "sensor", "ampere"), (0x0B04, 0x090B): ("user", "sensor", "watt"), (0x0B04, 0x0910): ("user", "sensor", "percentage"), (0x0B04, 0x0A05): ("user", "sensor", "volt"), (0x0B04, 0x0A08): ("user", "sensor", "ampere"), (0x0B04, 0x0A0B): ("user", "sensor", "watt"), (0x0B04, 0x0A10): ("user", "sensor", "percentage"), } ================================================================================ # FILE: pyproject.toml ================================================================================ [project] name = "majordom-zigbee" version = "0.1.5" description = "Zigbee integration for MajorDom — bridges Zigbee devices into the MajorDom language." authors = [{ name = "Mark Parker", email = "mark@parker-programs.com" }] readme = "README.md" # AGPL-3.0 is required, not chosen: this package is effectively a wrapper around the GPL-3.0 # zigpy / zigpy-znp / bellows stack, so the combined work must carry a compatible copyleft # license. Commercial (non-AGPL) terms are available separately — see the partnership link. license = "AGPL-3.0-or-later" license-files = ["LICENSE"] keywords = ["majordom", "zigbee", "zigpy", "smart-home", "home-automation", "iot", "integration", "coordinator", "802-15-4"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Topic :: Home Automation", "Programming Language :: Python :: 3", "Operating System :: POSIX :: Linux", ] requires-python = ">=3.12,<3.14" dependencies = [ "majordom-integration-sdk (>=0.1.5,<1.0)", "zigpy (>=2.0.0,<3.0.0)", "zigpy-znp (>=1.1.0,<2.0.0)", "bellows (>=0.49.2,<0.50.0)", # zha-quirks (zhaquirks) applies per-device quirks at the zigpy layer: it decodes # manufacturer clusters into named/typed attributes and carries v2 entity metadata. # Requires zigpy>=2.0, which is why the stack was bumped off the 0.88 line. "zha-quirks (>=2.1.1,<3.0.0)", ] [project.urls] Homepage = "https://majordom.io" Documentation = "https://docs.majordom.io/device-integration" Repository = "https://github.com/MajorDom-Systems/integration-zigbee" Issues = "https://github.com/MajorDom-Systems/integration-zigbee/issues" "Commercial licensing" = "https://parker-industries.org/partnership" [dependency-groups] dev = [ "poethepoet (>=0.36.0,<1.0.0)", "ty", "ruff", "pip-audit", "pytest", "pytest-asyncio", "pytest-cov", "pytest-timeout", "pytest-repeat", "pytest-benchmark", "pytest-profiling", "pytest-resource-usage", "coverage", "pympler", "psutil", "faker", ] [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" [tool.poetry] requires-poetry = ">=2.0" packages = [{ include = "majordom_zigbee" }] [tool.pytest.ini_options] asyncio_mode = "auto" pythonpath = ["."] filterwarnings = ["once"] [tool.coverage.run] omit = ["tests/*"] [tool.ruff] line-length = 120 indent-width = 4 [tool.ruff.lint] select = ["E", "F", "UP", "B", "SIM", "I"] fixable = ["ALL"] [tool.ruff.format] quote-style = "double" indent-style = "space" line-ending = "auto" docstring-code-format = true [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] "tests/*" = ["E402"] # The zigpy ControllerApplication test double intentionally uses loose signatures — scoped to # conftest.py (the only test file that needs it) so the rest of the suite stays fully checked. [[tool.ty.overrides]] include = ["tests/conftest.py"] [tool.ty.overrides.rules] invalid-method-override = "ignore" invalid-assignment = "ignore" invalid-argument-type = "ignore" [tool.poe.tasks.install] shell = """ poetry install echo '#!/bin/sh\npoetry run poe check' > .git/hooks/pre-commit chmod +x .git/hooks/pre-commit echo "Pre-commit hook installed." """ [tool.poe.tasks.check] shell = """ code=0 echo "\nRunning ruff lint:" poetry run ruff check --fix || code=1 echo "\nRunning ruff format:" poetry run ruff format || code=1 echo "\nRunning ty:" poetry run ty check || code=1 echo "\nRunning pytest:" poetry run pytest --cov=majordom_zigbee --cov-report=xml || code=1 echo "\nRunning poetry build:" poetry build || code=1 echo "\nRunning poetry check:" poetry check || code=1 ${ci:+git diff --exit-code} exit $code """ args = [{ name = "ci", type = "boolean" }] ================================================================================ # FILE: scripts/check_zha_drift.py ================================================================================ #!/usr/bin/env python3 """CI drift check for the vendored zha harvest — the Dependabot-style refresher. Re-harvests zha (must be installed in the CI/throwaway venv) and diffs the result against the committed ``zigbee_spec_zha.py``. Exit codes let CI decide what to do: 0 no drift 1 drift, low/medium risk only (ADD/REMOVE) -> open an auto-refresh PR 2 drift includes RECLASSIFY (HIGH risk) -> PR flagged for human review Run: python scripts/check_zha_drift.py """ from __future__ import annotations import sys from majordom_integration_sdk.spec_drift import diff_specs from majordom_zigbee.zigbee_spec_zha import ZHA_ATTRIBUTE_UX as COMMITTED from scripts.harvest_zha import harvest def main() -> int: current, _skipped = harvest() report = diff_specs(current, COMMITTED) print(report.render(source="zha"), file=sys.stderr) if report.is_empty: return 0 return 2 if report.has_high_risk else 1 if __name__ == "__main__": raise SystemExit(main()) ================================================================================ # FILE: scripts/harvest_zha.py ================================================================================ #!/usr/bin/env python3 """Harvest zha's standard-cluster entity judgment into ``majordom_zigbee/zigbee_spec_zha.py``. DEV / BUILD TOOL — run in a throwaway venv that has ``zha`` installed. ``zha`` is **not** a runtime dependency of this integration: we consume it here as a data source and vendor the result. See the parameter-ux recipe and the zigbee README's priority ladder. What it takes (the genuinely additive "judgment" that isn't in zigpy): per standard ``(cluster_id, attribute_id)``, zha's ``entity_category`` (→ our visibility), the entity platform (→ our role), and unit/device_class (→ our ParameterUnit). What it drops: names, types, valid_values, raw bounds — we already derive those from zigpy directly. Usage: python scripts/harvest_zha.py # writes the generated module python scripts/harvest_zha.py --stdout # print instead (for CI diffing) """ from __future__ import annotations import argparse import importlib import sys from collections import defaultdict from importlib.metadata import version import zigpy.zcl _ZHA_VERSION = version("zha") # Platform modules whose import fires the @register_entity side effects into ENTITY_REGISTRY. _PLATFORMS = ( "sensor", "binary_sensor", "switch", "number", "select", "climate", "lock", "cover", "fan", "light", "button", "siren", ) # zha unit strings / device_classes -> our ParameterUnit value. Anything unmapped -> "plain". _UNIT_BY_STRING = { "°C": "celsius", "%": "percentage", "V": "volt", "A": "ampere", "W": "watt", "Hz": "hertz", "lx": "lux", "kWh": "kwh", "ppm": "ppm", "µg/m³": "ugm3", "Pa": "pascal", "hPa": "pascal", "kPa": "pascal", "K": "kelvin", "mired": "mired", "m³/h": "m3h", "s": "second", } _UNIT_BY_DEVICE_CLASS = { "temperature": "celsius", "humidity": "percentage", "battery": "percentage", "illuminance": "lux", "power": "watt", "voltage": "volt", "current": "ampere", "energy": "kwh", "pressure": "pascal", "frequency": "hertz", } # Platforms that write the device (control) vs. read it (sensor). _CONTROL_PLATFORMS = {"switch", "number", "select", "climate", "lock", "cover", "fan", "light", "button", "siren"} def _unit_for(dc: str | None, unit: str | None) -> str: if unit and unit in _UNIT_BY_STRING: return _UNIT_BY_STRING[unit] if dc and dc in _UNIT_BY_DEVICE_CLASS: return _UNIT_BY_DEVICE_CLASS[dc] return "plain" def _enum_value(x: object) -> str | None: # HA enums carry str values; plain strings pass through unchanged. return str(getattr(x, "value", x)) if x is not None else None def harvest() -> tuple[dict[tuple[int, int], tuple[str, str, str]], list[str]]: for p in _PLATFORMS: importlib.import_module(f"zha.application.platforms.{p}") from zha.application.platforms import ENTITY_REGISTRY reg = zigpy.zcl.Cluster._registry # Collect every (visibility, role, unit) vote per key, then reduce with a fixed policy. votes: dict[tuple[int, int], list[tuple[str, str, str]]] = defaultdict(list) skipped: list[str] = [] for cluster_id, classes in ENTITY_REGISTRY.items(): cluster_cls = reg.get(cluster_id) for cls in classes: attr_name = getattr(cls, "_attribute_name", None) platform = cls.__module__.split(".")[-1] if attr_name is None: # Composite entities (light/climate/cover) map a whole cluster, not one attr. skipped.append(f"{cluster_id:#06x} {platform}:{cls.__name__} (no single attribute)") continue if cluster_cls is None or attr_name not in cluster_cls.attributes_by_name: skipped.append(f"{cluster_id:#06x} {platform}:{attr_name} (id unresolved)") continue attr_id = cluster_cls.attributes_by_name[attr_name].id ec = _enum_value(getattr(cls, "_attr_entity_category", None)) visibility = "user" if ec is None else "setting" # config/diagnostic -> settings tap role = "control" if platform in _CONTROL_PLATFORMS else "sensor" unit = _unit_for( _enum_value(getattr(cls, "_attr_device_class", None)), getattr(cls, "_attr_native_unit_of_measurement", None), ) votes[(cluster_id, attr_id)].append((visibility, role, unit)) # Reduce collisions (one cluster attr can back several entities across device types): # visibility: most-visible wins (user > setting) role: control > sensor # unit: first non-plain out: dict[tuple[int, int], tuple[str, str, str]] = {} for key, vs in votes.items(): visibility = "user" if any(v[0] == "user" for v in vs) else "setting" role = "control" if any(v[1] == "control" for v in vs) else "sensor" unit = next((v[2] for v in vs if v[2] != "plain"), "plain") out[key] = (visibility, role, unit) return out, skipped def render(data: dict[tuple[int, int], tuple[str, str, str]]) -> str: lines = [ '"""GENERATED by scripts/harvest_zha.py — DO NOT EDIT BY HAND.', "", f"Source: zha=={_ZHA_VERSION} (Apache-2.0). Harvested standard-cluster entity", "judgment (entity_category -> visibility, platform -> role, unit). Refresh by re-running", "the harvester; put hand overrides in zigbee_spec.py (OUR_ATTRIBUTE_UX), which win on merge.", '"""', "", "# (cluster_id, attribute_id) -> (visibility, role, unit)", "ZHA_ATTRIBUTE_UX: dict[tuple[int, int], tuple[str, str, str]] = {", ] for (cid, aid), (vis, role, unit) in sorted(data.items()): lines.append(f" ({cid:#06x}, {aid:#06x}): ({vis!r}, {role!r}, {unit!r}),") lines.append("}") return "\n".join(lines) + "\n" def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--stdout", action="store_true", help="print instead of writing the module") ap.add_argument("--out", default="majordom_zigbee/zigbee_spec_zha.py") args = ap.parse_args() data, skipped = harvest() text = render(data) if args.stdout: sys.stdout.write(text) else: with open(args.out, "w") as f: f.write(text) print(f"[harvest_zha] zha=={_ZHA_VERSION}: wrote {len(data)} entries -> {args.out}", file=sys.stderr) print(f"[harvest_zha] {len(skipped)} composite/unresolved entities skipped", file=sys.stderr) return 0 if __name__ == "__main__": raise SystemExit(main()) ================================================================================ # FILE: scripts/param_ux_audit.py ================================================================================ """Run majordom's zigbee visibility/role mapping across the ENTIRE ZCL surface (all zigpy clusters), replicating controller.py's parameter-build rules, and bucket the result the way the iOS app would show it: main / user / setting / system. Read-only static audit — no hardware.""" import importlib import inspect from zigpy.zcl import Cluster from zigpy.zcl.foundation import ZCLAttributeAccess from majordom_zigbee.zigbee_spec import ( CONFIG_HEAVY_CLUSTERS, EVERYDAY_COMMANDS, EVERYDAY_CONTROL_ATTRIBUTES, MAIN_PARAMETER_BY_CLUSTER, SYSTEM_CLUSTERS, USER_READINGS, VISIBILITY_OVERRIDES, is_metadata_attribute, ) MODULES = [ "general", "measurement", "lighting", "hvac", "closures", "homeautomation", "security", "smartenergy", "protocol", "lightlink", "manufacturer_specific", ] def clusters(): seen = {} for m in MODULES: try: mod = importlib.import_module(f"zigpy.zcl.clusters.{m}") except Exception: continue for _, obj in inspect.getmembers(mod, inspect.isclass): if issubclass(obj, Cluster) and obj is not Cluster and getattr(obj, "cluster_id", None) is not None: seen.setdefault(obj.cluster_id, obj) return dict(sorted(seen.items())) def attr_visibility(cluster_id, attr_id, access, name): # replicate controller.py curated visibility policy key = (cluster_id, attr_id) if key in VISIBILITY_OVERRIDES: return VISIBILITY_OVERRIDES[key].value vis = "system" if attr_id < 0xF000 or cluster_id not in SYSTEM_CLUSTERS: if is_metadata_attribute(cluster_id, attr_id, name): vis = "system" elif ( key in EVERYDAY_CONTROL_ATTRIBUTES or key in USER_READINGS or access & ZCLAttributeAccess.Report and cluster_id not in CONFIG_HEAVY_CLUSTERS ): vis = "user" elif access & ZCLAttributeAccess.Write: vis = "setting" return vis def attr_role(access): r = bool(access & ZCLAttributeAccess.Read) w = bool(access & ZCLAttributeAccess.Write) return "control" if (r and w) else "sensor" if r else "event" buckets_total = {"user": 0, "setting": 0, "system": 0} main_clusters = set(MAIN_PARAMETER_BY_CLUSTER) rows = [] for cid, cls in clusters().items(): is_sys = cid in SYSTEM_CLUSTERS b = {"user": [], "setting": [], "system": []} attrs = getattr(cls, "attributes", {}) or {} for aid, adef in attrs.items(): if not isinstance(aid, int): continue access = getattr(adef, "access", None) if access is None: continue vis = attr_visibility(cid, aid, access, getattr(adef, "name", str(aid))) b[vis].append(getattr(adef, "name", str(aid))) # client-side commands -> user (or system if system cluster) cmds = getattr(cls, "server_commands", {}) or {} # sendable actions, matching controller for k, c in cmds.items(): if not isinstance(k, int): continue cv = "system" if is_sys else ("user" if (cid, k) in EVERYDAY_COMMANDS else "setting") b[cv].append(f"{getattr(c, 'name', str(k))}()") for k in buckets_total: buckets_total[k] += len(b[k]) rows.append((cid, cls.__name__, is_sys, cid in main_clusters, b)) print(f"=== ZIGBEE full-ZCL mapping: {len(rows)} clusters ===") print( f"total params by bucket: user={buckets_total['user']} " f"setting={buckets_total['setting']} system={buckets_total['system']}" ) print(f"clusters providing a MAIN parameter: {sorted(hex(c) for c in main_clusters)}\n") # spotlight: common device-facing clusters + anomalies SPOT = { 0x0006: "OnOff", 0x0008: "LevelControl", 0x0300: "Color", 0x0201: "Thermostat", 0x0202: "FanControl", 0x0102: "WindowCovering", 0x0101: "DoorLock", 0x0402: "TempMeas", 0x0405: "Humidity", 0x0400: "Illuminance", 0x0000: "Basic(SYS)", 0x0001: "PowerConfig", 0x0B04: "ElectricalMeas", 0x0500: "IAS_Zone", } for cid, name, is_sys, has_main, b in rows: if cid in SPOT: print(f"--- 0x{cid:04X} {name}{' [SYSTEM]' if is_sys else ''}{' [MAIN]' if has_main else ''} ---") for k in ("user", "setting", "system"): if b[k]: print(f" {k:7}({len(b[k])}): {', '.join(b[k][:12])}{' …' if len(b[k]) > 12 else ''}") # anomaly scan: standard attributes on SYSTEM clusters that leaked to user/setting (the `or` bug) print("\n=== ANOMALY: user/setting params on SYSTEM clusters (should arguably be system) ===") for cid, name, is_sys, _has_main, b in rows: if is_sys and (b["user"] or b["setting"]): print(f" 0x{cid:04X} {name}: user={b['user'][:6]} setting={b['setting'][:6]}") ================================================================================ # FILE: tests/conftest.py ================================================================================ """Unit tests for the Zigbee controller. Drive `ZigBeeController` directly — via the SDK's test dependencies, against an in-memory zigpy stub (no radio, no DB). The Hub keeps the e2e (mocked-radio) coverage and the real-hardware tests. """ import asyncio from unittest.mock import AsyncMock, patch import pytest_asyncio import zigpy.application import zigpy.device import zigpy.endpoint import zigpy.profiles import zigpy.state as app_state import zigpy.types as t import zigpy.zdo.types as zdo_t from majordom_integration_sdk.controller import AbstractController from majordom_integration_sdk.repository import DeviceRepositoryMemory from majordom_integration_sdk.testing import ( FakeBLEDiscoveryService, FakeSSDPDiscoveryService, FakeZeroconfDiscoveryService, RecordingControllerOutput, ) from majordom_zigbee import ZigBeeController # IEEE 00:11:22:33:44:55:66:77 -> discovery_id 3ad0d5e0-e32d-5b43-ac8f-150aa969c63d MOCK_IEEE = t.EUI64.convert("00:11:22:33:44:55:66:77") MOCK_NWK = t.NWK(0x1234) NCP_IEEE = t.EUI64.convert("aa:11:22:bb:33:44:be:ef") DEVICE_ID = "3ad0d5e0-e32d-5b43-ac8f-150aa969c63d" PARAM_COMMAND_ID = "71f0a643-94d1-5def-b4ff-c5a394fafb63" # toggle command_1/6/2 PARAM_ATTRIBUTE_ID = "d20e6f38-6ad2-5f51-a7b0-0f812b58e5cd" # on_time attribute_1/6/16385 (Read|Write) def _make_mock_zb_device(app: zigpy.application.ControllerApplication) -> zigpy.device.Device: dev = app.add_device(nwk=MOCK_NWK, ieee=MOCK_IEEE) dev.node_desc = zdo_t.NodeDescriptor( logical_type=zdo_t.LogicalType.Router, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=zdo_t.NodeDescriptor.FrequencyBand.Freq2400MHz, mac_capability_flags=zdo_t.NodeDescriptor.MACCapabilityFlags.AllocateAddress, manufacturer_code=4174, maximum_buffer_size=82, maximum_incoming_transfer_size=82, server_mask=0, maximum_outgoing_transfer_size=82, descriptor_capability_field=zdo_t.NodeDescriptor.DescriptorCapability.NONE, ) ep = dev.add_endpoint(1) ep.status = zigpy.endpoint.Status.ZDO_INIT ep.profile_id = 260 ep.device_type = zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT ep.add_input_cluster(6) # OnOff ep.add_input_cluster(8) # LevelControl return dev class _MockZigpyApp(zigpy.application.ControllerApplication): """In-memory zigpy stub — no hardware, no DB.""" _zb_controller = None # set to the controller after start() async def send_packet(self, *_): pass async def connect(self, *_): pass async def disconnect(self, *_): pass async def start_network(self, *_): pass async def force_remove(self, *_): pass async def add_endpoint(self, *_): pass async def permit_ncp(self, *_): pass async def write_network_info(self, *_): pass async def reset_network_info(self, *_): pass async def permit_with_link_key(self, *_): pass async def shutdown(self, *_): pass async def load_network_info(self, *, load_devices=False): self.state.network_info.channel = 15 async def permit(self, time_s=60, node=None): """Simulate a device joining — triggers device_joined + device_initialized.""" if self._zb_controller is None: return dev = _make_mock_zb_device(self) self._zb_controller.device_joined(dev) await asyncio.sleep(0.05) self._zb_controller.device_initialized(dev) async def remove(self, ieee): if self.get_device(ieee): del self.devices[ieee] @pytest_asyncio.fixture async def zigbee(): """Started ZigBeeController wired to an in-memory zigpy stub, plus the recording output and repository so tests can assert on both sides.""" created: list[_MockZigpyApp] = [] async def _new(config, auto_form=False): app = _MockZigpyApp({"database_path": None, "device": {"path": "/dev/null"}}) app.state.node_info = app_state.NodeInfo( nwk=t.NWK(0x0000), ieee=NCP_IEEE, logical_type=zdo_t.LogicalType.Coordinator ) created.append(app) return app repository = DeviceRepositoryMemory(integration="ZigBee") output = RecordingControllerOutput() import tempfile from pathlib import Path deps = AbstractController.Dependencies( output=output, make_device_repository=repository.session, documents_folder=Path(tempfile.mkdtemp()), zeroconf_discovery_service=FakeZeroconfDiscoveryService(), ssdp_discovery_service=FakeSSDPDiscoveryService(), ble_discovery_service=FakeBLEDiscoveryService(), hardware_interfaces=["/dev/null"], ) with ( patch("bellows.zigbee.application.ControllerApplication.new", side_effect=_new), patch("zigpy.zcl.Cluster.read_attributes", new_callable=AsyncMock, return_value=({}, {})), patch("zigpy.zcl.Cluster.write_attributes", new_callable=AsyncMock, return_value=[{}, {}]), patch("zigpy.zcl.Cluster.command", new_callable=AsyncMock, return_value=None), ): controller = ZigBeeController(deps) await controller.start() created[0]._zb_controller = controller yield controller, output, repository, created[0] await controller.stop() ================================================================================ # FILE: tests/test_controller.py ================================================================================ """Unit tests driving ZigBeeController directly against the in-memory zigpy stub.""" from uuid import UUID from conftest import DEVICE_ID, PARAM_ATTRIBUTE_ID, PARAM_COMMAND_ID async def _wait_for(predicate, timeout: float = 3.0): import asyncio async with asyncio.timeout(timeout): while not predicate(): await asyncio.sleep(0.02) async def test_discovers_joining_device(zigbee): controller, output, _repo, _app = zigbee await controller.start_pairing_window(5) # permit-join -> the stub simulates a device joining await _wait_for(lambda: bool(output.received_discoveries)) discovery = output.received_discoveries[-1] assert str(discovery.id) == DEVICE_ID assert discovery.integration == "ZigBee" assert UUID(DEVICE_ID) in controller.discoveries async def _discover(controller, output): await controller.start_pairing_window(5) await _wait_for(lambda: bool(output.received_discoveries)) return output.received_discoveries[-1] async def _seed_provisional(repository, discovery): """The Hub creates the device row before calling pair_device (persistence is the Hub's job).""" from majordom_zigbee.model import ZBDeviceIntegrationData, ZBDeviceState async with repository.session() as repo: await repo.save( ZBDeviceState( id=discovery.id, name="Mock Device", room_id=UUID(int=1), transport=discovery.transport, integration="ZigBee", manufacturer=None, parameters=[], integration_data=ZBDeviceIntegrationData(), ) ) async def test_pairs_a_joined_device(zigbee): controller, output, repository, _app = zigbee from majordom_zigbee.model import ZBDevice discovery = await _discover(controller, output) await _seed_provisional(repository, discovery) await controller.pair_device(discovery, None) # the discovery is consumed and the device is now tracked as connected assert discovery.id not in controller.discoveries assert discovery.id in controller._connected_devices # persistence: the controller mapped the device's clusters into parameters async with repository.session() as repo: device = await repo.get(discovery.id, as_=ZBDevice) state = await repo.state(discovery.id) assert device is not None and device.integration_data.ieee assert state is not None and len(state.parameters) > 0 async def _pair(zigbee): controller, output, repository, app = zigbee discovery = await _discover(controller, output) await _seed_provisional(repository, discovery) await controller.pair_device(discovery, None) return discovery async def _param(repository, device_id, parameter_id): from majordom_zigbee.model import ZBDeviceState, ZBParameter async with repository.session() as repo: state = await repo.state(device_id, as_=ZBDeviceState) param = next(p for p in state.parameters if str(p.id) == parameter_id) return ZBParameter.model_validate(param.model_dump()) async def _device(repository, device_id): from majordom_zigbee.model import ZBDevice async with repository.session() as repo: return await repo.get(device_id, as_=ZBDevice) async def test_sends_a_command_parameter(zigbee): from majordom_integration_sdk.schemas.command import DeviceCommand controller, output, repository, _app = zigbee discovery = await _pair(zigbee) device = await _device(repository, discovery.id) parameter = await _param(repository, discovery.id, PARAM_COMMAND_ID) # a ZCL command parameter (toggle) — no exception means the command reached the cluster await controller.send_command( DeviceCommand(device_id=discovery.id, parameter_id=UUID(PARAM_COMMAND_ID), value=None), device, parameter ) async def test_sends_an_attribute_write(zigbee): from majordom_integration_sdk.schemas.command import DeviceCommand controller, output, repository, _app = zigbee discovery = await _pair(zigbee) device = await _device(repository, discovery.id) parameter = await _param(repository, discovery.id, PARAM_ATTRIBUTE_ID) await controller.send_command( DeviceCommand(device_id=discovery.id, parameter_id=UUID(PARAM_ATTRIBUTE_ID), value=0), device, parameter ) async def test_unpairs_a_device(zigbee): controller, output, repository, app = zigbee discovery = await _pair(zigbee) device = await _device(repository, discovery.id) await controller.unpair(device) # the device is gone from the zigpy network from conftest import MOCK_IEEE assert MOCK_IEEE not in app.devices ================================================================================ # FILE: tests/test_param_ux.py ================================================================================ """Light probes for the parameter-UX mapping — exercise the curation code paths without re-hardcoding every device's expected parameters (that stays manual review; see the audit script and the docs recipe).""" from uuid import uuid4 from majordom_integration_sdk.schemas.parameter import ParameterDataType, ParameterRole, ParameterVisibility from majordom_zigbee.zigbee_spec import ( CONFIG_HEAVY_CLUSTERS, EVERYDAY_COMMANDS, EVERYDAY_CONTROL_ATTRIBUTES, MAIN_PARAMETER_BY_CLUSTER, METADATA_SOURCES, OUR_ATTRIBUTE_UX, USER_READINGS, VISIBILITY_OVERRIDES, ZHA_ATTRIBUTE_UX, UxSpec, classify_attribute, is_metadata_attribute, resolve_metadata_bounds, ) def test_metadata_heuristic_catches_scaling_constants(): assert is_metadata_attribute(0x0B04, 0x0602, "ac_voltage_divisor") assert is_metadata_attribute(0x0B04, 0x0603, "ac_voltage_multiplier") assert is_metadata_attribute(0x0402, 0x0002, "max_measured_value") def test_metadata_heuristic_leaves_real_readings_alone(): assert not is_metadata_attribute(0x0402, 0x0000, "measured_value") assert not is_metadata_attribute(0x0B04, 0x050B, "active_power") def test_curation_sets_are_consistent(): # a parameter can't be both an everyday writable control and a read-only user reading assert USER_READINGS.isdisjoint(EVERYDAY_CONTROL_ATTRIBUTES) # overrides don't contradict the curated user lists (an override is for the *other* cases) assert set(VISIBILITY_OVERRIDES).isdisjoint(USER_READINGS | EVERYDAY_CONTROL_ATTRIBUTES) def test_config_heavy_and_commands_populated(): # DoorLock, ElectricalMeasurement, Metering are the config-heavy clusters assert {0x0101, 0x0B04, 0x0702} <= CONFIG_HEAVY_CLUSTERS # the everyday one-tap commands include lock/unlock and on/off assert {(0x0101, 0x00), (0x0101, 0x01), (0x0006, 0x02)} <= EVERYDAY_COMMANDS def test_fan_control_is_an_attribute_main_that_cycles(): spec = MAIN_PARAMETER_BY_CLUSTER[0x0202] # FanControl has no commands assert spec.is_attribute assert spec.target_id == 0x0000 # fan_mode assert spec.cycle == [0x00, 0x04] # off <-> on toggle def test_attribute_main_cycle_stored_as_default_value(): # Pairing stores an attribute main's cycle subset on default_value (not integration_data), so # the relay derives the next value via Parameter.main_cycle — the same path for every protocol. from majordom_zigbee.model import ZBParameterIntegrationData, ZBParameterState, ZBParameterType spec = MAIN_PARAMETER_BY_CLUSTER[0x0202] assert spec.cycle is not None param = ZBParameterState( id=uuid4(), name="fan_mode", data_type=ParameterDataType.enum, role=ParameterRole.control, visibility=ParameterVisibility.user, valid_values={0x00: "off", 0x01: "low", 0x02: "medium", 0x03: "high", 0x04: "on"}, integration_data=ZBParameterIntegrationData( endpoint_id=1, cluster_id=0x0202, attribute_id=0x0000, type=ZBParameterType.attribute ), default_value=set(spec.cycle), ) assert param.can_be_main_parameter assert param.main_cycle == [0x00, 0x04] # only the curated off/on subset, not the full enum assert not hasattr(param.integration_data, "main_cycle") def test_metadata_resolver_prefers_device_limit_values(): # LevelControl.current_level bounded by the device's own min/max_level runtime values values = {0x0002: 10, 0x0003: 200} lo, hi, missing = resolve_metadata_bounds(0x0008, 0x0000, values.get, 0, 254) assert (lo, hi) == (10, 200) assert not missing def test_metadata_resolver_reports_missing_sources(): lo, hi, missing = resolve_metadata_bounds(0x0008, 0x0000, {}.get, 0, 254) assert (lo, hi) == (0, 254) # falls back to wire-type defaults assert set(missing) == {0x0002, 0x0003} def test_metadata_resolver_passthrough_when_no_source(): assert resolve_metadata_bounds(0x0500, 0x0000, {}.get, 1, 2) == (1, 2, []) def test_metadata_sources_point_at_real_metadata(): # every declared source's target parameter is itself a real (non-metadata) reading key for cluster_id, attr_id in METADATA_SOURCES: assert not is_metadata_attribute(cluster_id, attr_id, "measured_value") # --- classification ladder probes (see classify_attribute) -------------------------------------- def test_our_override_beats_everything(): # OnOff.on_off is in USER_READINGS -> our layer forces user/sensor, even though zha also has it. spec, source = classify_attribute(0x0006, 0x0000, "on_off", writable=True, reportable=True) assert source == "ours" assert spec.visibility is ParameterVisibility.user def test_metadata_forced_system(): spec, source = classify_attribute(0x0402, 0x0002, "max_measured_value", writable=False, reportable=True) assert source == "metadata" assert spec.visibility is ParameterVisibility.system def test_harvested_zha_applied_when_uncurated(): # pick any harvested key that isn't in our hand layer, and check it flows through the zha tier zha_only = next(k for k in ZHA_ATTRIBUTE_UX if k not in OUR_ATTRIBUTE_UX) cid, aid = zha_only spec, source = classify_attribute(cid, aid, "x", writable=True, reportable=False) assert source in ("zha", "metadata") # metadata safety rule may pre-empt some keys def test_quirk_metadata_beats_zha(): # a v2 quirk spec is honored above the harvested zha layer (but below our hand overrides) q = UxSpec(ParameterVisibility.setting, ParameterRole.control, None) spec, source = classify_attribute( 0x0008, 0x0010, "on_off_transition_time", writable=True, reportable=False, quirk_ux=q ) assert source == "quirk-v2" assert spec is q def test_fallback_warns_for_uncurated(): # an attribute in no source falls to the heuristic and is tagged fallback-* spec, source = classify_attribute(0x0AAA, 0x1234, "mystery", writable=False, reportable=True) assert source.startswith("fallback") assert spec.visibility is ParameterVisibility.user # reportable heuristic (until coverage flip) def test_harvest_artifact_is_populated(): assert len(ZHA_ATTRIBUTE_UX) > 30 # the vendored zha harvest loaded # integration-homekit — full source # repo: https://github.com/MajorDom-Systems/integration-homekit branch: master commit: 30e5a3fd19a9d37c290b25405173a9a7dab18cc0 # generated: 2026-07-24 — AUTO-GENERATED, do not edit by hand ## File tree .gitignore LICENSE README.md majordom_homekit/__init__.py majordom_homekit/characteristics_storage.py majordom_homekit/controller.py majordom_homekit/mapper.py majordom_homekit/models.py majordom_homekit/pairings_storage.py majordom_homekit/parameters_map.py majordom_homekit/readme.md pyproject.toml tests/conftest.py tests/test_controller.py ================================================================================ # FILE: .gitignore ================================================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] *$py.class # C extensions *.so # Distribution / packaging build/ dist/ *.egg-info/ *.egg # Unit test / coverage reports .coverage .coverage.* coverage.xml htmlcov/ .pytest_cache/ .hypothesis/ # Environments .env .envrc .venv env/ venv/ # Poetry # poetry.lock # uncomment for applications, keep commented for libraries # Type checkers .mypy_cache/ .dmypy.json .pyre/ .pytype/ # Tools .ruff_cache/ .ropeproject # mkdocs (only relevant if you kept mkdocs.yml + docs/) /site .cache/ # IDEs .idea/ .vscode/ # OS .DS_Store Thumbs.db ================================================================================ # FILE: LICENSE ================================================================================ # PolyForm Noncommercial License 1.0.0 ## Acceptance In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses. ## Copyright License The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license). ## Distribution License The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license). ## Notices You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example: > Required Notice: Copyright Yoyodyne, Inc. (http://example.com) ## Changes and New Works License The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose. ## Patent License The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software. ## Noncommercial Purposes Any noncommercial purpose is a permitted purpose. ## Personal Uses Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose. ## Noncommercial Organizations Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding. ## Fair Use You may have "fair use" rights for the software under the law. These terms do not limit them. ## No Other Rights These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses. ## Patent Defense If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company. ## Violations The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately. ## No Liability ***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.*** ## Definitions The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms. **You** refers to the individual or entity agreeing to these terms. **Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect. **Your licenses** are all the licenses granted to you for the software under these terms. **Use** means anything you do with the software requiring one of your licenses. ================================================================================ # FILE: README.md ================================================================================ # integration-homekit A [MajorDom](https://majordom.io) integration — bridges **Apple HomeKit** accessories into the MajorDom language. Built for the **MajorDom Hub**, but it doesn't need it: this is a standalone, standardized library for HomeKit that you can use on its own (see **Run it standalone** below). Built on the [MajorDom Integration SDK](https://github.com/MajorDom-Systems/integration-sdk). The entry point is `HomeKitController` (`majordom_homekit/controller.py`), which the Hub — or the SDK's dev runner — instantiates and drives through its lifecycle: discovery → pairing → commands → teardown. - **Other protocols:** browse the [MajorDom integrations](https://github.com/orgs/MajorDom-Systems/repositories?q=integration-). - **Create your own:** start from the [integration template](https://github.com/MajorDom-Systems/integration-template). ## Documentation Full integration-author docs — the controller lifecycle, data models, storing data, discovery, and a worked example — live at **[docs.majordom.io](https://docs.majordom.io/device-integration)**. ## Development ```sh poetry install && poetry run poe install ``` | Task | Description | |------|-------------| | `poe check` | Full quality pipeline (ruff, ty, pytest, poetry build/check) | | `poe check --ci` | Same, plus `git diff --exit-code` | Work lands on `develop`; `master` is protected and released via **Actions → Release**. Tests drive the controller with the SDK's test doubles against an in-process fake HAP accessory server — no real accessory required (see `tests/`). ## Run it standalone (without the Hub) `majordom-homekit` is a standalone library — import it into your own app, or run **just this integration** interactively (discover, pair, control, and inspect devices from a prompt) with no Hub. It discovers IP accessories over mDNS (BLE accessories need a BLE adapter); no external service is required. See **[Standalone mode](https://docs.majordom.io/device-integration/standalone)** for the interactive CLI, watch mode, and the programmatic API. ## About this integration - **Protocol / platform:** Apple HomeKit Accessory Protocol (HAP) via the [`parker-aiohomekit`](https://pypi.org/project/parker-aiohomekit/) fork of `aiohomekit`. - **Transport(s):** IP (Wi-Fi / Ethernet, discovered via mDNS); BLE for BLE accessories. - **Supported devices:** HomeKit-compatible accessories — lights, plugs, switches, sensors, locks. - **Credentials needed to pair:** `code` (the accessory's 8-digit HomeKit setup code). ### Required harness - **Hardware adapters:** a BLE adapter only if pairing BLE accessories; IP accessories need none. - **Third-party software services:** none — `aiohomekit` speaks HAP directly. - **OS / permissions:** mDNS on the LAN for discovery; BLE access for BLE accessories. ### Protocol stack (OSI) | OSI layer | Protocol | Implemented by | |-----------|----------|----------------| | Application (7) | HAP characteristics / services | **this integration** (via `aiohomekit`) | | Session (5) | Pair-Setup / Pair-Verify (SRP, Ed25519) | library (`aiohomekit`) | | Transport (4) | TCP (IP) / GATT (BLE) | OS | | Network + below (1–3) | IP over Wi-Fi / Ethernet (or BLE) | OS | ### Progress - [x] Discovery services registered (mDNS via `zeroconf_discovery_service`; BLE where applicable); cancel closures called in `stop` - [x] Discovery listeners fire and call `controller_did_receive_discovery` - [x] Re-discovery of already-paired accessories on reconnect (`controller_did_connect_device`) - [x] Device pairing (HAP pair-setup with the setup code) - [x] Device schema mapped: services/characteristics → parameter list with per-parameter metadata - [x] Hub → Device control (`send_command`) - [x] Device → Hub event subscription (`controller_did_receive_events`) - [x] `identify` - [x] `unpair` - [x] `fetch` - [x] Availability tracking while running (`controller_did_lose_device` / `last_error`) - [x] Graceful shutdown in `stop` - [x] Tests pass against a fake HAP accessory server (`tests/`) ### Notes Uses the `parker-aiohomekit` fork of `aiohomekit`; note its Python upper bound (`<3.14`). ## License See [LICENSE](LICENSE). For commercial licensing or partnership inquiries regarding MajorDom, contact us via [parker-industries.org/partnership](https://parker-industries.org/partnership). ================================================================================ # FILE: majordom_homekit/__init__.py ================================================================================ """HomeKit integration for MajorDom. Bridges HomeKit accessories into the MajorDom language. `HomeKitController` is the entry point the Hub (or the SDK's standalone dev runner) instantiates and drives. """ from majordom_homekit.controller import HomeKitController __all__ = ["HomeKitController"] ================================================================================ # FILE: majordom_homekit/characteristics_storage.py ================================================================================ """ HomeKit-specific storage adapter. TLDR; aiohomekit manages pairing and characteristics data internally, but delegates persistence to a pluggable storage interface. This class implements that interface on top of MajorDom's DeviceRepository, so HomeKit state is stored in the Hub's database. """ from collections.abc import Callable from contextlib import AbstractAsyncContextManager from aiohomekit.model.accessories import AccessoriesState from aiohomekit.model.typed_dicts import HKDeviceID from aiohomekit.storage.characteristics_storage import CharacteristicsStorageProtocol from majordom_integration_sdk.repository import DeviceRepositoryProtocol from .mapper import HKMajorDomMapper from .models import HKDevice, HKDeviceIntegrationData, HKDeviceState class HKCharacteristicsStorageMajorDom(CharacteristicsStorageProtocol): def __init__( self, make_device_repository: Callable[[], AbstractAsyncContextManager[DeviceRepositoryProtocol]], mapper: HKMajorDomMapper, ): self.make_device_repository = make_device_repository self.mapper = mapper @property def _integration_name(self): return "HomeKit" # CharacteristicsStorageProtocol Implementation async def get_all(self) -> dict[HKDeviceID, AccessoriesState]: all = {} async with self.make_device_repository() as device_repository: for device in await device_repository.get_all(as_=HKDevice): if accessory := device.integration_data.characteristics_cache: all[device.hk_id] = accessory return all async def get(self, id: HKDeviceID) -> AccessoriesState | None: uuid = self.mapper.hap_id_to_uuid(id) async with self.make_device_repository() as device_repository: if (device := await device_repository.get(uuid, as_=HKDevice)) and ( accessory := device.integration_data.characteristics_cache ): return accessory return None async def save(self, id: HKDeviceID, item: AccessoriesState): # NOTE: this method is called only when data model (characteristics) is updated # which is detected by a change of config_num # usually after the accessory's software update async with self.make_device_repository() as device_repository: device_id = self.mapper.hap_id_to_uuid(id) device = await device_repository.state(device_id, as_=HKDeviceState) assert device if not device.integration_data: device.integration_data = HKDeviceIntegrationData() # fill only the unique data from AccessoryState that isn't already passed from # Discovery or DeviceCreate by the core # TODO: check these values with real devices accessory = item.accessories[0] # TODO: add later support for multiple accessories device.manufacturer = accessory.manufacturer # Manufacturer-provided, read-only description — the HAP accessory model. device.description = getattr(accessory, "model", None) device.integration_data.characteristics_cache = item # map all homekit characteristics to majordom parameters for accessory in item.accessories: for service in accessory.services: for characteristic in service.characteristics: parameter = self.mapper.hap_char_to_majordom_parameter(device.id, accessory.aid, characteristic) device.parameters.append(parameter) await device_repository.save(device) async def delete(self, id: HKDeviceID): # TODO: check usage, remove vs unpair, allow fast re-pairing async with self.make_device_repository() as device_repository: uuid = self.mapper.hap_id_to_uuid(id) if device := await device_repository.get(uuid, as_=HKDevice): device.integration_data.characteristics_cache = None # TODO: empty collection? await device_repository.save(device) ================================================================================ # FILE: majordom_homekit/controller.py ================================================================================ import asyncio import logging from collections.abc import Iterable from typing import override from uuid import UUID from aiohomekit.controller.abstract import ( AbstractController, AbstractDiscovery, AbstractPairing, ) from aiohomekit.controller.relay import Controller as AioHomeKitController from aiohomekit.controller.relay import controller as controller_module from aiohomekit.model.characteristics import CharacteristicKey from aiohomekit.model.characteristics.characteristic_key import CharacteristicKeyValue from aiohomekit.model.characteristics.permissions import CharacteristicPermissions from aiohomekit.model.typed_dicts import HKDeviceID, Response from majordom_integration_sdk.controller import ( AbstractController as MajorDomController, # avoid collision with aiohomekit ) from majordom_integration_sdk.schemas.base import NonEmptyStr from majordom_integration_sdk.schemas.command import DeviceCommand from majordom_integration_sdk.schemas.device import ( CredentialsType, Discovery, ProvidedCredentials, ) from majordom_integration_sdk.schemas.event import DeviceParameterChange from .characteristics_storage import HKCharacteristicsStorageMajorDom from .mapper import HKMajorDomMapper from .models import HKDevice, HKParameter from .pairings_storage import HKPairingsStorageMajorDom logger = logging.getLogger(__name__) # TODO: make a better way to pass this controller_module.BLE_TRANSPORT_SUPPORTED = False controller_module.COAP_TRANSPORT_SUPPORTED = False controller_module.IP_TRANSPORT_SUPPORTED = True # TODO: what are Accessory.needs_polling chars? handle them class HomeKitController(MajorDomController): """Bridges the Hub to HomeKit (HAP) accessories over IP via aiohomekit. aiohomekit owns the HAP protocol, discovery, pairing crypto, and the live connection; this controller adapts it to the Hub's AbstractController contract and persists HomeKit state in the Hub DB through the two storage adapters. See readme.md. """ mapper: HKMajorDomMapper # ------------------------------------------------------------------------- # AbstractController interface # ------------------------------------------------------------------------- name = "HomeKit" @property def discoveries(self) -> dict[UUID, Discovery]: return self._majordom_discoveries # device_type / parameter_type let the Hub deserialize into our typed subclasses. @property @override def device_type(self) -> type[HKDevice]: return HKDevice @property @override def parameter_type(self) -> type[HKParameter]: return HKParameter # ------------------------------------------------------------------------- # Lifecycle # ------------------------------------------------------------------------- async def start(self): # A single mapper, wired with the framework's UUID generators, is shared with both # storage adapters so every HAP id maps to the same MajorDom UUID everywhere. self.mapper = HKMajorDomMapper(device_uuid=self.device_uuid, parameter_uuid=self.parameter_uuid) self._majordom_discoveries: dict[UUID, Discovery] = dict() self._hap_discoveries: dict[UUID, AbstractDiscovery] = dict() self._availability: dict[UUID, bool] = dict() # device_id -> last signalled availability # aiohomekit already registers the `_hap._tcp/_udp` mDNS browsers itself (given our # shared AsyncZeroconf below), so we don't register them on the discovery service here. self.hk_char_storage = HKCharacteristicsStorageMajorDom(self.dependencies.make_device_repository, self.mapper) self.hk_pairing_data_storage = HKPairingsStorageMajorDom(self.dependencies.make_device_repository, self.mapper) # TODO: BLE discovery and pairing (aiohomekit supports it; the hub only wires IP for now) self._aiohomekit_controller = AioHomeKitController( zeroconf_instance=self.dependencies.zeroconf_discovery_service.async_zeroconf, char_cache=self.hk_char_storage, pairing_data_storage=self.hk_pairing_data_storage, ) self._aiohomekit_controller.on_discovery(self._aiohomekit_did_discover) await self._aiohomekit_controller.start() # aiohomekit only pushes "became available"; it never pushes "became unavailable" # (see AbstractPairing.add_observer_for_availability). So we poll each paired # accessory's connection state to catch both directions — mid-session drops and # reconnects — and reconcile it through _set_availability. self._availability_task = asyncio.create_task(self._availability_loop()) async def stop(self): if task := getattr(self, "_availability_task", None): task.cancel() await self._aiohomekit_controller.stop() # ------------------------------------------------------------------------- # Public device operations (Hub -> device) # ------------------------------------------------------------------------- async def pair_device(self, discovery: Discovery, credentials: ProvidedCredentials | None): if not credentials or credentials.type not in discovery.expected_credentials_options: raise ValueError( f"Credentials type {credentials.type if credentials else None!r} is not one of the " f"types this discovery advertised: {discovery.expected_credentials_options}" ) hap_discovery = self._hap_discoveries[discovery.id] async with asyncio.timeout(1): # TODO: timeout to settings finish_pairing = await hap_discovery.start_pairing() # TODO: check if pairing steps need to be split async with asyncio.timeout(1): pairing_data = await finish_pairing(str(credentials.value or "")) pairing_id = pairing_data["AccessoryPairingID"].lower() # main "patch"/"create" data is saved in majordom's core # aiohomekit will save pairing data and characteristics (data model) automatically using # the provided storage during finish_pairing # so no need to fetch or save anything manually here # upd: looks like it isn't implemented in aiohomekit yet, so doing it manually pairing = self._aiohomekit_controller.pairings[pairing_id] await pairing.fetch_accessories_and_characteristics() await self.hk_char_storage.save( pairing_id, pairing.accessories_state ) # converts and saves parameters (aka characteristics) await self.hk_pairing_data_storage.save(pairing_id, pairing_data) # converts and saves data for connection await self._handle_connected_pairing(pairing_id) self._hap_discoveries.pop(discovery.id) self._majordom_discoveries.pop(discovery.id) async def unpair(self, device: HKDevice): await self._aiohomekit_controller.remove_pairing(device.hk_id) # aiohomekit will cleanup the storage, so no need to do anything here async def identify(self, device: HKDevice): await self._aiohomekit_controller.identify(device.hk_id) async def fetch(self, device: HKDevice): pairing = self._aiohomekit_controller.pairings[device.hk_id] if not pairing: raise RuntimeError( f"Unexpected Error: Pairing {device.hk_id} for '{device.id}' aka '{device.name}' not found" ) # fetching the values only; aiohomekit will save the data model on change automatically response = await pairing.get_characteristics( self._get_all_characteristics_keys(pairing, {CharacteristicPermissions.paired_read}) ) await self._handle_accessory_response(device.hk_id, response) async def send_command(self, command: DeviceCommand, device: HKDevice, parameter: HKParameter): hk_value = self.mapper.mj_value_to_hap(command.value) pairing = self._aiohomekit_controller.pairings[device.hk_id] if not pairing: raise RuntimeError( f"Unexpected Error: Pairing {device.hk_id} for '{device.id}' aka '{device.name}' not found" ) response = await pairing.put_characteristics( [CharacteristicKeyValue(parameter.integration_data.aid, parameter.integration_data.iid, hk_value)] ) await self._handle_accessory_response(device.hk_id, response) # ------------------------------------------------------------------------- # Device -> Hub: discovery (aiohomekit delegate) # ------------------------------------------------------------------------- def _aiohomekit_did_discover(self, controller: AbstractController, hk_discovery: AbstractDiscovery): asyncio.create_task(self._async_aiohomekit_did_discover(controller, hk_discovery)) async def _async_aiohomekit_did_discover(self, controller: AbstractController, hk_discovery: AbstractDiscovery): # Already paired to us -> a reconnect, not a new discovery. if hk_discovery.description.id in controller.pairings: logger.debug(f"{self.name} Discovered paired device...") await self._handle_connected_pairing(hk_discovery.description.id) return # Paired to some other controller -> can't offer for pairing here. if hk_discovery.paired: # TODO: handle this case # If device supports only one controller, show as unreachable (requires unpairing) # Some protocols support multiple controllers (like Matter, some HomeKit, Zigbee green with hacks, etc.) # In this case, accessory might need to be put in pairing mode using the first # controller to allow a second pairing with this controller logger.info(f'Device "{hk_discovery.description.name}" is paired to another controller') return # A new, unpaired accessory -> surface it as a discovery. logger.info(f"{self.name} Discovered new device...") desc = hk_discovery.description discovery_uuid = self.mapper.hap_id_to_uuid(hk_discovery.description.id) mj_discovery_info = Discovery( # technical id=discovery_uuid, integration=NonEmptyStr(self.name), expected_credentials_options=[CredentialsType.code.with_mask("DDD-DD-DDD")], expiration=None, # TODO: # UX transport=NonEmptyStr("IP"), device_name=desc.name, device_manufacturer=None, # looks like it needs device to be paired first device_category=desc.category, device_icon=None, # will be implemented later # device_model_id = None, # ? model_name: desc.model ) self._hap_discoveries[discovery_uuid] = hk_discovery self._majordom_discoveries[discovery_uuid] = mj_discovery_info await self.dependencies.output.controller_did_receive_discovery(self, mj_discovery_info) # TODO: dismiss Discovery if discovery disapperd or expired # ------------------------------------------------------------------------- # Device -> Hub: parameter events & availability # ------------------------------------------------------------------------- def run_handle_accessory_response(self, hk_device_id: HKDeviceID, responses: Response): asyncio.create_task(self._handle_accessory_response(hk_device_id, responses)) async def _handle_accessory_response(self, hk_device_id: HKDeviceID, responses: Response): # TODO: review, test parameter_changed_events = [] for (aid, iid), response in responses.items(): if "value" in response: hk_value = response["value"] device_id = self.mapper.hap_id_to_uuid(hk_device_id) device_parameter_id = self.mapper.hap_iid_to_param_uuid(hk_device_id, aid, iid) parameter_changed_events.append( DeviceParameterChange( device_id=device_id, parameter_id=device_parameter_id, value=hk_value, # TODO: self.mapper.hap_value_to_mj(pairing.characteristic_for_key((aid, iid))) ) ) if "status" in response and response["status"] != 0: raise ValueError( f'Something\'s wrong with characteristic "{aid}.{iid}": ' f'status "{response["status"]}", description: {response.get("description", "none")}' ) await self.dependencies.output.controller_did_receive_events(self, parameter_changed_events) async def _set_availability(self, device_id: UUID, available: bool): """Single funnel for availability transitions — dedupes so the Hub is only told on an actual change, and translates it into the framework's connect / lose callbacks.""" if self._availability.get(device_id) == available: return self._availability[device_id] = available if available: await self.dependencies.output.controller_did_connect_device(self, device_id) else: await self.dependencies.output.controller_did_lose_device(self, device_id) async def _availability_loop(self, interval: float = 30): while True: await asyncio.sleep(interval) try: for hk_id, pairing in list(self._aiohomekit_controller.pairings.items()): await self._set_availability(self.mapper.hap_id_to_uuid(hk_id), pairing.is_available) except Exception: logger.exception(f"{self.name} availability poll failed") # ------------------------------------------------------------------------- # Private helpers: connection setup # ------------------------------------------------------------------------- async def _handle_connected_pairing(self, pairing_id: HKDeviceID): pairing = self._aiohomekit_controller.pairings[pairing_id.lower()] await self._set_availability(self.mapper.hap_id_to_uuid(pairing_id), True) # subscribe to events await self._observe_characteristics(pairing) # update state state = await pairing.get_characteristics( self._get_all_characteristics_keys(pairing, {CharacteristicPermissions.paired_read}) ) await self._handle_accessory_response(pairing_id, state) async def _observe_characteristics(self, pairing: AbstractPairing): cleanup = pairing.add_observer_for_characteristics(self.run_handle_accessory_response) # make controller handle cleanup for us if pairing.id not in self._aiohomekit_controller._pairing_cleanups: self._aiohomekit_controller._pairing_cleanups[pairing.id] = [] self._aiohomekit_controller._pairing_cleanups[pairing.id].append(cleanup) # pairing.add_observer_for_availability # TODO: check and use response = await pairing.subscribe_characteristics( self._get_all_characteristics_keys(pairing, {CharacteristicPermissions.events}) ) await self._handle_accessory_response(pairing.id, response) def _get_all_characteristics_keys( self, pairing: AbstractPairing, perms: set[CharacteristicPermissions] ) -> Iterable[CharacteristicKey]: for accessory in pairing.accessories_state.accessories: for service in accessory.services: for characteristic in service.characteristics: if perms.issubset(characteristic.perms): yield CharacteristicKey(accessory.aid, characteristic.iid) ================================================================================ # FILE: majordom_homekit/mapper.py ================================================================================ """ Conversion logic between HAP (HomeKit Accessory Protocol) concepts and MajorDom's domain model. Isolated here to keep the Controller free of boilerplate — formats, units, permissions, and valid enum values are all mapped in one place. """ import inspect import re from collections.abc import Callable, Iterable from enum import Enum from typing import Any from uuid import UUID from aiohomekit.model.characteristics import ( Characteristic, CharacteristicFormats, CharacteristicUnits, ) from aiohomekit.model.characteristics import const as aiohomekit_consts from aiohomekit.model.characteristics.characteristic_types import CharacteristicsTypes from aiohomekit.model.characteristics.permissions import CharacteristicPermissions from aiohomekit.model.typed_dicts import HKDeviceID from majordom_integration_sdk.schemas.parameter import ( ParameterDataType, ParameterRole, ParameterUnit, ParameterVisibility, ) from majordom_homekit.models import ( HKParameterIntegrationData, HKParameterState, ) class HKMajorDomMapper: def __init__( self, device_uuid: Callable[[str], UUID], parameter_uuid: Callable[[UUID, str], UUID], ): # The controller's provided (framework) UUID generators — see AbstractController. # HAP identifiers are turned into MajorDom UUIDs exclusively through these, so device # and parameter ids are namespaced under the integration consistently with every other # integration. self._device_uuid = device_uuid self._parameter_uuid = parameter_uuid # ------------------------------------------------------------------------- # Identity: HAP identifiers -> MajorDom UUIDs # ------------------------------------------------------------------------- def hap_id_to_uuid(self, hk_device_id: HKDeviceID) -> UUID: return self._device_uuid(hk_device_id.lower()) def hap_iid_to_param_uuid(self, hk_device_id: HKDeviceID, aid: int, iid: int) -> UUID: return self._parameter_uuid(self.hap_id_to_uuid(hk_device_id), f"{aid}.{iid}") # ------------------------------------------------------------------------- # MajorDom -> HAP # ------------------------------------------------------------------------- def mj_value_to_hap(self, value: Any): # TODO: implement conversion if needed # aiohomekit handle a lot of processing for us # use characteristic.format and characteristic.unit for correct conversion # checking existing mapping inside aiohomekit might be helpful return value # ------------------------------------------------------------------------- # HAP -> MajorDom # ------------------------------------------------------------------------- def hap_char_to_majordom_parameter( self, device_id: UUID, aid: int, characteristic: Characteristic ) -> HKParameterState: return HKParameterState( id=self._parameter_uuid(device_id, f"{aid}.{characteristic.iid}"), name=characteristic.description or "", data_type=self._hap_format_to_mj_data_type(characteristic.format), unit=self._hap_unit_to_mj(characteristic.unit), role=self._hap_perms_to_mj_role(characteristic.perms), visibility=ParameterVisibility.user, min_value=characteristic.minValue, max_value=characteristic.maxValue or characteristic.maxLen, min_step=characteristic.minStep, valid_values=self._valid_values(characteristic), integration_data=HKParameterIntegrationData( type=characteristic.type, aid=aid, iid=characteristic.iid, ), value=self.hap_value_to_mj(characteristic), ) # UNUSED: # Service.available # Characteristic.available # Characteristic.ev # Characteristic.maxDataLen # Characteristic.handle # Characteristic.broadcast_events # Characteristic.disconnected_events # Characteristic.valid_values_range def hap_value_to_mj(self, characteristic: Characteristic): # characteristic.value should already work in most cases since aiohomekit handle a lot of processing for us # otherwise use characteristic.format and characteristic.unit for correct conversion # checking existing mapping inside aiohomekit might be helpful return characteristic.value def _hap_format_to_mj_data_type(self, format: str | None) -> ParameterDataType: mapping: dict[str | None, ParameterDataType] = { CharacteristicFormats.bool: ParameterDataType.bool, CharacteristicFormats.uint8: ParameterDataType.integer, CharacteristicFormats.uint16: ParameterDataType.integer, CharacteristicFormats.uint32: ParameterDataType.integer, CharacteristicFormats.uint64: ParameterDataType.integer, CharacteristicFormats.int: ParameterDataType.integer, CharacteristicFormats.int32: ParameterDataType.integer, CharacteristicFormats.float: ParameterDataType.decimal, CharacteristicFormats.string: ParameterDataType.string, CharacteristicFormats.data: ParameterDataType.data, # TODO: review CharacteristicFormats.tlv8: ParameterDataType.data, CharacteristicFormats.array: ParameterDataType.data, CharacteristicFormats.dict: ParameterDataType.data, } return mapping[format] def _hap_unit_to_mj(self, unit: str | None) -> ParameterUnit: mapping: dict[str | None, ParameterUnit] = { CharacteristicUnits.celsius: ParameterUnit.celsius, CharacteristicUnits.percentage: ParameterUnit.percentage, CharacteristicUnits.arcdegrees: ParameterUnit.arcdegree, CharacteristicUnits.lux: ParameterUnit.lux, CharacteristicUnits.seconds: ParameterUnit.second, None: ParameterUnit.plain, } return mapping[unit] def _hap_perms_to_mj_role(self, perms: Iterable[str]) -> ParameterRole: # TODO: review all perms in specs if CharacteristicPermissions.paired_write in perms: return ParameterRole.control elif CharacteristicPermissions.paired_read in perms: return ParameterRole.sensor else: return ParameterRole.event def _valid_values(self, characteristic: Characteristic) -> dict[int | str | float, str] | None: # TODO: convert to codegen instead of runtime parsing if values_enum := self._search_values_enum_for_characteristic(characteristic): return {v.value: underscore_to_display_case(k) for k, v in values_enum.__members__.items()} else: return {key: str(key) for key in characteristic.valid_values or []} # scrapping def _search_values_enum_for_characteristic(self, characteristic: Characteristic) -> type[Enum] | None: # try get characteristic type name by id for char_type in CharacteristicsTypes: if char_type == characteristic.type and (values_enum := self._search_values_enum_by_name(char_type.name)): return values_enum # try using description as a type name if characteristic.description: possible_enum_name = "_".join(characteristic.description.split(" ")).upper() if values_enum := self._search_values_enum_by_name(possible_enum_name): return values_enum return None def _search_values_enum_by_name(self, char_uppercase_name: str) -> type[Enum] | None: # ignore = {'values', 'target', 'current', 'state', 'status', 'capabilities', 'units'} searched_char_name_set = set(word.lower() for word in char_uppercase_name.split("_")) for name, obj in inspect.getmembers(aiohomekit_consts, inspect.isclass): if not issubclass(obj, Enum): continue member_name_set = set(word.lower() for word in re.split(r"(? str: return " ".join([word.title() for word in name.split("_")]) ================================================================================ # FILE: majordom_homekit/models.py ================================================================================ """ MajorDom's Device and Parameter schemas expose an `integration_data` field for storing protocol-specific state. By default it is an untyped dict persisted as JSON. Integrations can subclass Device/DeviceState and Parameter/ParameterState to declare a typed schema for that field — Hub will then handle (de-)serialization automatically before passing Device instance to Controller's methods or when saving to the database. To make the Hub instantiate these custom types, the integration's Controller must override `device_type` and `parameter_type`. """ from typing import Any from uuid import UUID from aiohomekit.model.accessories import AccessoriesState from aiohomekit.model.typed_dicts import PairingData from majordom_integration_sdk.schemas.base import Base from majordom_integration_sdk.schemas.device import Device, DeviceState, ParameterState from majordom_integration_sdk.schemas.parameter import Parameter from pydantic import BaseModel, field_serializer, field_validator # Integration data class HKDeviceIntegrationData(Base): # NOTE: must be initializable without any arguments pairing_data: PairingData | None = None characteristics_cache: AccessoriesState | None = None # since AccessoriesState isn't a pydantic class, we need to implement (de)serialization model_config = { "arbitrary_types_allowed": True, } @field_serializer("characteristics_cache") def serialize_characteristics_cache(self, v: AccessoriesState, _info) -> dict[str, Any]: return v.as_dict() if v else {} @field_validator("characteristics_cache", mode="before") @classmethod def parse_characteristics_cache(cls, v: Any) -> AccessoriesState | None: if not v: return None if isinstance(v, AccessoriesState): return v if isinstance(v, dict): return AccessoriesState.from_dict(v) raise ValueError(f"Expected dict or AccessoriesState, got {type(v)}") class HKParameterIntegrationData(BaseModel): type: UUID aid: int iid: int # Overriding types for ease of use (shortcuts) class HKParameter(Parameter): integration_data: HKParameterIntegrationData class HKParameterState(ParameterState): integration_data: HKParameterIntegrationData class HKDevice(Device): integration_data: HKDeviceIntegrationData @property def hk_id(self) -> str: assert self.integration_data and self.integration_data.pairing_data return self.integration_data.pairing_data["AccessoryPairingID"].lower() # never used class HKDeviceState(HKDevice, DeviceState): # HKDevice + DeviceState — only used in isolation for convenient parsing, so the # parameter-type narrowing is safe here. parameters: list[HKParameterState] ================================================================================ # FILE: majordom_homekit/pairings_storage.py ================================================================================ """ TLDR: same purpose as characteristics_storage.py """ from collections.abc import Callable from contextlib import AbstractAsyncContextManager from aiohomekit.model.typed_dicts import HKDeviceID, PairingData from aiohomekit.storage.pairing_data_storage import PairingDataStorageProtocol from majordom_integration_sdk.repository import DeviceRepositoryProtocol from .mapper import HKMajorDomMapper from .models import HKDevice class HKPairingsStorageMajorDom(PairingDataStorageProtocol): def __init__( self, make_device_repository: Callable[[], AbstractAsyncContextManager[DeviceRepositoryProtocol]], mapper: HKMajorDomMapper, ): self.make_device_repository = make_device_repository self.mapper = mapper @property def _integration_name(self): return "HomeKit" # aiohomekit.PairingsStorageType async def get_all(self) -> dict[HKDeviceID, PairingData]: # keyed by the HAP pairing id (HKDeviceID), matching HKCharacteristicsStorage.get_all # and what aiohomekit looks pairings up by — not the MajorDom device UUID. pairings: dict[HKDeviceID, PairingData] = {} async with self.make_device_repository() as device_repository: for device in await device_repository.get_all(as_=HKDevice): if pairing_data := device.integration_data.pairing_data: pairings[device.hk_id] = pairing_data return pairings async def get(self, id: str) -> PairingData | None: uuid = self.mapper.hap_id_to_uuid(id) async with self.make_device_repository() as device_repository: if (device := await device_repository.get(uuid, as_=HKDevice)) and ( pairing_data := device.integration_data.pairing_data ): return pairing_data return None async def save(self, id: str, item: PairingData): uuid = self.mapper.hap_id_to_uuid(id) async with self.make_device_repository() as device_repository: if device := await device_repository.get(uuid, as_=HKDevice): device.integration_data.pairing_data = item await device_repository.save(device) async def delete_model(self, id: str): # TODO: check usage uuid = self.mapper.hap_id_to_uuid(id) async with self.make_device_repository() as device_repository: if device := await device_repository.get(uuid): device.integration_data.pairing_data = None await device_repository.save(device) # TODO: check if should delete the device # Discussion: # this class is responsible solely for managing pairing data storage, and should # not be used for any other purpose. # However, we should ensure there is no case of aiohomekit disconnecting from the # device without notifying us. ================================================================================ # FILE: majordom_homekit/parameters_map.py ================================================================================ """ Supplemental metadata for parameters / characteristics that the API does not expose directly. Maps characteristic types to static values or callables that provide missing information — such as enum labels, value ranges, or units. """ parameters_map = {} ================================================================================ # FILE: majordom_homekit/readme.md ================================================================================ # HomeKit integration Bridges the Hub to HomeKit (HAP) accessories over IP, built on top of [`aiohomekit`](https://github.com/Jc2k/aiohomekit). `aiohomekit` owns the HAP protocol, discovery, pairing crypto, and the live connection; this integration adapts it to the Hub's `AbstractController` contract and persists HomeKit state in the Hub's database. ## Files | File | Responsibility | |---|---| | `controller.py` | `AbstractController` implementation — lifecycle, pairing, control, and the aiohomekit discovery/event delegates | | `mapper.py` | HAP ↔ MajorDom conversions: UUIDs (via the framework helpers), characteristic → parameter, formats/units/permissions/valid-values | | `models.py` | Typed `integration_data` schemas (`HKDevice`, `HKParameter`, …) | | `characteristics_storage.py` | Adapts aiohomekit's characteristics cache onto the Hub's `DeviceRepository` | | `pairings_storage.py` | Adapts aiohomekit's pairing-data storage onto the Hub's `DeviceRepository` | The two storage classes implement aiohomekit's storage protocols so that all HomeKit state lives in the Hub DB rather than aiohomekit's own files. They share the controller's single `HKMajorDomMapper` instance, so every HAP id maps to the same MajorDom UUID everywhere. ## Discovery & pairing - **Discovery** uses the Hub's shared Zeroconf service: aiohomekit browses `_hap._tcp` / `_hap._udp` using the `AsyncZeroconf` handed to it from `dependencies.zeroconf_discovery_service`, and calls back into `_aiohomekit_did_discover`. Already-paired accessories seen again trigger a reconnect; accessories paired to another controller are surfaced but not offered for pairing. - **Pairing** takes a HAP setup code (`CredentialsType.code`, `DDD-DD-DDD`). The credentials type is validated against the discovery's `expected_credentials_options` before pairing. ## Availability aiohomekit only pushes a *became-available* signal, never *became-unavailable*, so the controller polls each paired accessory's connection state (`_availability_loop`) and funnels both directions through `_set_availability`, which dedupes and emits the framework's `controller_did_connect_device` / `controller_did_lose_device`. ## Tests `tests/test_controllers/test_homekit/` runs against aiohomekit's in-process `AccessoryServer` (a fake accessory) — no real hardware needed. ## Not yet implemented - BLE discovery/pairing (aiohomekit supports it; the Hub wires IP only for now). - Multi-accessory bridges save only the first accessory's manufacturer. ================================================================================ # FILE: pyproject.toml ================================================================================ [project] name = "majordom-homekit" version = "0.1.5" description = "HomeKit integration for MajorDom — bridges HomeKit accessories into the MajorDom language." authors = [{ name = "Mark Parker", email = "mark@parker-programs.com" }] readme = "README.md" license = "LicenseRef-PolyForm-Noncommercial-1.0.0" license-files = ["LICENSE"] keywords = ["majordom", "homekit", "hap", "apple", "smart-home", "home-automation", "iot", "integration", "accessory"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Topic :: Home Automation", "Programming Language :: Python :: 3", "Operating System :: OS Independent", ] requires-python = ">=3.12,<3.14" # upper bound comes from parker-aiohomekit dependencies = [ "majordom-integration-sdk (>=0.1.5,<1.0)", "parker-aiohomekit (>=4.0.1,<5.0.0)", ] [project.urls] Homepage = "https://majordom.io" Documentation = "https://docs.majordom.io/device-integration" Repository = "https://github.com/MajorDom-Systems/integration-homekit" Issues = "https://github.com/MajorDom-Systems/integration-homekit/issues" "Commercial licensing" = "https://parker-industries.org/partnership" [dependency-groups] dev = [ "poethepoet (>=0.36.0,<1.0.0)", "ty", "ruff", "pip-audit", "pytest", "pytest-asyncio", "pytest-cov", "pytest-timeout", "pytest-repeat", "pytest-benchmark", "pytest-profiling", "pytest-resource-usage", "coverage", "pympler", "psutil", "faker", ] [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" [tool.poetry] requires-poetry = ">=2.0" packages = [{ include = "majordom_homekit" }] [tool.pytest.ini_options] asyncio_mode = "auto" pythonpath = ["."] filterwarnings = ["once"] [tool.coverage.run] omit = ["tests/*"] [tool.ruff] line-length = 120 indent-width = 4 [tool.ruff.lint] select = ["E", "F", "UP", "B", "SIM", "I"] fixable = ["ALL"] [tool.ruff.format] quote-style = "double" indent-style = "space" line-ending = "auto" docstring-code-format = true [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] "tests/*" = ["E402"] [tool.poe.tasks.install] shell = """ poetry install echo '#!/bin/sh\npoetry run poe check' > .git/hooks/pre-commit chmod +x .git/hooks/pre-commit echo "Pre-commit hook installed." """ [tool.poe.tasks.check] shell = """ code=0 echo "\nRunning ruff lint:" poetry run ruff check --fix || code=1 echo "\nRunning ruff format:" poetry run ruff format || code=1 echo "\nRunning ty:" poetry run ty check || code=1 echo "\nRunning pytest:" poetry run pytest --cov=majordom_homekit --cov-report=xml || code=1 echo "\nRunning poetry build:" poetry build || code=1 echo "\nRunning poetry check:" poetry check || code=1 ${ci:+git diff --exit-code} exit $code """ args = [{ name = "ci", type = "boolean" }] ================================================================================ # FILE: tests/conftest.py ================================================================================ """Unit tests for the HomeKit controller. These drive `HomeKitController` directly — via the SDK's test dependencies, against aiohomekit's in-process virtual accessory server with a mocked zeroconf — so they test the integration on its own, with no Hub. The Hub keeps the e2e coverage (Coordinator + API/ws). """ import socket import tempfile import threading from unittest.mock import AsyncMock, MagicMock, patch from uuid import UUID, uuid5 import pytest import pytest_asyncio from aiohomekit.model.accessories import Accessory from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes from aiohomekit.testing.accessoryserver import AccessoryServer from aiohomekit.testing.mock_zeroconf import ( AsyncServiceBrowserStub, DNSCache, MockedAsyncServiceInfo, install_mock_service_info, ) from aiohomekit.testing.utils import next_available_port, wait_for_server_online from majordom_integration_sdk.controller import AbstractController from majordom_integration_sdk.discovery.zeroconf_discovery import ZeroconfDiscoveryService from majordom_integration_sdk.repository import DeviceRepositoryMemory from majordom_integration_sdk.testing import ( FakeBLEDiscoveryService, FakeSSDPDiscoveryService, RecordingControllerOutput, ) from majordom_homekit import HomeKitController from majordom_homekit.models import HKDeviceIntegrationData, HKDeviceState HAP_TYPE_TCP = "_hap._tcp.local." # The device id the accessory (pairing id 12:34:56:00:01:0A) maps to under HomeKit's slug — # uuid5(uuid5(namespace, "homekit"), "12:34:56:00:01:0a"). Computed the same way the mapper does. INTEGRATION_UUID = uuid5(UUID(int=0), "homekit") DEVICE_ID = uuid5(INTEGRATION_UUID, "12:34:56:00:01:0a") ON_PARAM_ID = uuid5(DEVICE_ID, "1.9") BRIGHTNESS_PARAM_ID = uuid5(DEVICE_ID, "1.10") def provisional_device(pairing_data=None) -> HKDeviceState: """A device row as the Hub has it *before* handing control to the integration: identity and integration_data present, parameters still empty (the controller fills them).""" return HKDeviceState( id=DEVICE_ID, name="Testlicht", room_id=UUID(int=1), transport="IP", integration="HomeKit", manufacturer="", parameters=[], integration_data=HKDeviceIntegrationData(pairing_data=pairing_data, characteristics_cache=None), ) def get_mock_service_info(port: int, is_paired: bool) -> MockedAsyncServiceInfo: desc = { b"c#": b"1", b"id": b"12:34:56:00:01:0A", b"md": b"Demoserver", b"s#": b"1", b"ci": b"5", b"sf": b"0" if is_paired else b"1", } return MockedAsyncServiceInfo( HAP_TYPE_TCP, f"Testlicht.{HAP_TYPE_TCP}", addresses=[socket.inet_aton("127.0.0.1")], port=port, properties=desc, weight=0, priority=0, ) @pytest.fixture(autouse=True) def mock_zeroconf(): with ( patch("majordom_integration_sdk.discovery.zeroconf_discovery.AsyncServiceBrowser", AsyncServiceBrowserStub), patch("majordom_integration_sdk.discovery.zeroconf_discovery.AsyncZeroconf") as mock_zc, patch("zeroconf.asyncio.AsyncServiceBrowser", AsyncServiceBrowserStub), patch("zeroconf.asyncio.AsyncZeroconf", mock_zc), patch("aiohomekit.controller.zeroconf.controller.AsyncServiceInfo", MockedAsyncServiceInfo), ): zc = mock_zc.return_value zc.register_service = AsyncMock() zc.async_close = AsyncMock() zeroconf = MagicMock(name="zeroconf_mock") zeroconf.cache = DNSCache() zeroconf.async_wait_for_start = AsyncMock() zeroconf.listeners = [AsyncServiceBrowserStub()] zc.zeroconf = zeroconf with patch("aiohomekit.testing.accessoryserver.Zeroconf", zc): yield zc @pytest.fixture def id_factory(): counter = 0 def _get_id(): nonlocal counter counter += 1 return counter return _get_id PAIRING_DATA = { "AccessoryPairingID": "12:34:56:00:01:0A", "AccessoryLTPK": "7986cf939de8986f428744e36ed72d86189bea46b4dcdc8d9d79a3e4fceb92b9", "AccessoryLTSK": "3d99f3e959a1f93af4056966f858074b2a1fdec1c5fd84a51ea96f9fa004156a", "iOSDeviceId": "decc6fa3-de3e-41c9-adba-ef7409821bfc", "iOSDeviceLTPK": "d708df2fbf4a8779669f0ccd43f4962d6d49e4274f88b1292f822edc3bcf8ed8", "iOSDeviceLTSK": "fa45f082ef87efc6c8c8d043d74084a3ea923a2253e323a7eb9917b4090c2fcc", "Connection": "IP", "AccessoryAddress": "127.0.0.1", } _ACCESSORY_CONFIG = b"""{ "accessory_ltpk": "7986cf939de8986f428744e36ed72d86189bea46b4dcdc8d9d79a3e4fceb92b9", "accessory_ltsk": "3d99f3e959a1f93af4056966f858074b2a1fdec1c5fd84a51ea96f9fa004156a", "accessory_pairing_id": "12:34:56:00:01:0A", "accessory_pin": "031-45-154", "c#": 1, "category": "Lightbulb", "host_ip": "127.0.0.1", "host_port": %port%, "name": "unittestLight", "peers": %peers%, "unsuccessful_tries": 0 }""" def _make_accessory_server(id_factory, port: int, peers: bytes, with_brightness: bool) -> AccessoryServer: config_file = tempfile.NamedTemporaryFile(delete=False) # noqa: SIM115 (outlives fn; AccessoryServer reads it by path) config_file.write(_ACCESSORY_CONFIG.replace(b"%port%", str(port).encode()).replace(b"%peers%", peers)) config_file.close() server = AccessoryServer(config_file.name, None) accessory = Accessory.create_with_info( aid=id_factory(), name="Testlicht", manufacturer="lusiardi.de", model="Demoserver", serial_number="0001", firmware_revision="0.1", ) bulb = accessory.add_service(ServicesTypes.LIGHTBULB) bulb.add_char(CharacteristicsTypes.ON, value=False) if with_brightness: bulb.add_char(CharacteristicsTypes.BRIGHTNESS, value=0) server.add_accessory(accessory) return server @pytest_asyncio.fixture async def repository(): """The in-memory device repository the controller is handed, scoped to HomeKit.""" return DeviceRepositoryMemory(integration="HomeKit") @pytest_asyncio.fixture async def dependencies(repository, mock_zeroconf, tmp_path): zeroconf_service = ZeroconfDiscoveryService() await zeroconf_service.start() # under mock_zeroconf, so async_zeroconf yields the mock output = RecordingControllerOutput() deps = AbstractController.Dependencies( output=output, make_device_repository=repository.session, documents_folder=tmp_path, zeroconf_discovery_service=zeroconf_service, ssdp_discovery_service=FakeSSDPDiscoveryService(), ble_discovery_service=FakeBLEDiscoveryService(), ) yield deps await zeroconf_service.stop() @pytest_asyncio.fixture async def controller(dependencies): controller = HomeKitController(dependencies) await controller.start() yield controller await controller.stop() @pytest_asyncio.fixture async def unpaired_server(id_factory, mock_zeroconf): port = next_available_port() server = _make_accessory_server(id_factory, port, peers=b"{}", with_brightness=False) thread = threading.Thread(target=server.serve_forever, daemon=True) service_info = get_mock_service_info(port, is_paired=False) async def start(): thread.start() await wait_for_server_online(port) assert not server.data.is_paired return server with install_mock_service_info(mock_zeroconf, service_info): yield start server.shutdown() thread.join() @pytest_asyncio.fixture async def paired_server(id_factory, repository, mock_zeroconf): """A pre-paired accessory + a HomeKit device seeded into the repository the way the Hub would have after pairing.""" port = next_available_port() peers = ( b'{"decc6fa3-de3e-41c9-adba-ef7409821bfc": {"admin": true,' b' "key": "d708df2fbf4a8779669f0ccd43f4962d6d49e4274f88b1292f822edc3bcf8ed8"}}' ) server = _make_accessory_server(id_factory, port, peers=peers, with_brightness=True) pairing_data = {**PAIRING_DATA, "AccessoryPort": port} async with repository.session() as repo: await repo.save(provisional_device(pairing_data)) thread = threading.Thread(target=server.serve_forever, daemon=True) service_info = get_mock_service_info(port, is_paired=True) thread.start() await wait_for_server_online(port) assert server.data.is_paired with install_mock_service_info(mock_zeroconf, service_info): yield server, pairing_data server.shutdown() thread.join() ================================================================================ # FILE: tests/test_controller.py ================================================================================ """Unit tests driving HomeKitController directly (no Hub). Each asserts on both sides: what the controller reported back via the recording output, and what actually happened on the virtual accessory / in the repository. """ import asyncio from uuid import UUID as _UUID import pytest from aiohomekit.controller.zeroconf.ip import IpPairing from aiohomekit.model.characteristics import CharacteristicKey from conftest import BRIGHTNESS_PARAM_ID, DEVICE_ID, ON_PARAM_ID, provisional_device from majordom_integration_sdk.schemas.command import DeviceCommand from majordom_integration_sdk.schemas.device import CredentialsType, ProvidedCredentials from majordom_homekit.models import HKDevice, HKParameter, HKParameterIntegrationData UUID_ONE = _UUID(int=1) async def _wait_for(predicate, timeout: float = 2.0): async with asyncio.timeout(timeout): while not predicate(): await asyncio.sleep(0.02) async def test_discovers_unpaired_accessory(unpaired_server, controller, dependencies): await unpaired_server() output = dependencies.output await _wait_for(lambda: bool(output.received_discoveries)) assert DEVICE_ID in controller.discoveries discovery = controller.discoveries[DEVICE_ID] assert discovery.integration == "HomeKit" assert discovery.transport == "IP" assert discovery.device_name == "Testlicht" assert CredentialsType.code in discovery.expected_credentials_options assert output.received_discoveries[-1].id == DEVICE_ID async def test_pairs_a_discovered_device(unpaired_server, controller, dependencies, repository): server = await unpaired_server() output = dependencies.output await _wait_for(lambda: DEVICE_ID in controller.discoveries) # the Hub has already created the device row before calling pair_device async with repository.session() as repo: await repo.save(provisional_device()) discovery = controller.discoveries[DEVICE_ID] await controller.pair_device(discovery, ProvidedCredentials(type=CredentialsType.code, value="031-45-154")) # device side: the accessory is now paired assert server.data.is_paired # Hub side: the controller reported the connect, and dropped the discovery assert DEVICE_ID in output.connected_devices assert DEVICE_ID not in controller.discoveries # persistence: pairing data landed in the device's integration_data async with repository.session() as repo: device = await repo.get(DEVICE_ID, as_=HKDevice) assert device is not None assert device.integration_data.pairing_data assert device.integration_data.pairing_data["AccessoryPairingID"] == "12:34:56:00:01:0A" async def test_pair_device_rejects_wrong_credentials_type(unpaired_server, controller): await unpaired_server() await _wait_for(lambda: DEVICE_ID in controller.discoveries) discovery = controller.discoveries[DEVICE_ID] with pytest.raises(ValueError): await controller.pair_device(discovery, ProvidedCredentials(type=CredentialsType.qr, value="x")) from majordom_homekit.models import HKDeviceIntegrationData def _brightness_parameter() -> HKParameter: return HKParameter( id=BRIGHTNESS_PARAM_ID, name="Brightness", data_type="integer", unit="plain", role="control", visibility="user", integration_data=HKParameterIntegrationData(type=UUID_ONE, aid=1, iid=10), ) def _hk_device(pairing_data) -> HKDevice: return HKDevice( id=DEVICE_ID, name="Testlicht", room_id=UUID_ONE, transport="IP", integration="HomeKit", manufacturer="lusiardi.de", integration_data=HKDeviceIntegrationData(pairing_data=pairing_data, characteristics_cache=None), ) async def test_unpairs_a_device(paired_server, controller, dependencies): server, pairing_data = paired_server await _wait_for(lambda: DEVICE_ID in dependencies.output.connected_devices) assert server.data.is_paired await controller.unpair(_hk_device(pairing_data)) assert not server.data.is_paired async def test_sends_a_command_to_the_device(paired_server, controller, dependencies): _, pairing_data = paired_server await _wait_for(lambda: DEVICE_ID in dependencies.output.connected_devices) value = 42 brightness = _brightness_parameter() await controller.send_command( DeviceCommand(device_id=DEVICE_ID, parameter_id=BRIGHTNESS_PARAM_ID, value=value), _hk_device(pairing_data), brightness, ) # device side: the accessory actually took the new brightness got = await IpPairing(pairing_data).get_characteristics([CharacteristicKey(1, 10)]) assert got == {CharacteristicKey(1, 10): {"value": value}} async def test_reports_device_state_as_events_on_connect(paired_server, controller, dependencies): # The device -> Hub reporting path: on connecting to a paired accessory the controller # reads its characteristics and reports each as a DeviceParameterChange. (This is what the # Hub's e2e test_events actually exercises — it uses value == the initial, so it never # distinguished a pushed change; the mock accessory server doesn't deliver async pushes # to the observer in-process. The reporting *channel* is what matters and is covered here.) _, pairing_data = paired_server output = dependencies.output await _wait_for(lambda: DEVICE_ID in output.connected_devices) await _wait_for(lambda: any(e.parameter_id == ON_PARAM_ID for e in output.events)) on_events = [e for e in output.events if e.parameter_id == ON_PARAM_ID] assert on_events[-1].value is False # the accessory's ON characteristic starts False async def test_identify(paired_server, controller, dependencies): _, pairing_data = paired_server await _wait_for(lambda: DEVICE_ID in dependencies.output.connected_devices) # smoke: identify reaches the accessory without error await controller.identify(_hk_device(pairing_data))