-
Notifications
You must be signed in to change notification settings - Fork 2
docs(export): PRD research pass + bridge protocol spec #118
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,345 @@ | ||||||||||||||||||||||
| # BRIDGE_PROTOCOL.md — plugin ⇄ bridge-node local protocol | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| **Version:** 1 (`protocolVersion: 1`) | ||||||||||||||||||||||
| **Transport:** WebSocket, JSON text frames, loopback only, default port **5581** | ||||||||||||||||||||||
| (pref-configurable; the controller's WS is 5580) | ||||||||||||||||||||||
| **Peers:** the Indigo plugin (client) and the `indigo-matter-bridge` node | ||||||||||||||||||||||
| (server). We author both ends and ship them together. | ||||||||||||||||||||||
| **Governing docs:** [`PRD-indigo-matter-export.md`](./PRD-indigo-matter-export.md) §4.4, | ||||||||||||||||||||||
| ADR-0006 (workspace-level). | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Section references: a bare `§N` refers to this document; PRD sections are | ||||||||||||||||||||||
| always written `PRD §N`. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| This is the outbound twin of the controller protocol that `protocol.py` / | ||||||||||||||||||||||
| `matter_client.py` speak. It reuses the same envelope grammar so the client | ||||||||||||||||||||||
| machinery and test doubles generalise, but it is **not** matter-server's | ||||||||||||||||||||||
| protocol and has no rename firewall: a field rename here is a coordinated edit | ||||||||||||||||||||||
| to both peers in one release. What protects us instead is the **version | ||||||||||||||||||||||
| handshake** — launchd deliberately keeps an old bridge node running across | ||||||||||||||||||||||
| plugin reloads (PM-B), so plugin/node version skew is the failure mode that | ||||||||||||||||||||||
| will actually occur. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ## 1. Envelope grammar | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Identical shapes to the controller protocol: | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| | Kind | Direction | Shape | | ||||||||||||||||||||||
| |---|---|---| | ||||||||||||||||||||||
| | Request | plugin → node | `{"message_id": "<str>", "command": "<str>", "args": {…}}` | | ||||||||||||||||||||||
| | Success response | node → plugin | `{"message_id": "<str>", "result": …}` | | ||||||||||||||||||||||
| | Error response | node → plugin | `{"message_id": "<str>", "error_code": "<str>", "details": "<str>"}` | | ||||||||||||||||||||||
| | Event | node → plugin | `{"event": "<str>", "data": {…}}` (no `message_id`) | | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| `message_id` is an opaque string chosen by the plugin, echoed verbatim. | ||||||||||||||||||||||
| **Every request gets exactly one response**, including ones the plugin chooses | ||||||||||||||||||||||
| not to await (§3.4). Frames on one connection are processed strictly in | ||||||||||||||||||||||
| receipt order, so a `set_state` sent after an `upsert_endpoint` on the same | ||||||||||||||||||||||
| socket is applied after it. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Unknown *fields* are ignored by both peers (forward compatibility inside a | ||||||||||||||||||||||
| protocol version). Unknown *commands* get `error_code: "unknown_command"`. | ||||||||||||||||||||||
| Unknown *events* are logged and dropped by the plugin. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ### 1.1 Error codes | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| The complete `error_code` domain for protocol version 1. Anything else is a | ||||||||||||||||||||||
| bug in the node. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| | `error_code` | Meaning | | ||||||||||||||||||||||
| |---|---| | ||||||||||||||||||||||
| | `unknown_command` | Command name not in §3 | | ||||||||||||||||||||||
| | `malformed_args` | Args missing/mistyped for the command | | ||||||||||||||||||||||
| | `version_mismatch` | `attach` carried a different `protocolVersion`; node refuses and closes the socket after responding | | ||||||||||||||||||||||
| | `not_attached` | Any command other than `attach` before a successful `attach` on this connection | | ||||||||||||||||||||||
| | `unknown_device` | `indigoDeviceId` has no live endpoint | | ||||||||||||||||||||||
| | `unknown_role` | `role` not in the §4.2 enum | | ||||||||||||||||||||||
| | `role_change` | `upsert_endpoint` tried to change an existing endpoint's role (§4.1) | | ||||||||||||||||||||||
| | `mass_removal_refused` | `attach` would remove every live endpoint without `"intent": "replace_all"` (§3.1) | | ||||||||||||||||||||||
| | `endpoint_map_invalid` | Node is in the refuse-to-start state (PRD §7); only `get_status`, `get_pairing` and `rebuild_endpoint_map` are accepted | | ||||||||||||||||||||||
| | `commissioning_window_failed` | Matter stack refused to open the enhanced commissioning window | | ||||||||||||||||||||||
| | `internal` | Unexpected node-side failure; `details` carries the message | | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ## 2. Handshake | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| On every new connection, before anything else: | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 1. Node sends a bare frame (not an event, mirroring `server_info`): | ||||||||||||||||||||||
| `{"protocolVersion": 1, "bridgeVersion": "<npm semver>", "matterJsVersion": "<semver>"}` | ||||||||||||||||||||||
| 2. Plugin sends `attach` (§3.1). If `protocolVersion` differs from the | ||||||||||||||||||||||
| plugin's own, the plugin does **not** attach: it surfaces an error telling | ||||||||||||||||||||||
| the user to restart/update the bridge agent (typically the plugin was | ||||||||||||||||||||||
| updated while launchd kept the old node alive). Node-side, a mismatched | ||||||||||||||||||||||
| `attach` gets `error_code: "version_mismatch"` and the socket is closed — | ||||||||||||||||||||||
| skew fails closed from both directions. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| The node accepts exactly one attached client at a time; a new successful | ||||||||||||||||||||||
| `attach` supersedes the incumbent (the old socket is closed). This exists to | ||||||||||||||||||||||
| recover from half-open sockets: if the plugin's TCP connection dies silently | ||||||||||||||||||||||
| (plugin crash, reload), the node would otherwise hold a dead "attached" client | ||||||||||||||||||||||
| forever and refuse the reconnecting plugin. A connection that completes the | ||||||||||||||||||||||
| handshake but has not attached within **10 seconds** is closed by the node. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ## 3. Commands (plugin → node) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ### 3.1 `attach` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Declares the client and delivers the desired endpoint set in one shot. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ```json | ||||||||||||||||||||||
| {"command": "attach", "args": { | ||||||||||||||||||||||
| "protocolVersion": 1, | ||||||||||||||||||||||
| "pluginVersion": "2026.8.1", | ||||||||||||||||||||||
| "endpoints": [ <EndpointSpec>, … ] | ||||||||||||||||||||||
| }} | ||||||||||||||||||||||
|
Comment on lines
+90
to
+94
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Align the attach example with the released plugin version. The PR objective bumps Suggested correction- "pluginVersion": "2026.8.1",
+ "pluginVersion": "2026.7.22",📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| ``` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Result: `<StatusReport>` (§4.3). The node reconciles its live endpoint set | ||||||||||||||||||||||
| against `endpoints` — creating, updating and removing as needed — so a fresh | ||||||||||||||||||||||
| connection is always a full reconcile (PRD §5.4 `startup`). | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| **Mass-removal guard.** If `endpoints` is empty (or would remove every live | ||||||||||||||||||||||
| endpoint) while the node currently serves a non-empty set, the node refuses | ||||||||||||||||||||||
| with `error_code: "mass_removal_refused"` unless the args carry | ||||||||||||||||||||||
| `"intent": "replace_all"`. Removing every exported accessory from every paired | ||||||||||||||||||||||
| ecosystem must be deliberate (the §5.1 allow-list being emptied), never the | ||||||||||||||||||||||
| side effect of a stale or buggy client attaching with a default state. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ### 3.2 `upsert_endpoint` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ```json | ||||||||||||||||||||||
| {"command": "upsert_endpoint", "args": {"endpoint": <EndpointSpec>}} | ||||||||||||||||||||||
| ``` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Creates the endpoint if absent, updates label/reachable/state if present. | ||||||||||||||||||||||
| Idempotent. Result: `{"endpointNumber": <int>}`. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ### 3.3 `remove_endpoint` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ```json | ||||||||||||||||||||||
| {"command": "remove_endpoint", "args": {"indigoDeviceId": 123456789}} | ||||||||||||||||||||||
| ``` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Removes the child endpoint (`endpoint.close()`); the persisted endpoint-number | ||||||||||||||||||||||
| allocation is **retained** so re-adding the same device restores the same | ||||||||||||||||||||||
| number. Idempotent — removing an absent endpoint succeeds with | ||||||||||||||||||||||
| `{"removed": false}`; a live removal returns `{"removed": true}`. Bulk | ||||||||||||||||||||||
| removals are paced ~100ms apart by the node. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ### 3.4 `set_state` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ```json | ||||||||||||||||||||||
| {"command": "set_state", "args": {"indigoDeviceId": 123456789, "states": {"onOff": true}}} | ||||||||||||||||||||||
| ``` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Pushes Indigo-originated state outward. `states` keys are role-specific (§4.2). | ||||||||||||||||||||||
| The node applies them as **local** (offline-context) writes so they are not | ||||||||||||||||||||||
| echoed back as `command` events. Result: `{}` on success, or a normal error | ||||||||||||||||||||||
| response (`unknown_device`, `malformed_args`). | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| The plugin sends this **without awaiting the result** — it must never block | ||||||||||||||||||||||
| Indigo's device thread. The response still arrives; the client's frame loop | ||||||||||||||||||||||
| MUST log any error response it cannot match to a waiting future (rather than | ||||||||||||||||||||||
| dropping it silently), because an unnoticed `set_state` failure looks exactly | ||||||||||||||||||||||
| like "the ecosystem shows stale state". This is a required behaviour change | ||||||||||||||||||||||
| from `matter_client.py`, which drops unmatched responses without a log line. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ### 3.5 `set_reachable` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ```json | ||||||||||||||||||||||
| {"command": "set_reachable", "args": {"indigoDeviceId": 123456789, "reachable": false}} | ||||||||||||||||||||||
| ``` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Split from `set_state` because it maps to Bridged Device Basic Information, | ||||||||||||||||||||||
| not the functional cluster, and is driven by device enable/disable rather than | ||||||||||||||||||||||
| state change. `reachable` is also present on `EndpointSpec`; both paths write | ||||||||||||||||||||||
| the same attribute and last-write-wins — there is no precedence rule. | ||||||||||||||||||||||
| Result: `{}`. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ### 3.6 `get_status` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Result: `<StatusReport>` (§4.3) — the same shape `attach` returns. Used by the | ||||||||||||||||||||||
| watchdog tick and the PRD §5.5 config readout. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ### 3.7 `get_pairing` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Reports pairing state. A Matter commissioning passcode is **not durable**: | ||||||||||||||||||||||
| once the first fabric commissions, the basic window closes and the original | ||||||||||||||||||||||
| code stops working; each additional admin needs an *enhanced* commissioning | ||||||||||||||||||||||
| window with a freshly derived code (§3.8). | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Result: | ||||||||||||||||||||||
| ```json | ||||||||||||||||||||||
| {"commissioned": true, | ||||||||||||||||||||||
| "windowOpen": false, | ||||||||||||||||||||||
| "windowExpiresAt": null, | ||||||||||||||||||||||
| "manualPairingCode": null, | ||||||||||||||||||||||
| "qrPairingCode": null, | ||||||||||||||||||||||
| "fabrics": [ <FabricInfo>, … ]} | ||||||||||||||||||||||
| ``` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| `manualPairingCode`/`qrPairingCode` are non-null only while a window is open | ||||||||||||||||||||||
| (always true for the never-commissioned initial state, whose codes are the | ||||||||||||||||||||||
| persisted originals). | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ### 3.8 `open_commissioning_window` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ```json | ||||||||||||||||||||||
| {"command": "open_commissioning_window", "args": {"durationSeconds": 900}} | ||||||||||||||||||||||
| ``` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Opens an enhanced commissioning window so another ecosystem can be added. | ||||||||||||||||||||||
| Result: `{"manualPairingCode": "...", "qrPairingCode": "MT:...", | ||||||||||||||||||||||
| "windowExpiresAt": "<ISO 8601 UTC>"}`. Fails with | ||||||||||||||||||||||
| `commissioning_window_failed` if the stack refuses (e.g. a window is already | ||||||||||||||||||||||
| open in a conflicting state). Emits `window_closed` (§5) when it expires or a | ||||||||||||||||||||||
| commissioner completes. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ### 3.9 `remove_fabric` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ```json | ||||||||||||||||||||||
| {"command": "remove_fabric", "args": {"fabricIndex": 2}} | ||||||||||||||||||||||
| ``` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Result: `{}`. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ### 3.10 `factory_reset` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Wipes commissioning credentials and starts advertising fresh (PRD §6 "reset | ||||||||||||||||||||||
| all pairings"). The endpoint-number map is **preserved by default** — a reset | ||||||||||||||||||||||
| must not scramble identities if the user re-pairs the same ecosystems. This | ||||||||||||||||||||||
| requires the map to be persisted **outside** the matter.js storage context | ||||||||||||||||||||||
| that its factory reset wipes: the node keeps it in its own file | ||||||||||||||||||||||
| (`endpoint-map.json`) in the bridge storage dir, written through the same | ||||||||||||||||||||||
| persistence layer as the drift detector's baseline. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ```json | ||||||||||||||||||||||
| {"command": "factory_reset", "args": {"preserveEndpointNumbers": true}} | ||||||||||||||||||||||
| ``` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Passing `false` wipes the map too — the "explicit rebuild" of PRD §7, for the | ||||||||||||||||||||||
| case where the map itself is what's corrupt. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ### 3.11 `rebuild_endpoint_map` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| The recovery path out of the `endpoint_map_invalid` refuse-to-start state | ||||||||||||||||||||||
| (PRD §7 "Endpoint map lost/corrupt"). Reallocates endpoint numbers for the | ||||||||||||||||||||||
| current endpoint set from scratch and persists a new map. This **will** | ||||||||||||||||||||||
| duplicate accessories in paired ecosystems — that is exactly why it is a | ||||||||||||||||||||||
| separate, explicit command that the plugin only issues after the user | ||||||||||||||||||||||
| confirms via a warning dialog. Result: `<StatusReport>`. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ## 4. Shapes | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ### 4.1 `EndpointSpec` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ```json | ||||||||||||||||||||||
| {"indigoDeviceId": 123456789, | ||||||||||||||||||||||
| "role": "onOffLight", | ||||||||||||||||||||||
| "label": "Kitchen Lamp", | ||||||||||||||||||||||
| "reachable": true, | ||||||||||||||||||||||
| "states": {"onOff": true}, | ||||||||||||||||||||||
| "options": {}} | ||||||||||||||||||||||
| ``` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| - `indigoDeviceId` — the immutable Indigo device ID. The node derives | ||||||||||||||||||||||
| `Endpoint.id` and `UniqueID` from it (PRD §4.3); it is the identity key | ||||||||||||||||||||||
| everywhere in this protocol. | ||||||||||||||||||||||
| - `role` — one of the v1 role enum (§4.2). A role change for an existing | ||||||||||||||||||||||
| endpoint is **rejected** (`error_code: "role_change"`); the plugin must | ||||||||||||||||||||||
| remove and re-add, because ecosystems cache device types per endpoint. | ||||||||||||||||||||||
| - `label` — Bridged Device Basic Information `NodeLabel`. | ||||||||||||||||||||||
| - `options` — role-specific extras (e.g. window-covering polarity). | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ### 4.2 Roles: state keys and commands (v1) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Each role defines two vocabularies: the **state keys** the plugin pushes via | ||||||||||||||||||||||
| `set_state`, and the **commands** the node emits as `command` events when an | ||||||||||||||||||||||
| ecosystem acts. Both are enumerated here in full; there is no other source. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| | `role` | Matter device type | `set_state` keys | `command` names (args) | | ||||||||||||||||||||||
| |---|---|---|---| | ||||||||||||||||||||||
| | `onOffPlugInUnit` | On/Off Plug-in Unit | `onOff: bool` | `onOff {"value": bool}` | | ||||||||||||||||||||||
| | `onOffLight` | On/Off Light | `onOff: bool` | `onOff {"value": bool}` | | ||||||||||||||||||||||
| | `dimmableLight` | Dimmable Light | `onOff: bool`, `level: 0-100` | `onOff`, `setLevel {"level": 0-100}` | | ||||||||||||||||||||||
| | `colorTemperatureLight` | Color Temperature Light | + `colorTempMireds: 153-500` | + `setColorTemp {"colorTempMireds": int}` | | ||||||||||||||||||||||
| | `extendedColorLight` | Extended Color Light | + `hue: 0-360`, `saturation: 0-100` | + `setColor {"hue": 0-360, "saturation": 0-100}` | | ||||||||||||||||||||||
| | `windowCovering` | Window Covering | `position: 0-100` (100 = open, inbound convention; polarity in `options`) | `goToPosition {"position": 0-100}`, `stopMotion {}` | | ||||||||||||||||||||||
| | `doorLock` | Door Lock | `locked: bool` | `lock {}`, `unlock {}` | | ||||||||||||||||||||||
| | `occupancySensor` | Occupancy Sensor | `occupied: bool` | — (sensors emit no commands) | | ||||||||||||||||||||||
| | `contactSensor` | Contact Sensor | `contact: bool` (true = closed) | — | | ||||||||||||||||||||||
| | `temperatureSensor` | Temperature Sensor | `temperatureC: float` | — | | ||||||||||||||||||||||
| | `humiditySensor` | Humidity Sensor | `humidityPct: float` | — | | ||||||||||||||||||||||
| | `lightSensor` | Light Sensor | `lux: float` | — | | ||||||||||||||||||||||
| | `pressureSensor` | Pressure Sensor | `pressureKPa: float` | — | | ||||||||||||||||||||||
| | `flowSensor` | Flow Sensor | `flowM3h: float` | — | | ||||||||||||||||||||||
| | `thermostat` | Thermostat | `localTemperatureC: float`, `heatingSetpointC: float`, `coolingSetpointC: float`, `systemMode: str` | `setHeatingSetpoint {"valueC": float}`, `setCoolingSetpoint {"valueC": float}`, `setSystemMode {"mode": str}` | | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| - `systemMode` domain (both directions): `"off" | "heat" | "cool" | "auto"`. | ||||||||||||||||||||||
| The node owns the mapping to/from Matter's `SystemModeEnum` integers. | ||||||||||||||||||||||
| - `level`/`position` are integers 0–100 in both directions; the node owns the | ||||||||||||||||||||||
| 0–254 Matter LevelControl conversion and its rounding (round-half-up, with | ||||||||||||||||||||||
| 0 ↔ off preserved exactly). | ||||||||||||||||||||||
| - Thermostat **fan is not part of v1** (the Fan descope, PRD §5.2); there are | ||||||||||||||||||||||
| no fan state keys or commands. v2 candidate. | ||||||||||||||||||||||
| - Units are Indigo-natural at the protocol boundary (°C, %, lux, 0–100); | ||||||||||||||||||||||
| the node owns all Matter wire conversions (0.01°C, mireds bounds-clamping, | ||||||||||||||||||||||
| the illuminance log scale). Exactly one converter per role, in the node, | ||||||||||||||||||||||
| next to the cluster it feeds. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ### 4.3 `StatusReport` and `FabricInfo` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ```json | ||||||||||||||||||||||
| {"commissioned": true, | ||||||||||||||||||||||
| "fabrics": [{"fabricIndex": 1, "label": "Apple Home", "vendorId": 4937}], | ||||||||||||||||||||||
| "endpointCount": 12, | ||||||||||||||||||||||
| "endpoints": [{"indigoDeviceId": 123456789, "endpointNumber": 2, "role": "onOffLight"}], | ||||||||||||||||||||||
| "drift": []} | ||||||||||||||||||||||
| ``` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| `drift` lists any `UniqueID → endpointNumber` mappings that changed since last | ||||||||||||||||||||||
| persist (PRD §4.3 drift detection); non-empty drift is surfaced as a plugin | ||||||||||||||||||||||
| error, never auto-repaired. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ## 5. Events (node → plugin) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| | Event | `data` | Meaning | | ||||||||||||||||||||||
| |---|---|---| | ||||||||||||||||||||||
| | `command` | `{"indigoDeviceId", "command", "args"}` | Ecosystem-originated action; names and args exactly as enumerated per role in §4.2 | | ||||||||||||||||||||||
| | `fabrics_changed` | `{"fabrics": […], "change": "added"\|"deleted"\|"updated"}` | Pairing/unpairing activity | | ||||||||||||||||||||||
| | `commissioned` / `decommissioned` | `{}` | First fabric added / last removed | | ||||||||||||||||||||||
| | `window_closed` | `{"reason": "expired"\|"commissioned"}` | The enhanced commissioning window ended | | ||||||||||||||||||||||
| | `drift_detected` | `{"drift": […]}` | Endpoint-number drift found at startup | | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| The plugin resolves `indigoDeviceId` through the allow-list before acting; a | ||||||||||||||||||||||
| command for a device no longer exported gets no action and a warning (PRD §7 | ||||||||||||||||||||||
| race row — the node responds to the ecosystem with failure when the endpoint | ||||||||||||||||||||||
| is already gone). | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ## 6. Invariants | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 1. **Loopback only.** The node binds `127.0.0.1`; there is no auth on the | ||||||||||||||||||||||
| socket because there is no remote surface. The mass-removal guard (§3.1) | ||||||||||||||||||||||
| exists because "local process" still includes stale plugin instances. | ||||||||||||||||||||||
| 2. **The plugin is the source of truth for the export set**; the node is the | ||||||||||||||||||||||
| source of truth for commissioning state and endpoint numbers. Neither peer | ||||||||||||||||||||||
| caches the other's domain across reconnects — `attach` reconciles. | ||||||||||||||||||||||
| 3. **Identity flows one way:** `indigoDeviceId` → `Endpoint.id` → persisted | ||||||||||||||||||||||
| endpoint number. Nothing is ever keyed on list position or label. | ||||||||||||||||||||||
| 4. **State pushes are echo-guarded** in the node (`ctx.offline`); the plugin | ||||||||||||||||||||||
| never receives a `command` event for a change it pushed. | ||||||||||||||||||||||
| 5. **Version skew fails closed:** mismatched `protocolVersion` means no | ||||||||||||||||||||||
| attach (both peers enforce it), an error in the Indigo log, and untouched | ||||||||||||||||||||||
| pairings. | ||||||||||||||||||||||
| 6. **Destructive operations are explicit:** emptying the endpoint set needs | ||||||||||||||||||||||
| `intent: "replace_all"`; discarding endpoint identity needs | ||||||||||||||||||||||
| `preserveEndpointNumbers: false` or `rebuild_endpoint_map`. Neither can | ||||||||||||||||||||||
| happen as a default. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ## 7. Testing contract | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Golden frames for every command/response/event pair live as **JSON fixture | ||||||||||||||||||||||
| files** under `tests/fixtures/bridge_protocol/`, consumed by both the Python | ||||||||||||||||||||||
| suite and the TypeScript node's protocol tests — a deliberate departure from | ||||||||||||||||||||||
| `test_golden_real.py`'s inline-dict pattern, which a TS suite cannot import. | ||||||||||||||||||||||
| A frame change that only updates one side fails that side's suite. | ||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.