Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ loop→Indigo writes go straight through `device_sync.apply_states` (thread-safe
| `matter_client.py` | matter-server client (inbound): the `serverLocation` URI, the `server_info` + `start_listening` handshake, controller command wrappers |
| `bridge_protocol.py` | Bridge-node wire contract — envelope/commands/error codes/roles + normalised `BridgeCommand`/`StatusReport`/`PairingReport`/`FabricInfo`. **Not** a rename firewall (we own both ends); `protocolVersion` is what protects us. See `docs/BRIDGE_PROTOCOL.md` |
| `bridge_client.py` | Bridge-node client (outbound export): hello+attach handshake that fails closed on version skew, endpoint CRUD, fire-and-forget `set_state`, §5 event callbacks. Attach refusals are triaged (§1.1): `version_mismatch`/`mass_removal_refused` halt with a `halted_reason`, `endpoint_map_invalid` holds the socket open un-attached in a `recovery` state so the §3.11 rebuild stays reachable, anything else reconnects on the normal backoff |
| `export_store.py` | The export allow-list (PRD-indigo-matter-export §5.1): `ExportEntry` (device id + role + name override + options) and an `RLock`'d store persisted as ONE JSON string in `pluginPrefs["matterExports"]`, schema-versioned. A blob it cannot parse is moved aside to `matterExports.corrupt` and the store starts empty — user config is never silently discarded |
| `export_catalog.py` | Indigo device → eligible Matter roles, or an `Excluded(reason)` shown in the picker (PRD §5.2, XAC9). The loop guard (XNG3/XAC6) is `pluginId` and nothing else, checked before any type reasoning. Type dispatch walks the IOM **class-name chain**, not `isinstance` — the indigo module is a MagicMock under test |
| `launch_agent.py` | Generic launchd LaunchAgent machinery (npm/npx/node resolution, plist authoring, applied-plist digest, orphan/EADDRINUSE reaping), driven by a frozen `AgentSpec` that carries one agent's identity. Extracted so the Matter **bridge node** can be a second agent without duplicating it (PRD-indigo-matter-export §4.2 / XOQ3) |
| `server_process.py` | `ServerProcess` = the matter-server (controller) specialisation of `LaunchAgent`: its prefs, its argv, its pinned version. Gated by the `serverLocation` pref — the config asks "is matter-server on this Mac?"; `local` (turnkey default) manages it here on loopback, `remote` connects to a server elsewhere. `manageLaunchAgent`/host/port are derived from that in `startup` (see `plugin.py:server_location`) |
| `commission_jobs.py` | Commissioning job state machine (API.md §3.2/§3.3) |
Expand Down
8 changes: 7 additions & 1 deletion docs/HANDOVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,13 @@ Domio no longer commissions; it relays a **share code** (Apple Home is admin 1;

`plugin.py` lifecycle/glue + IWS `http_*` handlers + action bridge · `async_runtime.py` the loop · `protocol.py` **rename firewall** (MatterCommand/MatterWrite/MatterEvent; field names verified vs v0.6.2) · `ws_json_client.py` **shared transport** (run loop, backoff, message_id↔future correlation, disconnect + repeated-failure diagnostics; handshake/vocabulary are subclass hooks) · `matter_client.py` matter-server client on top of it (server_info + start_listening handshake, controller wrappers) · `commission_jobs.py` job state machine · `http_handlers.py` routing · `device_sync.py` reconcile + priming + state/command/write seams + node_added · `matter_model.py` parse get_node (flat `attributes` map → endpoints) · `fabric_backup.py` storage-dir backup/restore (issue #26; zip into sibling `backups/`, move-aside restore, prune) · `launch_agent.py` generic AgentSpec + LaunchAgent machinery (launchd/npm/node, XOQ3 extraction) · `server_process.py` the controller AgentSpec + prefs on top of it (launchd PM-B, off by default) · `matter_handlers/` one ClusterHandler per cluster + registry.

**Export side (E1, `docs/BRIDGE_PROTOCOL.md`):** `bridge_protocol.py` the wire contract (envelope, §3 commands, §1.1 error codes, §4.2 roles, normalised `BridgeCommand`/`StatusReport`/`PairingReport`/`FabricInfo`; **no** rename firewall — we own both ends) · `bridge_client.py` the client (hello+attach handshake, **fails closed** on `protocolVersion` skew via `on_version_skew` + halt, endpoint CRUD, fire-and-forget `set_state`, §5 event callbacks) · `bridge-node/` the TypeScript node · `tests/fixtures/bridge_protocol/frames.json` the ONE golden-frame file both suites read (§7; `npm test` copies it into the TS build). Not yet wired into `plugin.py` — that is E2+.
**Export side (E1, `docs/BRIDGE_PROTOCOL.md`):** `bridge_protocol.py` the wire contract (envelope, §3 commands, §1.1 error codes, §4.2 roles, normalised `BridgeCommand`/`StatusReport`/`PairingReport`/`FabricInfo`; **no** rename firewall — we own both ends) · `bridge_client.py` the client (hello+attach handshake, **fails closed** on `protocolVersion` skew via `on_version_skew` + halt, endpoint CRUD, fire-and-forget `set_state`, §5 event callbacks) · `bridge-node/` the TypeScript node · `tests/fixtures/bridge_protocol/frames.json` the ONE golden-frame file both suites read (§7; `npm test` copies it into the TS build).

**Export side (E2, allow-list + UI):** `export_store.py` the allow-list model (`ExportEntry` = device id + §4.2 role + name override + options; `RLock`'d, one schema-versioned JSON string in `pluginPrefs["matterExports"]`, unparseable blobs moved aside to `matterExports.corrupt` rather than discarded) · `export_catalog.py` the PRD §5.2 mapping (eligible roles + safe default, or `Excluded(reason)`; loop guard is `pluginId` only — XNG3/XAC6 — and type dispatch walks the IOM class-name chain because `isinstance` is unusable against the MagicMock'd indigo module) · `MenuItems.xml` → **Manage Matter Exports…**, the UI-D dialog: no `<CallbackMethod>` (so it gets a single Close button and the in-dialog buttons do the work), filter textfield + Apply-filter button + single-select device `menu` with `dynamicReload` (a multi-select `list` has NO CallbackMethod, so master-detail is impossible with one) + role menu + name/polarity fields + Add/Remove buttons + a readonly `exportStatus` textfield (Indigo labels cannot change at runtime) + a readonly summary list. `plugin.py` builds the store in `startup` and owns the callbacks. `bridge_client` is still **not** wired to the store — that is E3.

**E2 hardening (PR #122) — read before building E3.** The store persists *then* commits: `_commit` writes the pref, flushes through the injected `save_prefs` (`indigo.server.savePluginPrefs`), and only then adopts the new map in memory, rolling the pref back if the flush raises — so memory and prefs can never disagree. It holds a `prefs_getter` callable, not the `pluginPrefs` object, because Indigo may rebind that on a PluginConfig save. A load failure is carried in `store.load_error` and shown in the dialog instead of "Nothing is exported yet.", and `matterExports.corrupt` is **first-rescue-wins** (a second corruption never overwrites it).

> **The store is NOT the guard. E3 MUST re-classify at endpoint-build time.** The injected `entry_validator` re-runs the loop guard over entries restored from prefs, and `ExportEntry.from_dict` enforces the options shape per role (`invert` only on `windowCovering`) — but both run at *load*, against the database as it was then. A device can change type, gain our `pluginId`, or be replaced between load and endpoint build. Every endpoint E3 builds must call `export_catalog.classify` again and refuse anything that comes back `Excluded`; treating a store hit as proof of eligibility reintroduces exactly the loop (XNG3/XAC6) the guard exists to prevent.

**Key invariants:** node-details has NO `endpoints` key (derive from flat `attributes`); `attribute_updated` data is `[node_id,"ep/cl/at",value]`; `node_removed` is a bare id; `server_info` is a bare connect frame (`sdk_version`/`fabric_id`). Setpoints/modes are attribute **writes**, not commands.

Expand Down
4 changes: 2 additions & 2 deletions docs/PRD-indigo-matter-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,8 @@ them into a PRD where they can rot.
| Garage doors | Needs the polarity handling the catalog doesn't yet carry (`onState` true = closed, turnOn = close), and mis-mapping is a physical-safety issue. Blocked on the catalog role/polarity work |
| Sensors with units outside the table | No faithful Matter sensor type |

Excluded devices must be **absent from the picker with a reason shown**, not
silently missing.
Excluded devices must **appear in the picker as excluded, with reasons** (XAC9),
not silently missing.
Comment on lines +238 to +239

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

This XAC9 statement now contradicts §5.1.

Line 178 states the candidate list MUST be filtered by plugin ID to exclude indigo-matter's own devices. Lines 238-239 state excluded devices must appear in the picker with reasons. Devices caught by the loop guard fall under both rules.

The implementation follows the new rule: _candidate_row in plugin.py returns an x--prefixed row labelled with REASON_LOOP_GUARD, so own devices are listed and not selectable. The loop guard remains structural through export_catalog.classify and the server-side refusal in exportAddOrUpdate. Only the §5.1 wording is stale.

Update §5.1 so it describes the loop guard as classification plus a non-selectable row, not as removal from the list.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/PRD-indigo-matter-export.md` around lines 238 - 239, Update §5.1 in
docs/PRD-indigo-matter-export.md to remove the requirement that indigo-matter’s
own devices be filtered out of the candidate list. Describe the loop guard as
classifying those devices and displaying them as non-selectable rows with the
REASON_LOOP_GUARD reason, consistent with _candidate_row,
export_catalog.classify, and exportAddOrUpdate.


### 5.3 Bridge node

Expand Down
2 changes: 1 addition & 1 deletion indigo-matter.indigoPlugin/Contents/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<key>IwsApiVersion</key>
<string>1.0.0</string>
<key>PluginVersion</key>
<string>2026.7.25</string>
<string>2026.7.26</string>
<key>ServerApiVersion</key>
<string>3.6</string>
</dict>
Expand Down
95 changes: 95 additions & 0 deletions indigo-matter.indigoPlugin/Contents/Server Plugin/MenuItems.xml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,101 @@
</Field>
</ConfigUI>
</MenuItem>
<!--
Manage Matter Exports (PRD-indigo-matter-export §5.1, UI-D).

Deliberately has NO <CallbackMethod>: with one, the dialog gets
Execute/Cancel and every action has to funnel through Execute. Without
one it gets a single Close button and the buttons below do the work —
which is what the SDK recommends for a multi-action dialog, and what a
master-detail pane needs.

Shape is dictated by what Indigo's dialogs can actually do: a multi-
select `list` has no CallbackMethod (no selection events) and a
`textfield` has none either, so the master-detail is a filter textfield
+ a reload button + a SINGLE-select `menu` picker (menus DO have
CallbackMethod) whose callback loads the picked device's saved settings
into the sibling fields. Labels are static in Indigo, so runtime
feedback goes in the read-only `exportStatus` textfield.
-->
<MenuItem id="manageMatterExports">
<Name>Manage Matter Exports…</Name>
<ConfigUI>
<Field id="exportIntro" type="label">
<Label>Choose which Indigo devices are exported to Apple Home and other ecosystems as Matter accessories. Nothing is exported until you add it here. Devices this plugin created itself, and device types Matter cannot represent faithfully, are listed with the reason they cannot be exported.</Label>
</Field>

<Field id="exportPickerHeading" type="label" fontColor="darkgray">
<Label>1 · Pick a device</Label>
</Field>
<!--
Textfields carry only <Label> (SDK: ConfigUI → Text Field);
<Description> is documented for checkboxes and menus, NOT for
textfields, so the guidance that used to live inside these two
fields sits in its own label field instead. The <Description>
elements on the menus below are documented and stay put.
-->
<Field id="exportFilter" type="textfield">
<Label>Filter by name:</Label>
</Field>
<Field id="exportFilterHelp" type="label" fontColor="darkgray">
<Label>Type part of a device name, then click Apply filter. Leave empty to list everything.</Label>
</Field>
<Field id="exportReload" type="button">
<Label></Label>
<Title>Apply filter / refresh</Title>
<CallbackMethod>exportReloadPicker</CallbackMethod>
</Field>
<Field id="exportDevice" type="menu">
<Label>Device:</Label>
<List class="self" method="getExportCandidates" dynamicReload="true"/>
<CallbackMethod>exportDeviceChanged</CallbackMethod>
<Description>A ● marks a device that is already exported.</Description>
</Field>

<Field id="exportDetailHeading" type="label" fontColor="darkgray">
<Label>2 · How it should appear</Label>
</Field>
<Field id="exportRole" type="menu">
<Label>Export as:</Label>
<List class="self" method="getExportRoles" dynamicReload="true"/>
<Description>Indigo cannot tell a plug from a lamp or a lock, so you declare it. The first option is the safest default.</Description>
</Field>
<Field id="exportName" type="textfield">
<Label>Name in ecosystems:</Label>
</Field>
<Field id="exportNameHelp" type="label" fontColor="darkgray">
<Label>Optional. Leave empty to use the Indigo device name.</Label>
</Field>
<Field id="exportInvert" type="checkbox" defaultValue="false"
visibleBindingId="exportRole" visibleBindingValue="windowCovering"
alwaysUseInDialogHeightCalc="true">
<Label>Invert position (0% = open):</Label>
<Description>Matter's convention is 100% = fully open. Tick this if your Indigo device is the other way round.</Description>
</Field>
<Field id="exportAdd" type="button">
<Label></Label>
<Title>Add / update export</Title>
<CallbackMethod>exportAddOrUpdate</CallbackMethod>
</Field>
<Field id="exportDelete" type="button">
<Label></Label>
<Title>Remove export</Title>
<CallbackMethod>exportRemove</CallbackMethod>
</Field>
<Field id="exportStatus" type="textfield" readonly="true">
<Label>Status:</Label>
</Field>

<Field id="exportListHeading" type="label" fontColor="darkgray">
<Label>Currently exported</Label>
</Field>
<Field id="exportList" type="list" rows="6" readonly="true">
<Label></Label>
<List class="self" method="getCurrentExports" dynamicReload="true"/>
</Field>
</ConfigUI>
</MenuItem>
<MenuItem id="exportFabricBackup">
<Name>Export fabric backup…</Name>
<CallbackMethod>menuExportFabricBackup</CallbackMethod>
Expand Down
Loading
Loading