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
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ loop→Indigo writes go straight through `device_sync.apply_states` (thread-safe

| Module | Role |
|---|---|
| `plugin.py` | Lifecycle glue, action bridge, IWS HTTP handlers |
| `plugin.py` | Lifecycle glue, action bridge, IWS HTTP handlers. Since E6 also the pairing surface: **Pair Matter Bridge…** (§3.8, codes to the event log because Indigo dialogs have no dynamic labels), **Unpair an Ecosystem…** (§3.9, two confirm gates + a dynamic fabric picker), the `pairing` IWS page, and `getPrefsConfigUiValues`, which seeds PRD §5.5's read-only export readout from state the plugin already holds (no I/O on a dialog open) |
| `async_runtime.py` | The event loop + thread + bridge primitives |
| `protocol.py` | **Rename firewall** — the only place that knows matter-server wire field names |
| `ws_json_client.py` | Shared transport core for both WS clients: run loop, `min(2**attempt, 30)` reconnect backoff, `message_id`→future correlation, disconnect/diagnostic handling. Handshake + frame vocabulary are subclass hooks; unmatched **error** responses are logged (BRIDGE_PROTOCOL §3.4) |
Expand All @@ -48,8 +48,9 @@ loop→Indigo writes go straight through `device_sync.apply_states` (thread-safe
| `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 |
| `export_handlers.py` | The **outbound** handler table, keyed by §4.2 **role** (the inbound registry is keyed by cluster; outbound there is no cluster, only a user-declared role) — `states_for` / `diff` / `dispatch` per role, each taking the export's §4.1 `options`. **Total over the v1 role enum since E4**: plug, on/off light, dimmable, colour-temp, extended colour, covering, lock, the seven sensors, thermostat. Hue diffs carry a ±1° tolerance (Matter's 0–254 hue round-trips ±1°); saturation deliberately has none. `windowCovering` applies the per-export `invert` polarity here so a `position` on the wire always means 100 = open; `doorLock` dispatches `indigo.device.lock`/`unlock` and confirms **nothing** (PRD §7). Indigo declares no units, so sensor/thermostat readings are passed through as already being in the §4.2 unit — documented in the module header as the known gap the device catalog should close. **One exception, `pressureSensor`:** Indigo's barometer convention is hPa (this plugin's own inbound handler writes it, and `export_catalog` routes `hpa`/`mbar` names here) and §4.2's key is `pressureKPa`, so it divides by 10 |
| `export_bridge.py` | The outbound engine: owns the `BridgeClient` and everything the Indigo callbacks *mean* for it. The client exists **only** while the allow-list is non-empty (XG5) and starts/stops on the dialog's empty↔non-empty transitions. The attach endpoint provider **re-runs `export_catalog.classify` on every attach** — the store is a past user declaration, not a guard — and skips-with-warning anything deleted/excluded/re-typed or carrying a role this build has no handler for — which since E4 means only an allow-list written by a *newer* plugin (an unknown role fails the *whole* attach). State pushes are fire-and-forget onto the loop; `on_command` dispatches `indigo.*` from the loop thread, the same discipline `device_sync.apply_states` already uses |
| `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) |
| `export_bridge.py` | The outbound engine: owns the `BridgeClient` and everything the Indigo callbacks *mean* for it. The client exists **only** while the allow-list is non-empty (XG5) and starts/stops on the dialog's empty↔non-empty transitions — and since E7 so does the **bridge LaunchAgent**, through injected `agent_start`/`agent_stop`/`agent_diagnose` seams: started before the client on empty→non-empty, stopped **after** the un-export has landed on non-empty→empty, and never stopped by a session that did not start it. The §5 pairing events (`fabrics_changed`/`commissioned`/`decommissioned`/`window_closed`) are consumed here since E6 — they were emitted by the node from E5 and read by nobody. PRD §5.5's `exportEnabled` switch fails **open** (absent or null means on, the opposite of the controller's attestation flag) and turning it off deliberately does **not** un-export: it drops the socket and stops the agent, leaving accessories paired-but-unavailable. The attach endpoint provider **re-runs `export_catalog.classify` on every attach** — the store is a past user declaration, not a guard — and skips-with-warning anything deleted/excluded/re-typed or carrying a role this build has no handler for — which since E4 means only an allow-list written by a *newer* plugin (an unknown role fails the *whole* attach). State pushes are fire-and-forget onto the loop; `on_command` dispatches `indigo.*` from the loop thread, the same discipline `device_sync.apply_states` already uses |

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

Describe the lifecycle trigger as the allow-list transition.

ExportBridge can receive the transition from Indigo callbacks, not only from the configuration dialog. The plugin context describes _start_bridge_agent as running on whichever Indigo thread changed the allow-list. Replace “the dialog’s empty↔non-empty transitions” with “the allow-list’s empty↔non-empty transitions” so the documentation covers non-dialog changes.

🤖 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 `@CLAUDE.md` at line 51, Update the ExportBridge lifecycle description to say
the client and bridge LaunchAgent start/stop on the allow-list’s empty↔non-empty
transitions, rather than only on the dialog’s transitions. Preserve the existing
ordering and session ownership details.

| `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). Since E7 `remove_package` is **per package** (`npm uninstall <name>`, falling back to deleting only `node_modules/<package>`) — it used to rmtree the shared `node_modules` and delete `package-lock.json`, which with two agents took the sibling's package out from under a still-loaded job. The `.indigo-node` install stamp stays deliberately **shared** (one node runs both agents) |
| `bridge_agent.py` | The **export bridge node**'s LaunchAgent (E7) — the second `AgentSpec`: label `com.simons-plugins.indigo-matter.bridge`, package `indigo-matter-bridge` (registry spec, exact-pinned in `DEFAULT_INSTALL_SPEC`), entry `dist/main.js`, its own logs, per-label applied-digest marker, and an argv builder for `--storage-path`/`--ws-port`/`--matter-port`/`--mdns-interface`. Its storage dir is **derived, not configured** — `bridge_storage_path()` is the one derivation the agent, the fabric backup and `config.ts`'s `DEFAULT_STORAGE_PATH` all have to agree on. Never constructed at `startup`: the agent is gated by the allow-list (XG5/XAC1), so a fresh install writes no plist and runs no process |
| `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) |
| `device_sync.py` | Node↔Indigo reconciliation + state/command seams |
Expand All @@ -69,7 +70,7 @@ matter.js is imported (ADR-0006). Its wire contract is `docs/BRIDGE_PROTOCOL.md`
| `node.ts` | The `ServerNode` + aggregator, the PRD §7 refuse-to-start decision, §3.9–§3.11, and the §5 event sinks |
| `endpoint-map.ts` | `endpoint-map.json` — the persisted `UniqueID → endpoint number` map and its drift detector (PRD §4.3). **It does not allocate anything: matter.js owns the numbers**, keyed on `Endpoint.id` in its own store; this file is the independent *witness*, so a lost/reset matter.js storage becomes a log line instead of silently duplicating every accessory. Lives OUTSIDE matter.js's storage context on purpose (§3.10's reset wipes that). Report-only — drift is never repaired, or the next pass would call the same fault clean. A **commissioned bridge with no map at all bootstraps** a baseline from matter.js's own persisted numbers and serves (every pre-E5 install is in that state); only a *present-but-unreadable* map refuses. `refuseReasonFor` is a pure function with no matter.js import, because the case that matters most cannot be reached in a test without real hardware |
| `storage.ts` | `identity.json`: install id, passcode, discriminator, and the `commissionedAt` witness for §7's "storage missing but previously commissioned". Atomic temp-plus-`rename` writes; witness writes report whether they landed |
| `registry.ts` / `endpoints.ts` | The live endpoint set and one Matter device-type factory per §4.2 role |
| `registry.ts` / `endpoints.ts` | The live endpoint set and one Matter device-type factory per §4.2 role. Every bridged child publishes the **full** Bridged Device Basic Information identity (`BridgedIdentity`: vendor name/id, product name, hardware + software versions, plus the per-accessory label/serial/uniqueId/reachable) — all optional in the cluster at 0.17.8, all populated, and the same values the root node's `BasicInformation` carries so an ecosystem is never shown two answers |
| `ws-server.ts` | The loopback protocol server (§1–§3). Holds an un-attached socket OPEN while the node is refusing — it is the client's only route to the §3.11 rebuild |
| `protocol.ts` | The wire contract as types + the §1.1 error/refusal domains. No matter.js import, so the protocol is testable without a Matter stack |
| `reconcile.ts` / `window.ts` / `config.ts` | §3.1 planning + arg validation, the enhanced commissioning window, CLI flags |
Expand Down
100 changes: 100 additions & 0 deletions bridge-node/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# indigo-matter-bridge

