Status: Accepted — build in progress
Owner: Simon
Governing ADR: ../../docs/adr/0006-indigo-as-matter-bridge.md (accepted 2026-08-03; workspace-level — the path resolves in the multi-repo workspace checkout, not on GitHub), as amended by ADR-0007 (validation-evidence criterion)
Companion PRD: PRD-indigo-matter-plugin.md (historical — the inbound/controller build)
Local protocol spec: BRIDGE_PROTOCOL.md
Last updated: 2026-08-05 (E8 docs pass: §5.2’s sensor rows, §5.3’s distribution
wording and §9’s E7/E8 rows corrected against the shipped code — see the marked
rows. Earlier: 2026-08-04 research pass, matter.js 0.17.8 verified by execution)
A second, outbound role for indigo-matter: export a user-selected set of
Indigo devices as a Matter bridge, so Apple Home, Alexa, Google Home and
SmartThings can see and control them locally, with no cloud relay and no
per-ecosystem bridge plugin.
Per ADR-0006 this is a plugin-owned matter.js bridge node (a second Node
process, authored by us) plus an explicit allow-list (nothing is exported
until the user says so). The inbound controller (matter-server) is untouched;
the two processes share a Node runtime and nothing else.
The substantive design work is §5.2 (the Indigo → Matter mapping and its
non-exportable set) and §5.1 (how a user picks devices). Everything else is
lifecycle plumbing that closely mirrors what server_process.py already does
for the controller.
indigo-matter imported over Matter.┌────────────────────────── Indigo Server (macOS) ────────────────────────────┐
│ │
│ ┌──────────────────────┐ │
│ WebSocket │ indigo-matter │ WebSocket │
│ ┌──────────────────────┤ plugin (Python) ├──────────────────────┐ │
│ │ (inbound, existing) │ │ (outbound, NEW) │ │
│ ▼ │ - allow-list │ ▼ │
│ ┌──────────────────┐ │ - export mapper │ ┌──────────────────────┐ │
│ │ matter-server │ │ - cluster mapper │ │ bridge node │ │
│ │ (controller) │ └──────────┬───────────┘ │ (matter.js, device) │ │
│ │ - Indigo fabric │ │ │ - Aggregator ep │ │
│ └────────┬─────────┘ ▼ │ - one child ep per │ │
│ │ Indigo Database │ exported device │ │
└──────────┼───────────────────────────────────────────────────┬──────────────┘
▼ ▼
Matter devices (in) Apple Home / Alexa / Google /
SmartThings (out)
Two processes, opposite roles, no shared state. The bridge node holds no fabric of its own — it is commissioned into other ecosystems’ fabrics.
Reuse the settled pattern. ADR/PRD history chose PM-B (launchd LaunchAgent)
for the controller and it shipped; server_process.py already carries install,
version-pinning, launchd plist management, restart and health-check machinery.
Decision: the bridge node is a second LaunchAgent, managed by
generalising server_process.py rather than duplicating it. Two differences
from the controller agent:
~/Library/Application Support/com.simons-plugins.indigo-matter/bridge-node/.
Contains the node’s operational credentials for each ecosystem fabric it has
joined, plus the endpoint-ID allocation map.UniqueID values MUST be allocated once, persisted,
and derived from the Indigo device ID — never from list position or iteration
order. matter.js keys its persisted endpoint numbers solely on the string
Endpoint.id (verified at 0.17.8): a stable id gives stable numbers across
restarts, reorderings and removals, while an omitted id falls back to
positional part0/part1/… and silently swaps identities when a device is
removed. Therefore: id = the immutable Indigo device ID (sanitised),
supplied explicitly, never reused, never mutated.UniqueID → endpoint number
map and warns loudly if any mapping changes (matterbridge’s
checkEndpointNumbers() pattern) — the only way to make “is it us or the
controller?” falsifiable in the field. The check runs wherever a number can
first be wrong, not at startup: at startup there are no endpoints yet (the
live set is created by the first attach), so a startup-only check would
compare an empty set against the baseline and report a clean bridge every
time. It therefore runs at the end of every operation that reshapes the live
set — the first attach’s reconcile, and every upsert_endpoint /
remove_endpoint after it. Drift is reported, never repaired: an
auto-repair would bless the storage loss that caused it and make the next
occurrence invisible too. One deliberate exception (issue #140): a
renumbering caused by a factory reset is voided and silently adopted rather
than reported, because the reset itself already erased matter.js’s own
allocation with no paired ecosystem left to disagree — everything else still
drifts exactly as before.Bidirectional, because both directions carry traffic: the plugin pushes Indigo state changes outward, and the bridge node pushes ecosystem commands inward.
Decision: mirror the controller’s WebSocket + JSON message shape. It costs
nothing new to learn, matter_client.py is a working reference for the client
half, and the existing test doubles generalise. The bridge node listens on
loopback only, on a configurable port defaulting to 5581 (the controller’s
WS is 5580).
Unlike protocol.py there is no rename firewall — we own both ends and ship
them together — but the handshake carries a protocolVersion, because launchd
deliberately keeps the old node running across plugin reloads and version skew
is the failure that will actually happen. Full spec: BRIDGE_PROTOCOL.md.
The Matter side of the node binds UDP 5540 (the Matter default, also
pref-configurable) with the Aggregator at Endpoint 1. matter.js’s
ECOSYSTEMS.md documents both as Alexa’s hard requirement (it discovers nothing
on any other port and needs EP1 beside the root); we have not verified this
(XOQ1), but the constraint costs nothing now and is painful to retrofit. No
conflict with the controller: matter-server listens on TCP 5580 plus
ephemeral UDP, not 5540. But 5540 is contended by any other Matter device
stack on the same Mac — Homebridge 2.x, matterbridge, an HA container in host
mode — so a bind failure is a first-class §7 failure mode, surfaced with the
holder named, and the pref is the escape hatch (moving off 5540 forfeits the
documented Alexa behaviour). mDNS is pinned to the primary interface (reusing
the primaryInterface pref) — matter.js’s own mDNS stack defaults to all
interfaces and breaks on Macs with VPN/utun interfaces.
Policy is fixed by ADR-0006 E2: explicit allow-list, default empty. What remains is the mechanism. Export needs per-device metadata beyond a boolean — role (§5.2), display name override, and polarity for covering/lock-like devices — which rules out a plain multi-select.
| Option | Shape | Assessment |
|---|---|---|
| UI-A | Multi-select list in PluginConfig | Rejected — carries no per-export metadata; unusable at 200 devices |
| UI-B | One “Exported Device” plugin device per export | Fallback — free per-device config dialogs, but doubles Indigo device count and puts a shadow device beside every real one |
| UI-D | Plugins ▸ Matter ▸ Manage Matter Exports… dialog |
Chosen — filterable device picker plus a per-export detail pane; no device clutter; allow-list lives in plugin prefs |
Decision: UI-D, falling back to UI-B if Indigo’s XML dialog list controls prove unworkable at scale — the same “recommended start, documented fallback” treatment PM-B/PM-A got in the original PRD. (UI-C, a hybrid variant, was folded into UI-D during drafting; the lettering gap is deliberate.)
The candidate list MUST be filtered by plugin ID to exclude indigo-matter’s
own devices, making the loop guard (XNG3) structural rather than a runtime check.
This is the inverse of the inbound §5.4 table, and it is not symmetric.
Inbound, Matter tells us what a device is — the device type and cluster set are
explicit. Outbound, Indigo does not: a relay device may be a lamp, a plug, a
lock, a valve, a fan or a garage door, and nothing in the Indigo device model
distinguishes them.
Consequence: export requires a per-device role that Indigo cannot supply. In v1 the role is user-declared in the §5.1 detail pane, defaulting to the safest interpretation (plug/light) rather than guessing.
Cross-repo note. This is the same classification problem
domio-codeandindigo-device-catalogalready solve for Domio’s device rendering. The catalog is the natural long-term home for role (and polarity), and role/polarity fields are already a known catalog gap. v2 candidate: default the export role from the catalog, keeping the user override authoritative. Do not build a third independent classifier.
| Indigo device | Declared role | Matter device type | Notes |
|---|---|---|---|
| Relay | Plug (default) | On/Off Plug-in Unit | Safest default |
| Relay | Light | On/Off Light | |
| Relay | Lock | Door Lock | Requires ecosystem PIN/confirm semantics; see §7. matter.js’s DoorLock implementation is its most complete (users/credentials/schedules, encrypted at rest) — the risk is ecosystem UX, not the library |
| Relay | Valve | Not exportable in v1 | Descoped 2026-08-04; see below |
| Relay | Garage door | Not exportable in v1 | Polarity + safety; see below |
| Dimmer | Light | Dimmable Light | |
| Dimmer (colour) | Light | Extended Color Light | Colour-temp-only devices → Color Temperature Light. A colour-capable dimmer is also offered the colour-temp-only role, so a user can downgrade a bulb an ecosystem renders badly |
| Dimmer | Window covering | Window Covering | Polarity declared per export (100% = open, inbound convention). Must implement handleMovement() — matter.js’s default snaps to target instantly |
| Dimmer | Fan | Not exportable in v1 | Descoped 2026-08-04; see below |
| Sensor (binary) | Occupancy (default) | Occupancy Sensor | Indigo does not distinguish motion from contact any more than it distinguishes a plug from a lamp, so this is declared too. Corrected 2026-08-05 (E8): the row used to read “—” in both directions, implying a mapping the code does not make |
| Sensor (binary) | Contact | Contact Sensor | |
| Sensor (numeric) | Temperature / Humidity / Light / Pressure / Flow | the matching Matter sensor type | All five are offered for any numeric sensor; the unit heuristic (pluginProps, then the formatted UI value, then the device name) picks only the default, and a sensor no hint matches is the “units outside the table” exclusion below. Corrected 2026-08-05 (E8): the rows used to read as one unit → one type |
| Sensor (numeric, pressure) | Pressure | Pressure Sensor | Apple Home ignores this type (Google supports it); exported anyway, documented. Indigo’s barometer convention is hPa and §4.2’s key is kPa, so this is the one unit the handler converts |
| Sensor (numeric, flow) | Flow | Flow Sensor | Apple Home ignores this type (Google supports it); exported anyway, documented |
| Thermostat | — | Thermostat | Setpoints, modes. No fan in v1 (the FanControl descope applies here too); v2 candidate. matter.js provides the cluster machinery; the HVAC logic is ours |
| SpeedControl | Fan | Not exportable in v1 | Descoped 2026-08-04; see below |
Matter device-type IDs are deliberately omitted here; take them from the matter.js device-type catalogue at implementation rather than transcribing them into a PRD where they can rot.
Explicitly not exportable in v1:
| Excluded | Why |
|---|---|
Any device created by indigo-matter |
Loop guard (XNG3), enforced at §5.1 |
| Valve role | Descoped 2026-08-04. matter.js’s ValveConfigurationAndControlServer is an empty stub — the whole command surface would be ours to implement — and ecosystem support is poor (Apple unresolved, Alexa ignores the type). v2 candidate |
| Fan role (Dimmer- or SpeedControl-backed) | Descoped 2026-08-04. matter.js’s FanControlServer only seeds a default fanMode; all fan behaviour would be ours to implement. v2 candidate |
| Sprinkler devices | Matter has no irrigation-controller type; per-zone Water Valve is a lossy fit (and Water Valve itself is descoped). v2 candidate |
| MultiIO devices | No coherent single-accessory representation |
custom devices with no resolvable role |
Includes the plugin’s own energy-meter type |
| 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 |
| Sensors reporting neither an on/off state nor a value | Nothing to publish (export_catalog.REASON_SENSOR_NO_VALUE; added to this table 2026-08-05, E8 — it was always in the code) |
Excluded devices must appear in the picker as excluded, with reasons (XAC9), not silently missing.
One Matter node: root endpoint, an Aggregator endpoint, and one Bridged
Node child endpoint per exported device carrying Bridged Device Basic
Information (NodeLabel, Reachable, UniqueID) — the standard bridge
topology the plugin already consumes inbound (the “Bridges” section of
MATTER.md; in code, matter_model.py’s BridgedDeviceBasicInformation
handling and device_sync.py’s DEVICE_TYPE_AGGREGATOR).
indigo-matter-bridge, TypeScript, decided 2026-08-04), exact-pinned by the
plugin the same way matter-server@1.2.2 is — the existing
npm install --prefix machinery works unchanged. It is on the registry
(0.5.0 onward; §9 E7), so the install action resolves the pin. matter.js itself is
exact-pinned (no caret): patch releases have changed what Apple Home
renders with zero code change on the bridge side.supportedFabrics to 254, so ≥5
concurrent ecosystems needs no action. Note Apple consumes two slots
(iCloud Keychain sync).Reachable = false over endpoint removal for anything temporary.PartsList (automatic in matter.js); ecosystems
remove the accessory. Bulk removals are rate-limited (~100ms apart,
matterbridge’s pattern) so controllers see one subscription update each.ctx.offline) or the Indigo↔ecosystem
loop is infinite.startup — if the allow-list is non-empty, ensure the bridge agent is
installed and running; connect; reconcile endpoints against the allow-list.shutdown — close the socket; leave the agent running (PM-B rationale:
plugin reloads must not un-pair ecosystems).deviceUpdated — diff relevant states, push outward.deviceDeleted — remove from allow-list, drop the endpoint.runConcurrentThread — health check, reconnect, reconcile drift, and
periodic bridge-node RSS logging (the XOQ6 watchdog).Plugin config gains an Export section: enable/disable export wholesale, the two bridge-node ports (local-protocol WS, default 5581; Matter UDP, default 5540), and a pairing-status readout (which ecosystems are paired, fabric slots used/remaining). The readout earns its place even though matter.js defaults to 254 fabric slots: what users actually need to see is which ecosystems hold a fabric and whether a commissioning window is open, not slot arithmetic. Per-export settings live in the §5.1 dialog.
open_commissioning_window,
BRIDGE_PROTOCOL §3.8), not “show the code”. Display mechanism (Indigo
dialogs have no dynamic labels and no image fields): the manual code is
written to the event log — the plugin’s established pattern for runtime
strings — and the QR is rendered on an IWS-served page, reachable from the
same menu action. Passcode and discriminator are randomised per install
(identical passcodes produce identical pairing codes across installs —
verified) and persisted by the bridge node.INSTALL.md, with the
Homebridge parallel, so it doesn’t read as a fault.| Mode | Behaviour |
|---|---|
| Bridge node down | Plugin marks export status degraded; Indigo devices unaffected; agent restarted by launchd; reconcile on reconnect |
| Ecosystem sends a command for a deleted Indigo device | Endpoint already removed; if racing, return failure rather than silently dropping |
| Indigo device disabled | Reachable = false; accessory greys out |
| Allow-list emptied | Endpoints removed (the deliberate intent: "replace_all" path, BRIDGE_PROTOCOL §3.1); agent stopped; pairings retained unless explicitly reset |
| Lock command | Never auto-confirm destructive state changes; honour the inbound lock conventions |
| Matter UDP port (5540) already bound | Another Matter device stack (Homebridge 2.x, matterbridge, HA) holds it; surface the error naming the holder; the port pref is the escape hatch (§4.4) |
| Endpoint map corrupt (present, unreadable) on a commissioned bridge | Refuse to serve endpoints; surface an error and require an explicit rebuild (rebuild_endpoint_map, BRIDGE_PROTOCOL §3.11), since silent reallocation duplicates accessories in every paired ecosystem. The unusable file is copied aside first — it is the only surviving record of the old numbers |
| Endpoint map absent on a commissioned bridge | Bootstrap a baseline and serve — do not refuse. matter.js owns the numbers (keyed on Endpoint.id in its own store) and this map is only the independent witness, so a missing witness renumbers nothing; it means we cannot yet check. Every install commissioned before the map existed is in exactly this state, and refusing there would take working exports offline on upgrade to fix a file that never existed |
| Endpoint map missing/corrupt on a bridge that has never been commissioned | Serve normally. Endpoint numbers only matter to somebody who is paired |
Bridge identity.json present but unreadable |
Refuse to serve endpoints, and a rebuild cannot fix it — the node refuses one. Minting a replacement would change the SerialNumber/UniqueID every paired ecosystem remembers, silently un-pairing the lot. The file is moved aside as identity.json.unreadable-<stamp>; the refusal is sticky across restarts until it is restored, repaired, or deliberately deleted |
UniqueIDs survive plugin reload, bridge-node
restart and Mac reboot with no accessory duplication (XG4).indigo-matter-created device is absent from the picker, verified
by unit test as well as live (XNG3).| # | Milestone | Gating criterion |
|---|---|---|
| E0 | Bridge node skeleton — the validation gate · GATE PASSED (2026-08-04) | Node process starts, exposes an aggregator with one hard-coded endpoint, and pairs into Apple Home. If an uncertified bridge will not pair here, the design is dead and nothing after this matters. Done on jarvis 2026-08-04: an uncertified bridge was commissioned into a real Apple Home and the uncertified-accessory prompt accepted. The pairing was driven from the bridge node’s own console, before E6’s menu existed — so pairing through the plugin’s menu is still unproven |
| E1 | Local protocol + plugin client | Plugin drives endpoint create/remove over WS |
| E2 | Allow-list + UI-D dialog | Devices selectable with role; loop guard live (XAC6, XAC9) |
| E3 | Relay + dimmer export — LIVE E2E PASSED (2026-08-05) | XAC4 both directions, and XAC3’s Apple Home control, against a manually started bridge node (start-on-export is E7’s; the code display is E6’s). Done on jarvis 2026-08-05: an on/off light and a dimmer, exported via “Manage Matter Exports…”, controlled from Apple Home and from Indigo with each change appearing on the other side |
| E4 | Sensors + thermostat export | Mapping table complete for v1 |
| E5 | Endpoint persistence — DEPLOYED to jarvis 2026-08-05; upgrade leg PASSED | XAC5 — the highest-risk correctness requirement. Upgrade migration observed on jarvis 2026-08-05: three exported accessories kept endpoint numbers 3, 5 and 4 across a plugin and bridge-node upgrade — their pre-upgrade values, deliberately not in creation order — with no duplicates in Apple Home. XAC5’s reboot leg is still outstanding: a plugin reload and a bridge-node restart have been survived, a full Mac reboot has not |
| E6 | Pairing/unpairing UX + fabric readout — BUILT (2026-08-05, plugin 2026.8.3) |
§6 complete, including XAC3’s displayed-code pairing flow. “Pair Matter Bridge…” (§3.8, duration 180–900s), the QR page over IWS, “Unpair an Ecosystem…” (§3.9, two gates), the §5.5 Export config section, and the §5 pairing events surfaced in the log. XAC3 is part-verified: the pairing gate itself passed on jarvis (E0 row), but that pairing was driven from the node’s console — pairing through this menu is unverified, as is the second-admin half |
| E7 | launchd agent + failure recovery — BUILT (2026-08-05, plugin 2026.8.3, bridge 0.5.0) |
§7, XAC1, XAC2, XAC7, XAC8. bridge_agent.BridgeProcess is the second AgentSpec; it is installed and started by the empty→non-empty allow-list transition and stopped after the un-export lands. remove_package is per-package. Three lifecycle menu items land with it — “Install/update the Matter export bridge”, “Reinstall the Matter export bridge (clean)…” and “Stop the Matter export bridge…” — the last two added in the PR #128 review batch to close the gap where the controller had recovery exits and the bridge had none. indigo-matter-bridge is published on the npm registry (0.5.0+), so the install action resolves the pinned spec; release steps live in bridge-node/README.md § Releasing |
| E8 | Docs — BUILT (2026-08-05) | INSTALL.md retitled for both runtimes, with a contents list and an export section (prerequisites, first export, pairing, the uncertified prompt, removal/unpairing, the two destructive recovery actions ordered by recoverability, bridge-storage backups, uninstall) plus an export-bridge troubleshooting table split into observable symptoms and verbatim log strings; MATTER.md outbound architecture, with the v1 role table and the excluded set hoisted to the front; README.md two-role lede. The ecosystems-untested note (§10 / ADR-0007) is stated in all three. The docs state the live-validation position as it actually stands — E0’s pairing gate and E3’s two-way control passed, E5 deployed and its upgrade leg observed, with pairing-through-the-menu, the second admin, the reboot leg and E6/E7 end-to-end still marked outstanding — plus the IWS-auth caveat on the pairing page and the unit assumption (the npm package has since been published — see E7) |
E0 is the whole validation loop. It answers the only question that can kill the feature — will any ecosystem pair an uncertified bridge? — on hardware already present for the TBR. E0 runs on jarvis (decided 2026-08-04): the real deployment host, same L2 as the Apple hub. Everything after E1 is well-understood, though not all of it is easy — E5 remains the highest-risk correctness milestone (§4.3).
Sequencing note (XOQ3 outcome): the AgentSpec extraction of
server_process.py lands as its own behaviour-preserving PR between E0 and
E1, so E7 is wiring, not refactoring.
There is deliberately no per-ecosystem spike. A test earns its place by changing a decision, and a Google Home or Alexa result changes none: we ship to whatever pairs, and the remedies for a refusal — a real vendor ID, or per-ecosystem developer-console registration — are weighed and declined in ADR-0007. Those ecosystems are therefore untested-and-unclaimed (§10), not blockers.
AgentSpec; the extraction is behaviour-preserving and is
pulled forward to before E1 so the hard-won recovery machinery (plist
digest, loaded-but-dead recovery, orphan reaper) is never duplicated.vendorId/productId is configured, with a Certification Declaration
signed by the CHIP development CD key (certificationType: Test). The
0xFFF1/0x8000 values come from its examples, not the library. v1 uses the
test-range VID 0xFFF1 deliberately (uncertified is the honest posture,
ADR-0006); still distinct from ADR-0005’s fabric VID.indigo-device-catalog now rather than as
plugin-local metadata later — cross-repo, and it would unblock garage doors.@matter/main + @matter/nodejs (0.17.8 at time of writing),
exact-pinned. Upstream repo is now the matter-js GitHub org (Open Home
Foundation). Do not depend on @matter/examples — stale on npm; the
live examples are in the repo’s examples/ tree.indigo-device-catalog — soft dependency for v2 role defaults (XOQ5).indigo-device-catalog.