The Matter **bridge node** for the
[indigo-matter](https://github.com/simons-plugins/indigo-matter) Indigo plugin.

It exports selected [Indigo](https://www.indigodomo.com) devices as Matter
accessories, so Apple Home, Alexa, Google Home and SmartThings see them as
ordinary Matter devices on a bridge.

> **This package is not useful on its own.** It has no configuration of its own,
> no discovery, and no idea what an Indigo device is. It is one half of a pair:
> the plugin decides *what* to export and the node makes it a Matter accessory.
> Install the Indigo plugin; it installs and manages this package for you, via
> its own **Plugins ▸ Matter ▸ Install/update the Matter export bridge** menu
> item and a launchd LaunchAgent it writes.

## Why it is a separate process

matter.js is imported **here and nowhere else**. Indigo plugins are Python, and
the alternative — a Node shim inside the plugin, or a second Matter stack — buys
two versions of the specification and a second place for accessory identity to
drift. The plugin talks to this node over a loopback WebSocket with a versioned
JSON protocol, and refuses to attach on a version mismatch rather than guessing.

The contract is
[`docs/BRIDGE_PROTOCOL.md`](https://github.com/simons-plugins/indigo-matter/blob/main/docs/BRIDGE_PROTOCOL.md)
in the plugin repository. It is a real contract: both sides' test suites assert
against one shared file of golden frames, so a frame that changes on one side
fails the other side's tests.

## Running it by hand

Normally the plugin's LaunchAgent does this. For development:

```sh
node dist/main.js \
--storage-path ~/Library/Application\ Support/com.simons-plugins.indigo-matter/bridge-node \
--ws-port 5581 \
--matter-port 5540 \
[--mdns-interface en0]
```

Every flag is validated and the node **refuses to start** on a value it cannot
parse, rather than silently running somewhere unexpected — including a flag given
with no value.

| Flag | Meaning |
|---|---|
| `--storage-path` | Where identity, fabrics and the endpoint-number map live. **Sacred**: losing it un-pairs every ecosystem and duplicates every accessory. |
| `--ws-port` | The loopback protocol port the plugin dials (default 5581). |
| `--matter-port` | The Matter UDP port to advertise on (default 5540). |
| `--mdns-interface` | Pin mDNS to one interface. Omit to let matter.js choose; on a Mac with VPN/utun interfaces, pinning is usually necessary. |

The node writes its own log to stdout/stderr. Under the plugin's LaunchAgent that
is `~/Library/Logs/indigo-matter/bridge-node.log` and `bridge-node.err.log`.

## Storage

Two files in `--storage-path`, alongside matter.js's own store:

* **`identity.json`** — install id, commissioning passcode and discriminator,
plus a witness recording that the bridge has been commissioned at least once.
An unreadable one is moved aside and a replacement minted **in memory only**:
the `SerialNumber`/`UniqueID` every paired ecosystem knows are never
overwritten.
* **`endpoint-map.json`** — the persisted `UniqueID → endpoint number` map. It
allocates nothing (matter.js owns the numbers); it is an independent *witness*,
so a lost or reset matter.js store becomes a log line instead of silently
duplicating every accessory in every ecosystem. Drift is reported and never
repaired automatically — repairing it would call the same fault clean on the
next pass.

Back both up. The plugin's **Export fabric backup…** menu item covers them.

## Uncertified by design

The bridge advertises with the specification's **test vendor id** (`0xFFF1`), so
every ecosystem shows an "uncertified accessory" warning when you add it. That is
normal and expected — Homebridge and Home Assistant produce the same warning —
and choosing "Add Anyway" is the intended path. Matter certification is a paid,
per-product process that a self-hosted bridge cannot meaningfully complete.

## Development

```sh
npm install
npm run build # tsc
npm test # builds, copies the shared golden frames, runs node --test
```

The test suite includes an integration file that stands up a **real**
`ServerNode` on a real Matter stack behind a real WebSocket server, because the
faults worth catching here (a role factory that builds but writes the wrong
attribute; a bridged child publishing the wrong manufacturer) cannot fail against
a stub.

## Licence

MIT. Not affiliated with Perceptive Automation, the Connectivity Standards
Alliance, Apple, Amazon, Google or Samsung.
20 changes: 18 additions & 2 deletions bridge-node/package.json
Original file line number Diff line number Diff line change
@@ -1,19 +1,35 @@
{
"name": "indigo-matter-bridge",
"version": "0.4.0",
"private": true,
"version": "0.5.0",
"description": "Matter bridge node for the indigo-matter Indigo plugin — exports selected Indigo devices as Matter accessories.",
"license": "MIT",
"type": "module",
"main": "dist/main.js",
"bin": {
"indigo-matter-bridge": "dist/main.js"
},
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "git+https://github.com/simons-plugins/indigo-matter.git",
"directory": "bridge-node"
},
"homepage": "https://github.com/simons-plugins/indigo-matter",
"keywords": [
"matter",
"matter.js",
"indigo",
"bridge",
"home-automation"
],
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run clean && npm run build",
"start": "node dist/main.js",
"test": "tsc && tsc -p tsconfig.test.json && rm -rf .test-build/test/fixtures && mkdir -p .test-build/test/fixtures && cp -R ../tests/fixtures/bridge_protocol .test-build/test/fixtures/bridge_protocol && node --test --test-force-exit \".test-build/test/*.test.js\"",
"clean": "rm -rf dist .test-build"
Expand Down
Loading
Loading