From c38ee86ce1f9a166ea21f9d4bd47ba71bc2c1fc0 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Wed, 5 Aug 2026 23:10:10 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat(export):=20E6+E7=20=E2=80=94=20pairing?= =?UTF-8?q?=20UX,=20fabric=20management,=20bridge=20LaunchAgent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E6: Pair Matter Bridge / Unpair an Ecosystem menu actions over §3.8/§3.9, pairing code to the event log with an IWS-served page carrying the manual code and MT: payload, PluginConfig Export section with a live fabric readout, §5 fabric events surfaced in the Indigo log. E7: the bridge node becomes a managed LaunchAgent via the AgentSpec machinery extracted in PR #120 — installed from the exact-pinned registry spec indigo-matter-bridge@0.5.0 (no JavaScript ships in the plugin bundle), gated so a fresh install stays inert (XG5/XAC1), started on the first export and stopped when the allow-list empties, with the watchdog branch and failure isolation from the inbound controller. remove_package is now per-package (npm uninstall) rather than wiping the shared node_modules — the TODO(E7) left by the AgentSpec extraction. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JxqGhP3DcENf68AZK21U4S --- CLAUDE.md | 9 +- bridge-node/package.json | 20 +- bridge-node/src/endpoints.ts | 71 +- bridge-node/src/node.ts | 33 +- bridge-node/src/registry.ts | 11 +- bridge-node/test/registry.test.ts | 110 ++- docs/HANDOVER.md | 382 +++++++++- docs/PRD-indigo-matter-export.md | 4 +- .../Contents/Info.plist | 4 + .../Contents/Server Plugin/Actions.xml | 8 + .../Contents/Server Plugin/MenuItems.xml | 61 ++ .../Contents/Server Plugin/PluginConfig.xml | 44 ++ .../Contents/Server Plugin/bridge_agent.py | 228 ++++++ .../Contents/Server Plugin/export_bridge.py | 310 +++++++- .../Contents/Server Plugin/launch_agent.py | 102 ++- .../Contents/Server Plugin/plugin.py | 599 +++++++++++++++- tests/test_bridge_agent.py | 235 ++++++ tests/test_export_agent_wiring.py | 406 +++++++++++ tests/test_export_bridge.py | 5 +- tests/test_launch_agent.py | 57 +- tests/test_pairing_menu.py | 672 ++++++++++++++++++ tests/test_server_process.py | 38 +- 22 files changed, 3326 insertions(+), 83 deletions(-) create mode 100644 indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_agent.py create mode 100644 tests/test_bridge_agent.py create mode 100644 tests/test_export_agent_wiring.py create mode 100644 tests/test_pairing_menu.py diff --git a/CLAUDE.md b/CLAUDE.md index 4db9f9a..ce2c02d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) | @@ -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 | +| `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 `, falling back to deleting only `node_modules/`) — 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 | @@ -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 | diff --git a/bridge-node/package.json b/bridge-node/package.json index 1e0e939..13d01c2 100644 --- a/bridge-node/package.json +++ b/bridge-node/package.json @@ -1,7 +1,6 @@ { "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", @@ -9,11 +8,28 @@ "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" diff --git a/bridge-node/src/endpoints.ts b/bridge-node/src/endpoints.ts index e59f172..3ed3872 100644 --- a/bridge-node/src/endpoints.ts +++ b/bridge-node/src/endpoints.ts @@ -26,7 +26,7 @@ * through {@link COMMAND_SINKS}. */ -import { Endpoint, type EndpointType, Logger, type MaybePromise } from "@matter/main"; +import { Endpoint, type EndpointType, Logger, type MaybePromise, VendorId } from "@matter/main"; import { BridgedDeviceBasicInformationServer } from "@matter/main/behaviors/bridged-device-basic-information"; import { ColorControlServer } from "@matter/main/behaviors/color-control"; import { DoorLockServer } from "@matter/main/behaviors/door-lock"; @@ -1319,18 +1319,77 @@ function mergeBehaviors(...patches: Record[]): Record { +/** + * Who the bridge says it is, on every child accessory it publishes. + * + * The values are the *bridge's* — they are not per-device, and there is nothing + * per-device to put there: Indigo does not tell us who made a relay. Passed in + * rather than imported so this module keeps no dependency on {@link ../node}, + * which imports it. + */ +export interface BridgedIdentity { + vendorName: string; + vendorId: number; + productName: string; + hardwareVersion: number; + hardwareVersionString: string; + softwareVersion: number; + softwareVersionString: string; +} + +/** + * The Bridged Device Basic Information every child carries (PRD §5.3). + * + * **Every field below is optional in the cluster** (verified against + * `@matter/types` 0.17.8's `bridged-device-basic-information.d.ts`: only + * `reachable` is mandatory) — but populating the identity set is what + * matterbridge does and what ecosystems render as an accessory's manufacturer, + * model and firmware detail. Left off, those panes read blank or "Unknown", + * which is a worse answer than "simons-plugins" for something that genuinely is + * a software bridge. + * + * Note the cluster's optional set is NOT BasicInformation's: it has no + * `productId`, no `partNumber`, no `capabilityMinima`. Nothing outside the list + * above is added here for that reason. + */ +function bridgedInfoFor(spec: EndpointSpec, identity: BridgedIdentity): Record { return { nodeLabel: spec.label, - productName, + productName: identity.productName, productLabel: spec.label, serialNumber: serialNumberFor(spec.indigoDeviceId), uniqueId: uniqueIdFor(spec.indigoDeviceId), reachable: spec.reachable, + // Branded, not a bare number: matter.js validates the tag at write time, + // and the root node's BasicInformation already goes through VendorId(). + vendorName: identity.vendorName, + vendorId: VendorId(identity.vendorId), + // A bridge has no hardware, so this is a constant rather than an + // invention — but the attributes are what an ecosystem reads for the + // "Version" row, and an absent one renders as blank. + hardwareVersion: identity.hardwareVersion, + hardwareVersionString: identity.hardwareVersionString, + // The bridge node's own version, same derivation the root node uses, so + // an ecosystem showing a child's firmware and the bridge's own agree. + softwareVersion: identity.softwareVersion, + softwareVersionString: identity.softwareVersionString, // Optional on bridged devices, and matter.js only lets // `increaseConfigurationVersion` run when an initial value exists — so // it is seeded here rather than discovered as a throw on the first bump. + // + // **An INITIAL value, not the live one, and that distinction was + // measured rather than assumed** (0.17.8, `registry.test.ts` + // "restores a bridged accessory's ConfigurationVersion"). The data model + // suggests otherwise: the bridged cluster's attribute carries no `N` + // quality (`bridged-device-basic-information.element.js`), unlike the + // root's, and `BridgedDeviceBasicInformationServer` extends only + // `uniqueId` to be persistent — which reads as "matter.js will not + // remember this, so every restart resets the accessory to 1". It does + // remember it: the value is written to its own store as + // `root.parts.aggregator.parts..bridgedDeviceBasicInformation.configurationVersion` + // and restored over whatever is seeded here. So this constant applies + // only the first time an endpoint id is ever created, and nothing needs + // to persist it alongside the endpoint numbers. configurationVersion: 1, }; } @@ -1340,12 +1399,12 @@ function bridgedInfoFor(spec: EndpointSpec, productName: string): Record this.log(message), emit: data => this.#command?.(data), onConfigurationChange: () => this.bumpConfigurationVersion(), diff --git a/bridge-node/src/registry.ts b/bridge-node/src/registry.ts index 8e3223f..cb3414d 100644 --- a/bridge-node/src/registry.ts +++ b/bridge-node/src/registry.ts @@ -13,6 +13,7 @@ import { applyLabel, applyReachable, applyStates, + type BridgedIdentity, createEndpoint, isSupportedRole, UNSUPPORTED_ROLE_DETAILS, @@ -51,8 +52,12 @@ export interface EndpointRegistryOptions { /** Sink for §5 `command` events. */ emit: (data: CommandEventData) => void; log?: (message: string) => void; - /** `ProductName` on every child's Bridged Device Basic Information. */ - productName: string; + /** + * The identity fields every child's Bridged Device Basic Information carries + * — vendor, product, hardware and software versions. Bridge-wide, not + * per-device: Indigo does not know who made a relay. + */ + bridgeIdentity: BridgedIdentity; /** Injectable so the pacing test does not take a real 100ms per removal. */ removalPacingMs?: number; /** @@ -295,7 +300,7 @@ export class EndpointRegistry { } private async create(spec: EndpointSpec): Promise { - const endpoint = createEndpoint(spec, this.options.productName); + const endpoint = createEndpoint(spec, this.options.bridgeIdentity); await this.options.aggregator.add(endpoint); let unwatch: () => void; try { diff --git a/bridge-node/test/registry.test.ts b/bridge-node/test/registry.test.ts index c1f6e59..f801513 100644 --- a/bridge-node/test/registry.test.ts +++ b/bridge-node/test/registry.test.ts @@ -21,6 +21,7 @@ import { after, describe, it } from "node:test"; import { Endpoint, Environment, Logger, ServerNode, VendorId } from "@matter/main"; import { BasicInformationServer } from "@matter/main/behaviors/basic-information"; +import { BridgedDeviceBasicInformationServer } from "@matter/main/behaviors/bridged-device-basic-information"; import { DoorLockServer } from "@matter/main/behaviors/door-lock"; import { OnOffServer } from "@matter/main/behaviors/on-off"; import { WindowCoveringServer } from "@matter/main/behaviors/window-covering"; @@ -39,6 +40,22 @@ import { import { EndpointRegistry } from "../src/registry.js"; const PRODUCT_NAME = "Indigo Matter Bridge"; + +/** + * The bridge identity every child endpoint publishes, as node.ts builds it. + * Spelled out here rather than imported from node.ts so these tests keep their + * distance from the ServerNode wiring — and so a field going missing from the + * real one is a test failure rather than a silently-shared blank. + */ +const BRIDGE_IDENTITY = { + vendorName: "simons-plugins", + vendorId: 0xfff1, + productName: PRODUCT_NAME, + hardwareVersion: 1, + hardwareVersionString: "1", + softwareVersion: 500, + softwareVersionString: "0.5.0", +}; const scratchRoots: string[] = []; /** @@ -64,6 +81,7 @@ interface Harness { node: ServerNode; commands: CommandEventData[]; logs: string[]; + storagePath: string; close: () => Promise; } @@ -107,7 +125,7 @@ async function harness( const logs: string[] = []; const registry = new EndpointRegistry({ aggregator, - productName: PRODUCT_NAME, + bridgeIdentity: BRIDGE_IDENTITY, emit: data => { commands.push(data); if (options.emitThrows === true) { @@ -129,6 +147,7 @@ async function harness( node, commands, logs, + storagePath, close: async () => { registry.close(); await node.close(); @@ -738,6 +757,95 @@ describe("set_state (§3.4)", () => { } }); + it("publishes the FULL bridged-accessory identity on every child", async () => { + // Every one of these is optional in the cluster (@matter/types 0.17.8: + // only `reachable` is mandatory), so nothing enforces their presence + // except this test — and left off, an ecosystem's accessory-detail pane + // reads blank or "Unknown" for a bridge that does know the answers. + // Pinned as a SET so the list cannot silently shrink. + const h = await harness(); + try { + await h.registry.reconcile([spec(1, Role.onOffLight, { label: "Kitchen Lamp" })], false); + const endpoint = [...h.aggregator.parts][0]; + const info = endpoint?.stateOf("bridgedDeviceBasicInformation") as Record; + + // Per-accessory: from the spec. + assert.equal(info.nodeLabel, "Kitchen Lamp"); + assert.equal(info.productLabel, "Kitchen Lamp"); + assert.equal(info.uniqueId, uniqueIdFor(1)); + assert.equal(info.reachable, true); + assert.equal(typeof info.serialNumber, "string"); + + // Bridge-wide: from the identity, and identical to what the root + // node's BasicInformation carries. + assert.equal(info.vendorName, BRIDGE_IDENTITY.vendorName); + assert.equal(Number(info.vendorId), BRIDGE_IDENTITY.vendorId); + assert.equal(info.productName, BRIDGE_IDENTITY.productName); + assert.equal(info.hardwareVersion, BRIDGE_IDENTITY.hardwareVersion); + assert.equal(info.hardwareVersionString, BRIDGE_IDENTITY.hardwareVersionString); + assert.equal(info.softwareVersion, BRIDGE_IDENTITY.softwareVersion); + assert.equal(info.softwareVersionString, BRIDGE_IDENTITY.softwareVersionString); + + // Seeded so `increaseConfigurationVersion` has something to bump. + assert.equal(info.configurationVersion, 1); + } finally { + await h.close(); + } + }); + + it("matter.js RESTORES a bridged accessory's ConfigurationVersion across a restart", async () => { + // Pins a matter.js 0.17.8 behaviour that the data model actively + // suggests is false, so nobody re-derives the wrong answer from it. + // + // The bridged cluster's ConfigurationVersion carries NO data-model + // quality (`bridged-device-basic-information.element.js`), unlike the + // root's `quality: "N"`, and `BridgedDeviceBasicInformationServer` + // extends only `uniqueId` to be persistent. Read that way, the seed in + // `bridgedInfoFor` would be the live value on every start and every + // accessory would reset to 1 on every restart — a backwards jump on an + // attribute Matter defines as monotonic, and one matter.js guards + // against only on the root. + // + // It does not happen. matter.js writes the value into its own store as + // `root.parts.aggregator.parts..bridgedDeviceBasicInformation.configurationVersion` + // (measured) and restores it OVER the constructor seed, so the seed + // applies only the first time an endpoint id is created. Nothing needs + // to persist it alongside the endpoint numbers. + const storagePath = mkdtempSync(join(SCRATCH_ROOT, "indigo-matter-configver-")); + scratchRoots.push(storagePath); + const readVersion = (h: Harness): number => { + const endpoint = [...h.aggregator.parts][0]; + const info = endpoint?.stateOf("bridgedDeviceBasicInformation") as Record; + return info.configurationVersion as number; + }; + + let raised: number; + const first = await harness({ storagePath }); + try { + await first.registry.reconcile([spec(1, Role.onOffLight)], false); + assert.equal(readVersion(first), 1, "a brand-new accessory starts at the seed"); + // Drive it up through matter.js's own API, which is the only thing + // that may change it (the value is read-only over the wire). + await [...first.aggregator.parts][0]!.act(agent => + agent.get(BridgedDeviceBasicInformationServer).increaseConfigurationVersion(), + ); + raised = readVersion(first); + assert.ok(raised > 1, `expected the version to climb, got ${raised}`); + } finally { + await first.close(); + } + + // A genuinely new node and a new storage lock on the same directory. + const second = await harness({ storagePath }); + try { + await second.registry.reconcile([spec(1, Role.onOffLight)], false); + assert.equal(readVersion(second), raised, + "matter.js must restore the persisted version over the seed"); + } finally { + await second.close(); + } + }); + it("applies §3.5 reachability to Bridged Device Basic Information", async () => { const h = await harness(); try { diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 64259a0..2ddd5a4 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,9 +1,9 @@ # indigo-matter — Build Handover -**Last updated:** 2026-08-05 18:58 UTC -**Active work:** `feat/e5-persistence-hardening` (PR #126) — see the two E5 -sections below; the `main` summary in this header describes the last merge, not -that branch. +**Last updated:** 2026-08-05 20:11 UTC +**Active work:** `feat/e6-e7-pairing-and-agent` — E6 + E7, the last functional +export milestones (E8 is docs only). See the section immediately below; the +`main` summary in this header describes the last merge, not that branch. **Branch:** `main` — PRs #106, #107, #108 all merged. **Version:** `2026.7.13` **Tests:** 1005 passing (`cd indigo-matter && /Library/Frameworks/Python.framework/Versions/Current/bin/python3 -m pytest -q`) @@ -12,6 +12,380 @@ that branch. --- +## 2026-08-05 — E6 + E7: pairing UX, fabric management, the bridge LaunchAgent + +Plugin `2026.8.1`, bridge-node `0.5.0`. Suites: **2171 Python**, **345 TS** +(from 2056/344). pylint 9.41. + +These are the last two *functional* milestones. E8 is docs. + +### E6 — the user surface for protocol that already existed + +Everything the node needs for pairing shipped in E5: `get_pairing`, +`open_commissioning_window`, `remove_fabric`, and the §5 `fabrics_changed` / +`commissioned` / `decommissioned` / `window_closed` events. **None of it was +reachable from the plugin**, and the events were consumed by nobody — so a +bridge could be built, exported to, and never paired, and pairing activity was +invisible without polling. E6 is the surface. + +Four things are shaped by what Indigo dialogs can actually do, not by +preference, and they are worth knowing before touching any of it: + +1. **The pairing codes go to the EVENT LOG.** Indigo dialogs have no dynamic + labels, so a value computed *by the callback* cannot be shown in the dialog + that produced it. The log is this plugin's established channel for runtime + strings, and it is also the only place a code survives being scrolled past — + a window lasts up to 15 minutes and nobody types 11 digits right first time. +2. **The QR goes on an IWS-served page**, because a log line cannot carry an + image. `` → `http_pairing`, the one + handler in the plugin that returns HTML rather than JSON, at + `…/message/com.simons-plugins.indigo-matter/pairing/`. The URL is logged + beside the codes and comes from `indigo.server.getWebServerURL()`, which + prefers the reflector then the Bonjour name — so it is reachable from the + phone the user is holding, which is the case the page exists for. +3. **The §5.5 config readout is a read-only textfield**, seeded by + `getPrefsConfigUiValues`, for the same no-dynamic-labels reason. It does **no + I/O**: everything it shows comes from the last attach, the 15-second status + poll, or the §5 fabric events. A dialog open must never block on a node that + may be down. Both callback spellings are defined (`getPrefsConfigUiValues` + and `get_prefs_config_ui_values`) because Indigo 2025.2's `PluginBase` + carries snake_case aliases and which one it dispatches on is undocumented — + getting it wrong costs a silently blank field, so both is cheap insurance. +4. **"Pair" is "open a window", not "show the code" (PRD §6).** A Matter + passcode is not durable: the moment the first ecosystem commissions, the + basic window closes and the original code stops working. There is no such + thing as *the* pairing code to display. + +Two states the pair action must **not** open a window in, and both would do real +harm: + +- **already open** — §3.8's `assertClosed` refuses a second one, and forcing it + would kill the code the user is currently holding; +- **never commissioned** — §3.7 says the basic window is *already* open with the + persisted originals, so deriving a fresh enhanced code there invalidates a + code the user may already be typing. + +Both are detected by a `get_pairing` before the open, and both report the +existing code instead. + +**Duration is validated, not clamped.** 180–900s is Matter's band and the node +clamps to it; being silently given 900 when you asked for 60 is a difference the +user should hear about while the dialog is still open. + +**Unpair** is a dynamic fabric picker plus E5's now-verified two-gate pattern. +The picker is built from the fabric set the bridge already reported — never from +a fresh WS round trip, because a list callback runs while the dialog is opening. +Its rows name the *vendor* (`Apple (index 1)`), because a fabric's own `label` +is whatever the commissioner wrote there and for Apple that is a UUID-ish string +that tells nobody anything; the index is always shown because that is what §3.9 +removes a fabric **by**. The last-fabric case E5 hardened gets its own message: +matter.js factory-resets itself when the fabric set empties, so the user has +just reset the whole bridge without using the reset menu, and reporting a +routine removal would be a lie. + +### E7 — the bridge node as a managed LaunchAgent + +`bridge_agent.BridgeProcess` is the second `AgentSpec`, and it is what the XOQ3 +extraction was *for*: label `com.simons-plugins.indigo-matter.bridge`, package +`indigo-matter-bridge`, entry `dist/main.js`, its own logs, the **default +per-label** applied-digest marker (the controller keeps its legacy un-suffixed +one because every existing install has that file), port 5581, and an argv +builder. The `.indigo-node` install stamp stays **shared** — reviewed again here +and kept, because both agents run whatever single node this plugin resolved, so +a per-package stamp would assert a divergence that cannot exist. + +**The storage path is derived, not configured, in exactly one place.** +`bridge_agent.bridge_storage_path()` is called by the agent (which passes it as +`--storage-path`), by the fabric backup (which archives it), and it must equal +`config.ts`'s `DEFAULT_STORAGE_PATH`. A disagreement here is silent: a node +serving from one directory while the plugin backs up another, discovered at +restore time. + +**The lifecycle gate (XG5/XAC1) has an ordering that matters in both +directions.** The agent starts on the empty→non-empty allow-list transition, +*before* the client (a client built first spends its whole backoff dialling a +port nothing is listening on yet). It stops on non-empty→empty **after** the +un-export has landed and the socket is closed — stopping first would take the +node down with the §3.1 removal request still unsent, leaving the accessories in +every paired ecosystem and the debt pointing at a bridge the plugin has just +switched off. It is stopped even when that attach *failed*, because the socket +is closed and the list is empty either way and the debt in prefs is what carries +the un-export forward. + +A latch (`_agent_started`) gates the stop: a session that never brought the +agent up never boots one out, so a plugin reload on an install that has never +exported anything touches launchd not at all, and a bridge somebody started by +hand is not taken down by us. + +**A bridge-agent failure degrades export only.** The seams are contained in +`ExportBridge` (an Indigo callback must never see a launchd fault), the client +is still built when the agent will not start — refusing to build it would +replace one diagnosis with *none*, since the client's unreachable path is what +reports the outage with the node's error log attached — and every message says +in as many words that Indigo devices and inbound Matter control are unaffected. + +`_bridge_agent_diagnosis` is deliberately **read-only**: launchd owns respawn +via `KeepAlive`, the loaded-but-dead revival lives in `ensure_installed`, and a +diagnostic that quietly bounced the agent on every failure streak would turn a +crash-loop into a crash-loop nobody can read the log of. + +**`remove_package` is now per package** — the `TODO(E7)` in `launch_agent.py`, +closed. It used to `rmtree` the shared `node_modules` and delete +`package-lock.json` while booting out only its own label, which with two agents +meant the sibling's package vanished under a still-loaded job that then +crash-looped on module-not-found with a *matching* applied marker and nothing in +the log to say why. It is now `npm uninstall ` (preferred, because it +also prunes the transitive deps nothing else needs — matter.js is ~40MB of +them), falling back to deleting `node_modules/` alone. The lock file is +left alone: it describes the whole install root, not one package. + +The trade this makes explicit: wiping `node_modules` wholesale also happened to +fix a corrupt *shared* dependency, and this no longer does. That was never what +the menu claimed to do, and rebuilding a sibling agent's install as a side +effect of recovering this one is worse than the fault it incidentally cured. + +### PRD §5.5's export switch, and the mistake it would have been easy to make + +`exportEnabled` fails **open** — absent, blank, unparseable *or null* all read as +ON. That is the opposite of the controller's attestation flag, which fails +closed, and deliberately so: the harm of misreading *that* one is relaxing +device attestation; the harm of misreading *this* one is un-running a working +export for every user whose prefs predate the key. + +**Switching it off does not un-export.** It drops the socket and stops the +agent; the endpoints, the pairings and the allow-list all stand, and the +accessories go unavailable rather than disappearing. Answering the switch with +the §3.1 mass removal would delete every accessory from every paired ecosystem +and take their names, rooms, scenes and automations with them — a destructive, +irreversible answer to a checkbox. Ticking it back on reconnects. +`closedPrefsConfigUi` re-runs the transition so the switch acts immediately; +changed **ports** still need a reload, and the config dialog says so. + +### The QR: what was chosen and what was not + +**No QR is generated.** Rendering one needs either a Python dependency (Indigo's +framework Python has no image stack and this plugin ships none) or a +hand-written JS encoder — a few hundred lines of Reed–Solomon and bit-masking +whose failure mode is a plausible-looking square that no phone can read. Neither +earns its place for a code that Apple Home, Alexa and Google all accept **typed +in**. + +The page therefore shows the manual code at a size you can read across a room, +the raw `MT:` payload for copying, and a link to the CHIP project's own QR +viewer (`project-chip.github.io/connectedhomeip/qrcode.html`) for anyone who +wants to scan. Two consequences to be honest about: that link needs internet +access, and it sends the payload to a third-party page — both stated on the page +itself. The payload is URL-encoded into it, because an `MT:` string is base-38 +and can legitimately contain `+`, `/` or `%`; unencoded, the tool opens on a +silently truncated payload, which is a QR that scans and means the wrong thing. + +If a real QR is ever wanted, the honest options are a bundled pure-Python +encoder under `Contents/Packages/` or an SVG built server-side — both are new +dependencies, and neither was worth it here. + +### Bridged-accessory identity is now complete + +`bridgedInfoFor` populated only `nodeLabel`, `productName`, `productLabel`, +`serialNumber`, `uniqueId`, `reachable` and `configurationVersion`. It now also +carries **`vendorName`, `vendorId`, `hardwareVersion` + `hardwareVersionString`, +`softwareVersion` + `softwareVersionString`** — parity with matterbridge, and +more accessory detail in every ecosystem's device pane, which otherwise reads +blank or "Unknown" for facts the bridge does know. + +Verified against `@matter/types` 0.17.8's +`bridged-device-basic-information.d.ts`: **every one of these is optional on the +bridged cluster** (only `reachable` is mandatory), so all six are permitted. +Worth knowing for next time: the bridged cluster's optional set is **not** +`BasicInformation`'s — it has no `productId`, no `partNumber`, no +`capabilityMinima`, and nothing outside the verified list was added. + +The values come from a new `BridgedIdentity` struct built in `node.ts` from the +same constants and the same `softwareVersion` derivation the root node's +`BasicInformation` uses, so an ecosystem showing a child's firmware and the +bridge's own can never be shown two answers. `registry.ts`'s `productName` +option became `bridgeIdentity`. `registry.test.ts` pins the whole field set so +it cannot silently shrink. `vendorId` is written through `VendorId()` — matter.js +validates the brand at write time. + +### A matter.js finding worth not re-deriving: ConfigurationVersion IS persisted + +While enriching the bridged identity above, the seeded `configurationVersion: 1` +looked like a bug, and the data model says it is one: + +- the **root** node's `BasicInformation.ConfigurationVersion` carries data-model + quality `N` (`@matter/model` 0.17.8, `basic-information.element.js`), so + matter.js persists it — a live bridge shows it climbing, and jarvis's is at 13; +- the **bridged** cluster's attribute carries **no quality at all** + (`bridged-device-basic-information.element.js`: + `Attribute({ name: "ConfigurationVersion", id: 24, conformance: "[Rev >= v6]" })`), + and `BridgedDeviceBasicInformationServer.schema` extends only `uniqueId` to be + persistent; +- child endpoints are constructed fresh on every attach. + +Read together that says every exported accessory's ConfigurationVersion resets to +**1** on every node restart — a backwards jump on an attribute Matter defines as +monotonic (controllers use it to decide whether cached accessory metadata is +stale), and one matter.js guards against *only* on the root +(`#preventConfigurationVersionRegression`). + +**It was measured, and it does not happen.** matter.js persists the value anyway, +independent of the declared quality. Its own store contains, verbatim: + +``` +root.parts.aggregator.parts.indigo-1.bridgedDeviceBasicInformation.configurationVersion = 2 +``` + +and on the next start it is restored **over** the constructor seed — seeded `1`, +read back `2`. So the literal is only an *initial* value for the first creation +of an endpoint id, which is exactly what it should be, and there is nothing to +fix. + +A persistence layer for it was built and then **reverted**: it added a +`configurationVersions` map to `endpoint-map.json`, seeded from it, and bumped on +role change. All of it was machinery for a fault that does not exist, and its +comments asserted a mechanism that is empirically wrong. What survives is one +test — `registry.test.ts` → *"matter.js RESTORES a bridged accessory's +ConfigurationVersion across a restart"* — which pins the measured behaviour +against a real stack and a real restart, so the data model cannot mislead the +next person into rebuilding the same thing. + +**One genuine gap identified and deliberately not taken:** nothing ever calls the +*child's* `increaseConfigurationVersion`, so a bridged accessory's version never +moves after creation. The spec says a bridge SHALL increase it when it detects a +configuration change on that device (a role change being the obvious one). That +is a real, small conformance gap — it is not a regression, it costs nothing today +(controllers re-read on the bridge's own bump, which does fire per batch), and +fixing it is a scoped change of its own rather than something to smuggle into +E6/E7. + +### Distribution: the bridge is an npm package, and it is NOT published yet + +`DEFAULT_INSTALL_SPEC = "indigo-matter-bridge@0.5.0"` is a **registry** spec, +exact-pinned exactly as `matter-server@1.2.2` is, and installing it is +`npm install --prefix ~/indigo-matter ` with a different string — which is +precisely the parameterisation the `AgentSpec` extraction bought. The plugin +bundle ships **no JavaScript**; `bridge-node/` stays a top-level source +directory in the repo and is published from there. + +**`indigo-matter-bridge` is not on the registry yet.** Until Simon publishes it, +"Install/update the Matter export bridge" cannot resolve the pin and E7 cannot +be exercised end to end on jarvis. + +#### Publishing the bridge node + +`bridge-node/package.json` is publish-ready as of this branch: `"private": true` +was **removed** (flagged deliberately — it is what blocks `npm publish`), +`files` is limited to `dist`, `main` is `dist/main.js` (which is what +`bridge_agent.DEFAULT_BRIDGE_ENTRY` expects), `engines.node` is `>=22.13.0`, and +a `prepublishOnly` script runs `clean` then `build` so a stale `dist` can never +ship. `npm pack --dry-run` produces 34 files / ~112kB. + +```sh +cd bridge-node +npm login # Simon's npm account — nobody else can do this step +npm test # 345 tests; publishing an untested build is the one unrecoverable mistake +npm publish --access public +``` + +**Every release bumps two versions in step**, and a mismatch is silent until an +install fails: `bridge-node/package.json`'s `version`, and +`bridge_agent.DEFAULT_INSTALL_SPEC` in the plugin. `test_bridge_agent.py` asserts +they agree, so the mismatch is a test failure rather than a field report. + +**Pre-publish testing** (dev workaround, *not* the shipped default): the same +on-disk layout is reproduced by a local install — + +```sh +npm install --prefix ~/indigo-matter /path/to/indigo-matter/bridge-node +``` + +which puts the package at `~/indigo-matter/node_modules/indigo-matter-bridge` +exactly where `LaunchAgent._server_entry()` looks for it. `npm link` works too. +Do this and everything in E7 runs unchanged; just remember the plugin's install +menu will still try the registry spec. + +### Deferred / not done + +- **XAC3's live pairing is unverified.** Every code path is unit-tested, but + nothing here has met a real ecosystem. That is the jarvis script below. +- **A locally-opened commissioning window still does not update the + `AdministratorCommissioning` cluster's `windowStatus`/`adminFabricIndex`.** + Unchanged from E5: matter.js 0.17.8's cluster command asserts a remote + authenticated session, so §3.8 drives `DeviceCommissioner` directly. A + conformance gap, not something the pairing flow depends on — it was listed as + "for E7 to close" and is **not** closed, because closing it means either + patching matter.js or faking a session, and the flow works without it. +- **Changing the bridge ports still needs a plugin reload.** The switch acts + immediately; the ports are read when the client and the agent are built. +- **Restoring the bridge-node storage from a backup** is now *possible* (E7 + gives the stop/start seam the E5 note said was missing) but is **not wired**: + `fabric_backup.restore_backup` still reports and skips the `bridge-node/` + members. Wiring it is a small, self-contained follow-up. +- **Still open from earlier:** #105, #83, #84, #62 follow-up, #43, #46, #21–#24. + +### Open, unexplained, and Apple-side: room changes in Apple Home + +Observed on jarvis with E5 deployed and 3 accessories live: on/off control works, +but Apple Home refuses to change an accessory's **room** ("can't change +settings"). Simon had changed those same accessories' rooms successfully before, +so this is a change in Apple-side state, not in bridge behaviour. + +**No bridge-side change can address this, and the reasoning matters.** Room +assignment is a HomeKit/iCloud home-database concept. It never crosses Matter +and never reaches our node — so the node log showing no traffic at the moment of +the attempt is the *expected* state, not evidence of anything. An earlier +hypothesis about `isBridged`/`uniqueIdentifiersForBridgedAccessories` was raised +and is **unsupported**; do not carry it forward. + +Recorded as something to watch, not something to fix. The +`BridgedDeviceBasicInformation` enrichment above is unrelated and is not +presented as a remedy for it. + +### Deploying E6 + E7 to jarvis + +Nothing in E5, E6 or E7 has been deployed. In order, because each step gates +the next: + +1. **Publish `indigo-matter-bridge@0.5.0`** (above). Without it step 4 fails. + For a dry run, use the local-install workaround instead. +2. **Install the plugin bundle** — `2026.8.1`. This is an *update* to an + existing install, so a copy of the changed files plus a plugin restart is + enough; only a first-time install needs the double-click. +3. **Configure ▸ Matter export.** Confirm the new Export section renders, that + "Enable Matter export" is ticked, and that the readout line appears. If the + readout is blank, Indigo dispatched neither callback spelling — say so, do + not guess. +4. **Plugins ▸ Matter ▸ Install/update the Matter export bridge.** Watch for + "Matter export bridge installed"; it will refuse to start anything while + nothing is exported, which is correct (XG5). +5. **Export one relay** in "Manage Matter Exports…". This is XAC2: the agent + should install, the LaunchAgent should load, and the log should show + "bridge node LaunchAgent is running" followed by "bridge node attached". + Verify with `launchctl print gui/$(id -u)/com.simons-plugins.indigo-matter.bridge`. +6. **Plugins ▸ Matter ▸ Pair Matter Bridge…**, 900s. The log must carry the + manual code, the `MT:` payload, an expiry, and the page URL. +7. **Open the page URL on a phone** and check it renders. Then **add the bridge + in Apple Home** from the manual code — expect and accept the uncertified + warning. This is **XAC3**, the one thing no test can answer. +8. **Re-open Configure…** and confirm the readout now names Apple. +9. **Unpair an Ecosystem…** against a *second* fabric if one exists; against the + only one it factory-resets the bridge, which is correct but means re-pairing. +10. **Empty the allow-list** and confirm the accessories leave Apple Home + (XAC7) *and* that the agent stops + (`launchctl print …bridge` should report the label as unloaded). + +**What Simon must do by hand, and nobody else can:** + +- **`npm publish`** — his npm account (step 1). Everything else is blocked on it. +- **Pairing in Apple Home** (step 7) — the uncertified "Add Anyway" prompt is + an interactive decision on his phone. +- **Deciding whether to keep the bridge paired** before step 9/10, since both + are destructive to the pairing he has just made. +- The plugin bundle install itself, if he would rather not have it copied over a + running install. + +--- + ## 2026-08-05 — E5 hardening, ROUND TWO: the PR #126 whole-PR reviews Plugin `2026.8.0`, bridge-node `0.4.0`. Suites: **2056 Python**, **344 TS** diff --git a/docs/PRD-indigo-matter-export.md b/docs/PRD-indigo-matter-export.md index 76e3cb0..86a3a96 100644 --- a/docs/PRD-indigo-matter-export.md +++ b/docs/PRD-indigo-matter-export.md @@ -373,8 +373,8 @@ not slot arithmetic. Per-export settings live in the §5.1 dialog. | E3 | Relay + dimmer export | 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) | | E4 | Sensors + thermostat export | Mapping table complete for v1 | | E5 | Endpoint persistence | XAC5 — the highest-risk correctness requirement | -| E6 | Pairing/unpairing UX + fabric readout | §6 complete, including XAC3's displayed-code pairing flow | -| E7 | launchd agent + failure recovery | §7, XAC1, XAC2, XAC7, XAC8 | +| E6 | Pairing/unpairing UX + fabric readout — **BUILT** (2026-08-05, plugin `2026.8.1`) | §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's live pairing is unverified** — it needs jarvis and a real ecosystem | +| E7 | launchd agent + failure recovery — **BUILT** (2026-08-05, plugin `2026.8.1`, 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. **`indigo-matter-bridge` is not yet on the npm registry** — publishing is Simon's action (`docs/HANDOVER.md` → "Publishing the bridge node"), and until then the install action cannot resolve the pinned spec | | E8 | Docs | `INSTALL.md` export section, uncertified-prompt explanation, ecosystems-untested note (§10), `MATTER.md` outbound architecture | **E0 is the whole validation loop.** It answers the only question that can kill diff --git a/indigo-matter.indigoPlugin/Contents/Info.plist b/indigo-matter.indigoPlugin/Contents/Info.plist index acfb156..337223c 100644 --- a/indigo-matter.indigoPlugin/Contents/Info.plist +++ b/indigo-matter.indigoPlugin/Contents/Info.plist @@ -20,7 +20,11 @@ IwsApiVersion 1.0.0 PluginVersion +<<<<<<< HEAD 2026.8.2 +======= + 2026.8.1 +>>>>>>> feat(export): E6+E7 — pairing UX, fabric management, bridge LaunchAgent ServerApiVersion 3.6 diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/Actions.xml b/indigo-matter.indigoPlugin/Contents/Server Plugin/Actions.xml index b50882a..557b114 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/Actions.xml +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/Actions.xml @@ -56,4 +56,12 @@ matter device diagnostics http_diagnostics + + + matter export pairing page + http_pairing + diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/MenuItems.xml b/indigo-matter.indigoPlugin/Contents/Server Plugin/MenuItems.xml index 5a33326..c4c066d 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/MenuItems.xml +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/MenuItems.xml @@ -199,6 +199,67 @@ Endpoint numbers are kept, so re-pairing the same ecosystems restores the same a + + + Pair Matter Bridge… + menuPairMatterBridge + Open pairing window + + + + + + + Matter allows 180 to 900 (3 to 15 minutes). 900 is the maximum and the default. + + + + + Unpair an Ecosystem… + menuUnpairEcosystem + + + + + + + + The index is the bridge node's own fabric number, shown so this matches the Event Log. + + + + + + + + + + + Install/update the Matter export bridge + menuInstallBridgeNode + Export fabric backup… menuExportFabricBackup diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/PluginConfig.xml b/indigo-matter.indigoPlugin/Contents/Server Plugin/PluginConfig.xml index e463ed4..e2d0fe1 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/PluginConfig.xml +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/PluginConfig.xml @@ -98,6 +98,50 @@ + + + + + + + + Publishes the devices listed in "Manage Matter Exports…" to Apple Home + and other ecosystems. Unticking this stops the bridge and its accessories go + unavailable — it does NOT unpair anything or delete accessories, so ticking it + again brings them straight back. Nothing runs until at least one device is + exported. + + + + + + + + + + How the plugin talks to the bridge node. Loopback only — nothing outside + this Mac can reach it. Change it only if something else already uses 5581. + + + + The port ecosystems reach the bridge on. 5540 is the Matter default and + is what Alexa expects; move it only if another Matter stack on this Mac + (Homebridge 2.x, matterbridge, Home Assistant) already holds it — the bridge's + log will say so. Changing it takes effect on the next plugin reload. + + diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_agent.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_agent.py new file mode 100644 index 0000000..1d230b5 --- /dev/null +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_agent.py @@ -0,0 +1,228 @@ +"""The Matter **export bridge node** LaunchAgent (PRD-indigo-matter-export §4.2). + +The second of the plugin's two launchd-managed node processes, and the reason +:mod:`launch_agent` exists at all: the controller's hard-won recovery machinery +(applied-plist digest, loaded-but-dead revival, orphan/EADDRINUSE reaping) is +shared verbatim, and everything that differs is carried by a frozen +:class:`~launch_agent.AgentSpec`. :class:`BridgeProcess` is to the bridge node +what :class:`server_process.ServerProcess` is to the controller. + +Three differences from the controller agent, all of them deliberate: + +* **It is not installed and not started while the allow-list is empty (XG5 / + XAC1).** A fresh install must be completely inert — no plist, no process, no + log noise — so nothing here is called from ``startup``. The empty→non-empty + transition in :class:`export_bridge.ExportBridge` is what brings it up, and + non-empty→empty (after the un-export has actually landed) is what takes it + down. Constructing a ``BridgeProcess`` is side-effect free; ``ensure_installed`` + is the first thing that writes anything. +* **Its storage dir is derived, not configured.** It is the sibling of the + controller's (PRD §4.3), which is also ``bridge-node/src/config.ts``'s + ``DEFAULT_STORAGE_PATH``. One derivation, used by the agent, by the fabric + backup, and by the plugin — see :func:`bridge_storage_path`. +* **A failure here degrades export only.** The inbound controller is a separate + agent with a separate label, package, storage path and port; nothing in this + module can reach it. Callers wrap agent operations so that a launchd fault + never propagates into an Indigo callback. + +**Distribution.** ``indigo-matter-bridge`` is a published npm package, exact- +pinned by :data:`DEFAULT_INSTALL_SPEC` exactly as ``matter-server@1.2.2`` is — +which is precisely the parameterisation the ``AgentSpec`` extraction was for: +installing it is ``npm install --prefix ~/indigo-matter `` with a +different string. The plugin bundle ships **no JavaScript**; ``bridge-node/`` is +a top-level source directory in the repo and is published from there. See +``docs/HANDOVER.md`` → "Publishing the bridge node" for the release steps and +for the local-install recipe that stands in before a version is on the registry. +""" +from __future__ import annotations + +import os +import subprocess +import time +from typing import Any, Callable, Optional + +from launch_agent import AgentSpec, LaunchAgent, expand_home +from server_process import LABEL as CONTROLLER_LABEL + +#: launchd job label. A SUFFIX of the controller's, never the same string: +#: the label is the plist filename stem and launchd's job identity, so sharing +#: one would have each agent bootout the other on every reload. +LABEL = "com.simons-plugins.indigo-matter.bridge" + +#: The npm package name. Matched in ``ps`` output when reaping orphans and used +#: as the directory under ``node_modules`` holding the entry point. +BRIDGE_PACKAGE = "indigo-matter-bridge" + +#: Version installed by :meth:`BridgeProcess.install`. Exact-pinned (no caret) +#: for the same reason ``matter-server`` is: this package pins matter.js exactly, +#: and matter.js patch releases have changed what Apple Home renders with no code +#: change on the bridge side. **This is the one line a release bumps**, in step +#: with ``bridge-node/package.json``'s ``version``. +DEFAULT_INSTALL_SPEC = f"{BRIDGE_PACKAGE}@0.5.0" + +#: Fallback entry point when the installed package's ``package.json`` is missing +#: or unreadable. Must match ``bridge-node/package.json``'s ``main``; +#: ``LaunchAgent._server_entry()`` reads the real value and only falls back here. +DEFAULT_BRIDGE_ENTRY = "dist/main.js" + +BRIDGE_OUT_LOG = "bridge-node.log" +BRIDGE_ERR_LOG = "bridge-node.err.log" + +#: Matter UDP port the node binds (PRD §4.4). 5540 is Matter's default and the +#: one matter.js's ECOSYSTEMS.md records as Alexa's hard requirement; the pref is +#: the escape hatch when another Matter stack on the same Mac already holds it. +PREF_MATTER_PORT = "bridgeMatterPort" +DEFAULT_MATTER_PORT = "5540" + +#: Reused from the controller rather than given its own pref: matter.js's mDNS +#: stack defaults to every interface and breaks on Macs with VPN/utun interfaces, +#: which is the same fault, on the same host, that the controller's pref exists +#: for. A blank value means "let matter.js decide" and the flag is omitted. +PREF_PRIMARY_INTERFACE = "primaryInterface" + + +def bridge_storage_path(controller_storage_path: str) -> str: + """The bridge node's storage dir, given the controller's (PRD §4.3). + + Derived rather than configured, and derived in exactly ONE place, because + three callers have to agree on it or the disagreement is silent: this agent + (which passes it as ``--storage-path``), the fabric backup (which archives + it), and ``bridge-node/src/config.ts``'s ``DEFAULT_STORAGE_PATH`` (which is + what the node uses if nobody passes the flag). A mismatch means a node + serving from one directory while the plugin backs up another — a backup that + silently contains nothing, discovered at restore time. + + SACRED, for two independent reasons: it holds the operational credentials of + every ecosystem fabric the bridge has joined (losing them un-pairs the lot) + and the endpoint-number witness (losing that duplicates every accessory in + every ecosystem). Created here, never deleted here. + """ + controller = os.path.normpath(controller_storage_path) + return os.path.join(os.path.dirname(controller), "bridge-node") + + +def default_controller_storage(home: str) -> str: + """The controller's default storage dir — the root :func:`bridge_storage_path` hangs off.""" + return expand_home(f"~/Library/Application Support/{CONTROLLER_LABEL}/matter-server", home) + + +def _port_str(value: Any, fallback: str) -> str: + """A port as a non-empty numeric string, or ``fallback``. + + ``bridge-node/src/config.ts`` throws on a value it cannot parse *and* on a + flag whose value is missing — deliberately, so a bad plist fails loudly + rather than silently running on the wrong port. Under ``KeepAlive`` that is a + respawn loop, which is the precise failure the controller's blank ``--port`` + caused before 2026.7.1. So a blank or non-numeric pref never reaches the CLI: + it becomes the documented default here, where it is one log line instead. + """ + text = str(value or "").strip() + if text.isdigit() and 1 <= int(text) <= 65535: + return text + return fallback + + +def bridge_arguments(agent: "BridgeProcess") -> list[str]: + """Build the bridge node's command line. The argv hook of its AgentSpec. + + ``node …`` rather than ``npx indigo-matter-bridge``: the same + reasoning as the controller (see :mod:`server_process`) plus one of its own — + ``npx`` would resolve a *different* copy of the package if one is on PATH, + and launchd would keep respawning it long after the plugin was upgraded. + """ + args = [ + agent.node_path, + agent._server_entry(), # pylint: disable=protected-access + "--storage-path", agent.storage_path, + "--ws-port", agent.ws_port, + "--matter-port", agent.matter_port, + ] + if agent.mdns_interface: + # Omitted entirely when blank. The node validates the name against the + # host's real interfaces and refuses to start on a miss, which is right + # — but only when the user actually asked for one. + args += ["--mdns-interface", agent.mdns_interface] + return args + + +class BridgeProcess(LaunchAgent): + """Install / control the Matter export bridge node's LaunchAgent. + + The bridge specialisation of :class:`~launch_agent.LaunchAgent`: it reads the + export prefs, builds its :class:`~launch_agent.AgentSpec`, and adds nothing + else. It deliberately takes the DEFAULT per-label applied-plist marker + (``.launchagent-com.simons-plugins.indigo-matter.bridge.sha256``) — the + controller keeps the original un-suffixed filename because every existing + install already has that file, and this agent has no such history. + """ + + def __init__( + self, + prefs: dict, + logger: Any, + *, + home: Optional[str] = None, + npx_path: Optional[str] = None, + runner: Callable[..., "subprocess.CompletedProcess"] = subprocess.run, + exists: Callable[[str], bool] = os.path.exists, + sleep: Callable[[float], None] = time.sleep, + ) -> None: + # Resolved before super() because the storage path the spec carries + # expands against it — the same ordering ServerProcess uses. + resolved_home = home or os.path.expanduser("~") + raw_controller = str(prefs.get("storagePath") or "").strip() + controller_storage = (expand_home(raw_controller, resolved_home) if raw_controller + else default_controller_storage(resolved_home)) + storage_path = bridge_storage_path(controller_storage) + # Imported here rather than at module scope: bridge_protocol is the WIRE + # contract and this is process management, so the dependency runs one way + # only. The port itself must agree with what BridgeClient dials, which is + # why it is read from the same constant rather than duplicated. + import bridge_protocol # pylint: disable=import-outside-toplevel + + ws_port = _port_str(prefs.get(bridge_protocol.PREF_WS_PORT), + bridge_protocol.DEFAULT_WS_PORT) + super().__init__( + AgentSpec( + label=LABEL, + package=BRIDGE_PACKAGE, + install_spec=DEFAULT_INSTALL_SPEC, + default_entry=DEFAULT_BRIDGE_ENTRY, + storage_path=storage_path, + out_log=BRIDGE_OUT_LOG, + err_log=BRIDGE_ERR_LOG, + argv=bridge_arguments, + # The loopback protocol port, NOT the Matter UDP port: the + # orphan reaper's port signal is an `lsof -iTCP` match, and the + # thing a second bridge node actually contends for on TCP is + # 5581. 5540 is UDP and a clash there is the node's own + # first-class §7 failure, reported by the node at startup. + port=int(ws_port), + ), + prefs, logger, + home=resolved_home, npx_path=npx_path, + runner=runner, exists=exists, sleep=sleep, + ) + self._ws_port = ws_port + self._matter_port = _port_str(prefs.get(PREF_MATTER_PORT), DEFAULT_MATTER_PORT) + self.mdns_interface = str(prefs.get(PREF_PRIMARY_INTERFACE) or "").strip() + + @property + def ws_port(self) -> str: + """The loopback protocol port as the CLI takes it. Read-only. + + ``spec.port`` (the reaper's EADDRINUSE signal) is frozen from the same + pref at construction, so a post-construction assignment here would change + the command line without changing the port the reaper polices. Both come + from prefs — rebuild the ``BridgeProcess`` to change them. + """ + return self._ws_port + + @property + def matter_port(self) -> str: + """The Matter UDP port as the CLI takes it. Read-only, same reasoning.""" + return self._matter_port + + def install(self, install_spec: str = DEFAULT_INSTALL_SPEC) -> bool: + """npm-install the bridge node. Signature pins the default for callers/docs.""" + return super().install(install_spec) diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py index 2e3d16c..b0de267 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py @@ -85,6 +85,18 @@ #: deadline over the removals — see :func:`bridge_client.attach_timeout_for`. PREF_PENDING_REPLACE_ALL = "matterExportPendingReplaceAll" +#: PRD §5.5's wholesale export switch. **Absent means ON**, because it arrived in +#: E6 and every install that predates it already has a working allow-list; a +#: missing key reading as "off" would silently un-run everyone's export on +#: upgrade. Explicitly off means: build no client, start no agent, and — the +#: part that is easy to get wrong — **un-export nothing**. Turning a switch off +#: is not the same statement as emptying the allow-list (PRD §7), and answering +#: it with the §3.1 mass removal would delete every accessory from every paired +#: ecosystem, taking their names, rooms and automations with them. Off means the +#: accessories stop being *updated* and go unreachable, which is recoverable by +#: ticking the box again. +PREF_EXPORT_ENABLED = "exportEnabled" + class ExportBridge: """Owns the bridge client and everything the Indigo callbacks mean for it. @@ -105,6 +117,16 @@ class ExportBridge: ``ExportStore`` takes one. :param executor_factory: builds the command executor; injected so tests can run dispatches inline instead of on a thread. + :param agent_start: called on the empty→non-empty transition, before the + client is built — the E7 LaunchAgent seam (XG5/XAC1). Blocking (launchctl + and file I/O), so it runs on whichever Indigo thread changed the + allow-list, never on the loop. + :param agent_stop: called after an un-export has actually landed and the + socket is closed. Awaited off the loop by the un-export coroutine. + :param agent_diagnose: called when the node stops answering; returns a + sentence naming what the agent found (and may revive a dead job), or + ``None`` when it has nothing to add. Purely advisory — export degrades, + the inbound controller is untouched. """ # The seams ARE the API, exactly as BridgeClient's callbacks are. @@ -115,7 +137,10 @@ def __init__(self, store, runtime, logger, prefs_getter: Callable[[], dict], *, device_getter: Optional[Callable[[int], Any]] = None, client_factory: Optional[Callable[..., BridgeClient]] = None, save_prefs: Optional[Callable[[], None]] = None, - executor_factory: Optional[Callable[[], Any]] = None) -> None: + executor_factory: Optional[Callable[[], Any]] = None, + agent_start: Optional[Callable[[], None]] = None, + agent_stop: Optional[Callable[[], None]] = None, + agent_diagnose: Optional[Callable[[], Optional[str]]] = None) -> None: self._store = store self._runtime = runtime self._logger = logger @@ -126,6 +151,13 @@ def __init__(self, store, runtime, logger, prefs_getter: Callable[[], dict], *, self._client_factory = client_factory or BridgeClient self._save_prefs = save_prefs self._executor_factory = executor_factory or _command_executor + self._agent_start = agent_start + self._agent_stop = agent_stop + self._agent_diagnose = agent_diagnose + #: Whether THIS plugin session has brought the bridge agent up. Gates + #: the stop (see :meth:`_stop_agent`) so a session that never started it + #: never boots one out. + self._agent_started = False #: The live client, or ``None`` while nothing is exported (XG5). self.client: Optional[BridgeClient] = None @@ -194,6 +226,16 @@ def __init__(self, store, runtime, logger, prefs_getter: Callable[[], dict], *, #: The last drift set reported, for the same reason — see #: :meth:`_on_drift_detected`. self._drift_reported: frozenset = frozenset() + #: ISO 8601 expiry of the commissioning window the pairing menu opened, + #: or ``None``. Held here rather than re-read from the node because the + #: PRD §5.5 config readout is a dialog-open, and a dialog must not block + #: on a WS round trip. Cleared by the §5 ``window_closed`` event, which + #: fires on expiry AND on a commissioner completing. + self.window_expires_at: Optional[str] = None + #: The fabric set as of the last §5 ``fabrics_changed`` (or attach), for + #: the same readout. ``None`` means "nothing has told us yet", which the + #: readout must not render as "no ecosystems are paired". + self.fabrics: Optional[list] = None # ------------------------------------------------------------------ # Lifecycle @@ -203,6 +245,11 @@ def active(self) -> bool: """True while a client exists (whether or not it is connected).""" return self.client is not None + @property + def enabled(self) -> bool: + """PRD §5.5's wholesale export switch, as the config readout reads it.""" + return self._export_enabled() + def start(self) -> None: """Create and run the client. Idempotent. @@ -220,6 +267,20 @@ def start(self) -> None: return if self.client is not None: return + if not self._export_enabled(): + # Not an error and not a latch: the user has turned export off in + # Configure…, and every caller of start() is a path that would + # ordinarily bring it up. Saying so once per attempt at debug is + # enough — the config dialog's own readout is where this belongs. + self._logger.debug("Matter export: export is switched off in Configure…; " + "not connecting to the bridge node") + return + # The agent BEFORE the client, and deliberately not conditional on it + # having worked: a bridge that will not start is an export outage, and + # the client's own unreachable path is what reports it with the node's + # error log attached. Refusing to build the client here would replace one + # diagnosis with none. + self._ensure_agent() self.client = self._client_factory( self._logger, self._prefs_getter(), plugin_version=self._plugin_version, @@ -230,6 +291,10 @@ def start(self) -> None: on_attach_refused=self._on_attach_refused, on_version_skew=self._on_version_skew, on_drift_detected=self._on_drift_detected, + on_fabrics_changed=self._on_fabrics_changed, + on_commissioned=self._on_commissioned, + on_decommissioned=self._on_decommissioned, + on_window_closed=self._on_window_closed, on_repeated_failure=self._on_unreachable, ) self._unreachable_reported = False @@ -298,6 +363,19 @@ def exports_changed(self) -> None: # deadline over what the node is about to remove, and the store that # would have told it is already empty by the time we get here. removing, self._last_export_count = self._last_export_count, count + if not self._export_enabled(): + # PRD §5.5's switch, and the ONE branch that must not un-export. See + # PREF_EXPORT_ENABLED: off is "stop updating the accessories", not + # "delete them from every ecosystem". Drop the socket and stop the + # agent; the endpoints, the pairings and the allow-list all stand. + if self.client is not None: + self._logger.info("Matter export: export is switched off — disconnecting from the " + "bridge node. Exported accessories are LEFT paired and will show " + "as unavailable until export is switched back on.") + self.stop() + self._stopped = False # a config change is not a shutdown + self._stop_agent() + return if count: self.start() elif self.client is not None: @@ -316,6 +394,84 @@ def exports_changed(self) -> None: self._pending_replace_all()) self.start() + def _export_enabled(self) -> bool: + """PRD §5.5's wholesale switch. Absent, blank or unparseable means ON. + + Only an explicit, recognisable negative turns export off. Indigo + checkboxes arrive as real bools once saved but as ``"true"``/``"false"`` + strings from other write paths, and ``bool("false")`` is ``True`` — so a + string is parsed strictly and anything else falls back to *enabled*. + Failing open is the right direction here and the opposite of the + controller's attestation flag (``server_process._pref_flag``, which fails + closed): the harm of misreading this one is un-running a working export + for every user whose prefs predate the key. + """ + raw = self._prefs_getter().get(PREF_EXPORT_ENABLED) + if raw is None: + # Absent, or present-and-null. `.get(key, True)` covers only the + # first, and a null is what a hand-edited .indiPref or a partial + # restore leaves behind — reading it as "off" would un-run the + # export of anyone whose prefs file has been through either. + return True + if isinstance(raw, str): + return raw.strip().lower() not in ("false", "no", "off", "0") + return bool(raw) + + # ------------------------------------------------------------------ + # The bridge LaunchAgent (PRD §4.2 / XG5 / XAC1) + # ------------------------------------------------------------------ + def _ensure_agent(self) -> None: + """Bring the bridge agent up, if there is one injected. Never raises. + + Contained here rather than at the seam's implementation because the + caller is an Indigo callback: a launchd fault, a full disk, a missing + node — none of them may escape into ``deviceDeleted`` or a menu handler. + The consequence of failing is an export outage, which the client's + unreachable path already reports with the node's own error log attached. + """ + if self._agent_start is None: + return + # Latched BEFORE the attempt: a start that raised may still have written + # a plist and bootstrapped a job, so "we have not touched launchd" would + # be a claim we cannot make afterwards — and the stop that would clear it + # up is the thing this flag gates. + self._agent_started = True + try: + self._agent_start() + except Exception as exc: # pylint: disable=broad-except + self._logger.error( + "Matter export: could not start the bridge node's LaunchAgent (%s). Indigo " + "devices and inbound Matter control are unaffected; exported accessories will " + "not be reachable until this is fixed.", exc) + self._logger.exception(exc) + + def _stop_agent(self) -> None: + """Take the bridge agent down. Never raises; same containment reasoning. + + Called only once there is genuinely nothing to serve — the allow-list is + empty AND the un-export has landed, or export has been switched off. The + pairings live in the node's storage dir, which stopping does not touch + (PRD §5.4: a plugin reload must never un-pair anyone). + """ + if self._agent_stop is None or not self._agent_started: + # ⊗ XAC1. Nothing this plugin session started, nothing it stops: a + # bootout issued by a plugin that never brought the agent up would + # take down a bridge the *user* started by hand, and would run on + # every reload of an install that has never exported anything. + return + self._agent_started = False + try: + self._agent_stop() + except Exception as exc: # pylint: disable=broad-except + self._logger.warning( + "Matter export: could not stop the bridge node's LaunchAgent (%s). It will keep " + "running with nothing to export, which is harmless — pairings are untouched.", exc) + + async def _stop_agent_off_loop(self) -> None: + """:meth:`_stop_agent` from a loop-thread caller, without blocking the loop.""" + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self._stop_agent) + def _pending_replace_all(self) -> int: """**How many** endpoints an un-export still owes the node (XAC7). @@ -487,6 +643,23 @@ async def _un_export() -> None: if self._start_after_un_export: self._start_after_un_export = False self.start() + else: + # **After** the un-export, never before it (XG5's other + # half). Stopping the agent first would take the node down + # with the removal request still unsent, and the debt in + # prefs would then have to reach a node the plugin has just + # switched off — which is only recoverable by exporting + # something again. Off the loop, because launchctl is + # subprocess I/O and this coroutine shares its loop with the + # inbound matter-server client. + # + # Deliberately runs even when the attach FAILED: the socket + # is closed either way, the allow-list is empty either way, + # and the debt is what carries the un-export forward — it + # survives in prefs and is discharged the next time anything + # brings the client back up. + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self._stop_agent) if not self._fire(_un_export(), "un-exporting everything", lost="exported accessories will linger in paired ecosystems"): @@ -1028,6 +1201,11 @@ def _on_attached(self, status, carried_replace_all: bool = False) -> None: self._halted_reported = False self._recovery_reported = False self._refusal_reported = None + # The attach's own §4.3 report is the fabric set's only source on a fresh + # connection: `fabrics_changed` fires on CHANGES, so a bridge that has + # been paired for months emits nothing at all and the §5.5 readout would + # sit on "not known yet" forever. + self.fabrics = list(getattr(status, "fabrics", ()) or []) self._logger.info("Matter export: bridge node attached — %d endpoint(s) live, %s", status.endpoint_count, "commissioned" if status.commissioned else "not yet paired") @@ -1039,11 +1217,14 @@ def _on_attached(self, status, carried_replace_all: bool = False) -> None: "Matter export: the outstanding un-export completed — %d accessory record(s) " "removed from the bridge node; paired ecosystems will drop them.", owed) if len(self._store) == 0: - # XG5 again: nothing is exported, so nothing needs a socket. - # Only when the list is still empty — a debt discharged by an - # attach that also carried real endpoints is an export we must - # keep serving, not one to hang up on. + # XG5 again: nothing is exported, so nothing needs a socket — + # and, since E7, nothing needs an agent either. Only when the + # list is still empty: a debt discharged by an attach that also + # carried real endpoints is an export we must keep serving, not + # one to hang up on. self._stop_soon("closing the bridge client after the outstanding un-export") + self._fire(self._stop_agent_off_loop(), + "stopping the bridge agent after the outstanding un-export") def _report_node_warnings(self, status) -> None: """Say what the node could not persist (§4.3 ``warnings``). @@ -1135,18 +1316,96 @@ def _on_drift_detected(self, drift: list) -> None: "swapped identities in paired ecosystems. This is never repaired automatically.", ", ".join(f"{d.unique_id}: expected {d.expected}, got {d.actual}" for d in drift)) + # ------------------------------------------------------------------ + # §5 pairing activity — the events that make the bridge's own state visible + # ------------------------------------------------------------------ + def _on_fabrics_changed(self, fabrics: list, change: str) -> None: + """An ecosystem was added, removed or renamed (§5 ``fabrics_changed``). + + Surfaced at INFO rather than debug, and unlatched, because every one of + these is a discrete user-visible act — somebody paired Apple Home, + somebody's Alexa dropped us — and there is no polling loop that would + otherwise notice. The node emits it for the changes §3.9/§3.10 cause + themselves as well as for ecosystem-originated ones, so this is also the + acknowledgement the unpair menu reports against. + """ + self.fabrics = list(fabrics) + described = ", ".join(_describe_fabric(fabric) for fabric in fabrics) or "none" + self._logger.info( + "Matter export: the bridge node's paired ecosystems changed (%s) — now paired with: %s", + change or "changed", described) + + def _on_commissioned(self) -> None: + """First fabric (§5 ``commissioned``) — a transition, not a repeat.""" + self._logger.info( + "Matter export: the Matter bridge has been PAIRED for the first time. Exported " + "accessories should now appear in that ecosystem. To add a second ecosystem, use " + "Plugins ▸ Matter ▸ Pair Matter Bridge… — the original pairing code no longer works.") + + def _on_decommissioned(self) -> None: + """Last fabric gone (§5 ``decommissioned``). + + Worth a warning rather than an info: the fabric set emptying makes + matter.js factory-reset itself, so every exported accessory has just + disappeared from everywhere — and if the user did not do it deliberately + (an ecosystem removed *us*), this line is the only notice they get. + """ + self.fabrics = [] + self.window_expires_at = None + self._logger.warning( + "Matter export: the Matter bridge is no longer paired with ANY ecosystem. Every " + "exported accessory has gone with the last fabric. Indigo devices are unaffected; " + "use Plugins ▸ Matter ▸ Pair Matter Bridge… to pair it again.") + + def _on_window_closed(self, reason: str) -> None: + """The commissioning window ended (§5 ``window_closed``).""" + self.window_expires_at = None + if reason == "commissioned": + self._logger.info("Matter export: the pairing window closed — an ecosystem completed " + "commissioning.") + return + self._logger.info( + "Matter export: the pairing window has expired without an ecosystem completing " + "commissioning. Open a new one with Plugins ▸ Matter ▸ Pair Matter Bridge… — the " + "code it showed is now dead.") + + def note_window_opened(self, expires_at: str) -> None: + """Record a window the pairing menu just opened, for the §5.5 readout.""" + self.window_expires_at = expires_at + def _on_unreachable(self, attempts: int) -> None: - """The node is not answering. In E3 that usually means it is not running.""" + """The node is not answering — and since E7 the agent is asked why. + + The diagnosis matters more here than for the controller, because the + bridge agent is started and stopped by the allow-list rather than at + plugin startup: "not running" can mean launchd never got it up, that it + is crash-looping on a bound Matter port, or that its package was never + installed. All three present identically at the socket as "connection + refused". The seam returns the agent's own error-log tail; a failure + inside it is contained there and costs only the extra sentence. + """ if self._unreachable_reported: return self._unreachable_reported = True self._logger.warning( "Matter export: the bridge node is not responding after %d attempts on port %s. " - "In this build the node is started by hand — check it is running. Indigo devices " - "are unaffected; exported accessories will show as unavailable.", + "Indigo devices and inbound Matter control are unaffected; exported accessories " + "will show as unavailable.%s", attempts, str(self._prefs_getter().get(bridge_protocol.PREF_WS_PORT) - or bridge_protocol.DEFAULT_WS_PORT)) + or bridge_protocol.DEFAULT_WS_PORT), + self._agent_diagnosis()) + + def _agent_diagnosis(self) -> str: + """Ask the agent seam why the node is quiet. Never raises; may be empty.""" + if self._agent_diagnose is None: + return "" + try: + detail = self._agent_diagnose() + except Exception as exc: # pylint: disable=broad-except + self._logger.debug("Matter export: the bridge agent diagnostic failed (%s)", exc) + return "" + return f" {detail}" if detail else "" # ------------------------------------------------------------------ # Watchdog @@ -1298,6 +1557,39 @@ def _log_future(self, future, what: str) -> None: self._logger.warning("Matter export: %s failed — %s", what, exc) +#: Matter vendor IDs whose ecosystems a user is likely to recognise. Purely +#: cosmetic: a fabric's own ``label`` is whatever the commissioner wrote there, +#: which for Apple is a UUID-ish string that tells nobody anything. Unknown ids +#: are rendered as hex, never guessed at. +VENDOR_NAMES = { + 0x1349: "Apple", + 0x100B: "Google", + 0x1217: "Amazon", + 0x1075: "SmartThings", + 0xFFF1: "test vendor", +} + + +def _describe_fabric(fabric: Any) -> str: + """One fabric as a human would name it: ``Apple (index 1)``. + + The index is always shown because it is what §3.9 removes a fabric BY, so a + user reading the log and a user picking from the unpair menu are looking at + the same identifier. + """ + vendor_id = int(getattr(fabric, "vendor_id", 0) or 0) + name = VENDOR_NAMES.get(vendor_id) or f"vendor 0x{vendor_id:04X}" + label = str(getattr(fabric, "label", "") or "").strip() + index = getattr(fabric, "fabric_index", "?") + return f"{name} (index {index})" if not label else f"{name} — {label} (index {index})" + + +def describe_fabric(fabric: Any) -> str: + """Public alias of :func:`_describe_fabric` — the menu and the config readout + render fabrics the same way the log does, so a user matches one to the other.""" + return _describe_fabric(fabric) + + def reachable_of(dev: Any) -> bool: """§4.1 ``reachable`` for an Indigo device (XAC8). diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/launch_agent.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/launch_agent.py index 8a4645a..405297f 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/launch_agent.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/launch_agent.py @@ -47,13 +47,16 @@ # DELIBERATELY SHARED between agents (not per-label): project_dir holds ONE node_modules # installed by ONE node, and every agent's LaunchAgent runs that same node. A per-agent # stamp would claim they can diverge, which is exactly the ABI crash this guards against. -# CAVEAT the sharing does not cover: if nodeBinDir is repointed BETWEEN two agents' -# installs, the later install rewrites the stamp with the new node and the earlier -# agent's already-built native bindings go unwarned (npm install of package B does not -# rebuild package A's bindings). Single-agent today this cannot happen (its own install -# always rewrites the stamp). TODO(E7): when the bridge agent lands, either record -# per-package node versions inside the one stamp or re-install both packages on a -# nodeBinDir change. +# CAVEAT the sharing does not cover, REVIEWED AT E7 AND KEPT: if nodeBinDir is repointed +# BETWEEN two agents' installs, the later install rewrites the stamp with the new node and +# the earlier agent's already-built native bindings go unwarned (npm install of package B +# does not rebuild package A's bindings). Kept shared because the alternative — per-package +# versions inside one stamp — would make `abi_warning` claim the two agents CAN legitimately +# run on different nodes, which is the opposite of true: both LaunchAgents run whatever +# single node this plugin resolved, so a per-package stamp that disagreed with the other +# would be describing a state that cannot exist. The residual risk is one stale ADVISORY +# warning after a manual nodeBinDir change, and the remedy is in the message the warning +# already prints: run both Install/update menu actions. INSTALL_NODE_STAMP = ".indigo-node" @@ -726,25 +729,38 @@ def uninstall(self) -> None: pass def remove_package(self) -> None: - """Delete the installed npm package for a clean reinstall. + """Uninstall THIS agent's npm package for a clean reinstall. Stops the managed job and reaps any orphan first (so nothing holds the files or - the storage lock), then removes ``node_modules`` and ``package-lock.json`` under - project_dir and drops the applied-plist marker so the next ensure_installed - re-bootstraps. The storage dir is SACRED and never touched — commissioned devices - and pairings survive a clean reinstall. Blocking; run off the Indigo main thread. + the storage lock), then removes the package, then drops this agent's + applied-plist marker so the next ensure_installed re-bootstraps. The storage dir + is SACRED and never touched — commissioned devices and pairings survive a clean + reinstall. Blocking; run off the Indigo main thread. + + **Per-package since E7, and it had to become so.** This used to ``rmtree`` the + whole shared ``node_modules`` and delete ``package-lock.json``, which was + tolerable while exactly one agent existed and destructive the moment a second one + did: the sibling's package vanished underneath a launchd job that was still + loaded and still pointing at it, so the next respawn crash-looped on + module-not-found, its applied marker still matched, and nothing in the plugin + ever said why. The lock file goes the same way — it describes the whole install + root, not one package, so deleting it on behalf of one agent unpins the other's + transitive dependency tree at its next install. + + ``npm uninstall`` is preferred over deleting the directory because it is the only + thing that also prunes the transitive dependencies this package brought in and + nothing else needs — matter.js is ~40MB of them. Removing the package directory + is the fallback for a project dir npm cannot operate on at all. + + **The trade this makes explicit:** wiping ``node_modules`` wholesale also fixed a + corrupt *shared* dependency, and this no longer does. That was never what the + menu action claimed to do, and rebuilding a sibling agent's install as a side + effect of recovering this one is worse than the fault it happened to cure. """ self._bootout() self.reap_orphan_servers() - for name in ("node_modules", "package-lock.json"): - target = os.path.join(self.project_dir, name) - try: - if os.path.isdir(target): - shutil.rmtree(target) - elif os.path.exists(target): - os.remove(target) - except OSError as exc: - self.logger.warning("could not remove %s: %s", target, exc) + if not self._npm_uninstall(): + self._remove_package_dir() try: os.remove(self._applied_marker_path()) except OSError: @@ -752,6 +768,50 @@ def remove_package(self) -> None: self.logger.info("Removed the %s package under %s (storage left intact)", self.spec.package, self.project_dir) + def _npm_uninstall(self) -> bool: + """``npm uninstall`` this agent's package. True if npm reported success. + + Returns False — quietly, at debug — when npm is absent or refuses, because the + caller has a working fallback and a warning here would name a problem the user + does not have. + """ + npm = os.path.join(self.resolved_bin_dir, "npm") + if not self._exists(npm): + self.logger.debug("npm not found at %s; removing the package directory instead", npm) + return False + env = dict(os.environ) + env["PATH"] = self.resolved_bin_dir + os.pathsep + env.get("PATH", "") + try: + result = self._run([npm, "uninstall", "--prefix", self.project_dir, self.spec.package], + capture_output=True, text=True, check=False, env=env) + except OSError as exc: + self.logger.debug("npm uninstall %s could not start (%s)", self.spec.package, exc) + return False + if result is None or result.returncode != 0: + detail = "" if result is None else (result.stderr or result.stdout or "").strip() + self.logger.debug("npm uninstall %s exited non-zero (%s); removing the package " + "directory instead", self.spec.package, detail[:500]) + return False + return True + + def _remove_package_dir(self) -> None: + """Delete ``node_modules/`` and nothing else. + + The fallback when npm cannot run. Scoped to this agent's own directory: the + sibling's package, the shared transitive dependencies it may also be using, and + ``package-lock.json`` are all left alone. Some of this package's own transitive + deps are therefore orphaned in ``node_modules`` — harmless, and the next + ``npm install`` reconciles them. + """ + target = os.path.join(self.project_dir, "node_modules", self.spec.package) + try: + if os.path.isdir(target): + shutil.rmtree(target) + elif os.path.exists(target): + os.remove(target) + except OSError as exc: + self.logger.warning("could not remove %s: %s", target, exc) + def stop(self) -> bool: """Stop the agent (bootout) but keep the plist so ``start`` can reload it. diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py index 3d44873..a94e05c 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py @@ -19,15 +19,19 @@ from concurrent.futures import CancelledError as FuturesCancelledError from concurrent.futures import TimeoutError as FuturesTimeoutError from datetime import datetime, timezone -from typing import Optional +from typing import Any, Optional +from urllib.parse import quote import indigo # provided by the Indigo runtime +import bridge_agent import bridge_client +import bridge_protocol import fabric_backup from async_runtime import AsyncRuntime from commission_jobs import CommissionJobs, node_id_to_str from device_sync import DeviceSync +import export_bridge from export_bridge import ExportBridge import export_catalog import export_handlers @@ -127,6 +131,89 @@ def sanitize_host(raw: str) -> str: return host +#: Where the raw ``MT:`` payload can be rendered as a scannable QR code. The +#: CHIP project's own tool, which is the reference implementation of the payload +#: format — so a code it cannot render is a code no commissioner would accept +#: either. Linked rather than embedded: see :meth:`Plugin._pairing_page` for why +#: no QR is generated locally. +QR_VIEWER_URL = "https://project-chip.github.io/connectedhomeip/qrcode.html" + + +def _escape(text: Any) -> str: + """Minimal HTML escaping for the pairing page. + + Hand-rolled rather than ``html.escape`` only in that it also handles a + ``None`` — every value on that page comes from the bridge node or from an + exception string, and one of them being absent must not render the word + "None" into a field a user is about to type into their phone. + """ + if text is None: + return "" + return (str(text).replace("&", "&").replace("<", "<").replace(">", ">") + .replace('"', """)) + + +def _pairing_html(pairing, message: str) -> str: + """The pairing page (PRD §6). Self-contained: no scripts, no assets. + + ``pairing`` may be ``None`` when there is nothing to report — the page still + renders, carrying ``message``, because a blank page over a bridge that is + merely not running is indistinguishable from a broken handler. + """ + manual = _escape(getattr(pairing, "manual_pairing_code", None)) + qr_payload = _escape(getattr(pairing, "qr_pairing_code", None)) + expires = _escape(getattr(pairing, "window_expires_at", None)) + fabrics = list(getattr(pairing, "fabrics", ()) or []) + paired = ", ".join(_escape(export_bridge.describe_fabric(f)) for f in fabrics) or "none yet" + banner = f'

{_escape(message)}

' if message else "" + codes = "" + if manual: + # The payload is URL-encoded into the viewer link because an `MT:` string + # is base-38 and can legitimately contain characters that would otherwise + # end the query (`+`, `/`, `%`), producing a link that opens the tool with + # a silently truncated payload — a QR that scans and means the wrong thing. + viewer = f"{QR_VIEWER_URL}?data={quote(str(getattr(pairing, 'qr_pairing_code', '') or ''), safe='')}" + codes = f""" +

Manual pairing code

+

{manual}

+

QR payload

+

{qr_payload}

+

+ Render this payload as a scannable QR code (opens the Matter project's own + viewer — it needs internet access, and the payload is sent to it).

+ {f'

This code stops working at {expires}.

' if expires else ''} +

What to expect

+

Add the bridge in your ecosystem's app as you would any Matter accessory. Every + ecosystem will warn that it is an uncertified accessory — that is + normal for a bridge like this one, and the same warning Homebridge and Home Assistant + produce. Choose "Add Anyway".

""" + return f""" + + +Indigo Matter bridge — pairing + +

Indigo Matter bridge

+{banner}{codes} +
Paired ecosystems: {paired}.
+This page is served by the Indigo Web Server from the Matter plugin.
+""" + + class Plugin(indigo.PluginBase): """Matter plugin entry point.""" @@ -144,6 +231,11 @@ def __init__(self, plugin_id, plugin_display_name, plugin_version, plugin_prefs, self.jobs: CommissionJobs | None = None self.http: HttpApi | None = None self.server_process: ServerProcess | None = None + # The EXPORT bridge node's LaunchAgent (E7). Built lazily and only ever + # by a path that means to run it: a fresh install must be inert (XAC1), + # and constructing this in startup would be one `ensure_installed` away + # from a plist for a bridge nobody has asked for. + self.bridge_process = None # The export allow-list (PRD §5.1). Built in startup, before anything # can consult it; None means "the plugin has not started yet", which # every export callback checks rather than assuming. @@ -277,6 +369,13 @@ def startup(self) -> None: # disk to be worth writing: the failure it covers is the plugin # never getting another chance to say it. save_prefs=self._save_plugin_prefs, + # E7's LaunchAgent seams. Passed unconditionally — the bridge is + # gated by the ALLOW-LIST, not by startup, so handing them over here + # installs nothing (XAC1). ExportBridge calls them on the + # empty↔non-empty transitions and nowhere else. + agent_start=self._start_bridge_agent, + agent_stop=self._stop_bridge_agent, + agent_diagnose=self._bridge_agent_diagnosis, ) self._exports_changed() @@ -661,6 +760,52 @@ def validatePrefsConfigUi(self, valuesDict): # noqa: N802 return (False, valuesDict, errors) return (True, valuesDict) + def getPrefsConfigUiValues(self): # noqa: N802 + """Seed the plugin config dialog, computing the PRD §5.5 export readout. + + Indigo has no dynamic labels, so the readout is a **read-only textfield** + written here — the same shape the export dialog's status line already + uses. Every value comes from state the plugin already holds (the last + attach or 15-second status poll, and the §5 fabric events); nothing here + does I/O, because this runs while the dialog is opening. + """ + values = indigo.Dict(self.pluginPrefs) + values["exportReadout"] = self._export_readout() + return (values, indigo.Dict()) + + # Indigo 2025.2's PluginBase carries snake_case aliases for the ConfigUI + # pre-population callbacks alongside the camelCase names, and which one it + # dispatches on is not documented. Both are defined so the readout cannot be + # silently empty on a build that prefers the other spelling. + def get_prefs_config_ui_values(self): + return self.getPrefsConfigUiValues() + + def _export_readout(self) -> str: + """One line describing the export bridge for the config dialog (PRD §5.5). + + What the PRD asks for is *which* ecosystems hold a fabric and whether a + window is open — not slot arithmetic. matter.js allows 254 fabrics, so + the count is never the interesting number; the identity of the peers is, + because "why has Alexa stopped working" is answered by seeing that Alexa + is not in this list. + """ + bridge = self.export_bridge + if bridge is None: + return "Plugin still starting." + exported = len(self.exports) if self.exports is not None else 0 + if not bridge.enabled: + return f"Export is switched off. {exported} device(s) would be exported." + if not bridge.active: + return (f"{exported} device(s) exported; the bridge node is not running." + if exported else "Nothing is exported yet, so the bridge node is not running.") + fabrics = bridge.fabrics + if fabrics is None: + return f"{exported} device(s) exported; not yet connected to the bridge node." + paired = ", ".join(export_bridge.describe_fabric(f) for f in fabrics) or "nothing yet" + window = (f" A pairing window is open until {bridge.window_expires_at}." + if bridge.window_expires_at else "") + return f"{exported} device(s) exported. Paired with: {paired}.{window}" + def closedPrefsConfigUi(self, valuesDict, userCancelled): # noqa: N802 if userCancelled: return @@ -671,6 +816,16 @@ def closedPrefsConfigUi(self, valuesDict, userCancelled): # noqa: N802 "matter-server settings saved — reload the plugin (or Plugins ▸ Matter ▸ " "Restart matter-server) to apply them" ) + # Export is the exception: its switch and its ports are read on every + # connect, and the ONE change a user expects to act immediately is + # ticking or unticking "Enable Matter export". Re-running the transition + # applies it without a reload — and, because the transition is the same + # code the allow-list uses, it also brings the agent up or down. + if self.export_bridge is not None: + try: + self.export_bridge.exports_changed() + except Exception as exc: # noqa: BLE001 + self.logger.exception(exc) # ------------------------------------------------------------------ # Device lifecycle @@ -2030,6 +2185,432 @@ def menuResetBridgePairings(self, valuesDict, menuId=""): # noqa: N802, ARG002 "bridge from any ecosystem that still lists it.") return (True, valuesDict) + # ------------------------------------------------------------------ + # The export bridge node's LaunchAgent (E7 — PRD §4.2, XG5, XAC1) + # ------------------------------------------------------------------ + def _bridge_agent(self): + """The bridge :class:`~bridge_agent.BridgeProcess`, built on first use. + + Cached on the plugin so the applied-plist digest and the resolved node + path are computed once, and rebuilt from CURRENT prefs by the two paths + that change them (the install action and a config save) — the same + discipline ``menuRestartMatterServer`` learned the hard way, where a + ``ServerProcess`` snapshotted at startup silently served stale settings. + + Constructing one writes nothing. That is what lets this be lazy at all + and what keeps a fresh install inert (XAC1). + """ + if self.bridge_process is None: + self.bridge_process = bridge_agent.BridgeProcess(dict(self.pluginPrefs), self.logger) + return self.bridge_process + + def _start_bridge_agent(self) -> None: + """Install (if needed) and start the bridge node's LaunchAgent. + + The ``agent_start`` seam. Called by :class:`export_bridge.ExportBridge` + on the empty→non-empty allow-list transition, on whichever Indigo thread + made that change — never on the loop. Blocking, but only by a couple of + ``launchctl`` calls; ``install`` is deliberately NOT attempted here (npm + takes a minute and this can run from ``deviceDeleted``), so a missing + package surfaces as ``ensure_installed``'s actionable preflight error + naming the install menu. + + Rebuilt from current prefs on every call rather than cached: the ports + and the mDNS interface are prefs, ``ensure_installed`` only reloads + launchd when the resulting plist actually differs, and a stale + ``BridgeProcess`` would write yesterday's ports while reporting success. + """ + self.bridge_process = bridge_agent.BridgeProcess(dict(self.pluginPrefs), self.logger) + agent = self.bridge_process + if agent.ensure_installed() is None: + # Preflight failed; the plist has been torn down and the reason + # logged. Nothing to start, and starting would only crash-loop. + return + self.logger.info("Matter export: bridge node LaunchAgent is running (protocol port %s, " + "Matter port %s)", agent.ws_port, agent.matter_port) + + def _stop_bridge_agent(self) -> None: + """Stop the bridge agent, keeping its plist and its storage. + + The ``agent_stop`` seam, called only once there is genuinely nothing to + serve. ``stop()`` rather than ``uninstall()``: the plist is cheap to keep + and re-exporting a device should not have to re-derive it, and the + storage dir — every ecosystem pairing plus the endpoint-number witness — + is never touched by either. + """ + agent = self.bridge_process + if agent is None: + return + if agent.stop(): + self.logger.info("Matter export: nothing is exported — the bridge node has been " + "stopped. Its pairings are kept.") + + def _bridge_agent_diagnosis(self) -> Optional[str]: + """Why is the bridge node not answering? The ``agent_diagnose`` seam. + + Reads the agent's own error log, which is the only place the real cause + appears: a Matter port already bound by another stack (PRD §7), a package + that was never installed, an ABI mismatch. The socket sees "connection + refused" for all of them. + + **Read-only.** It deliberately does not restart anything. launchd already + owns respawn via ``KeepAlive``, the loaded-but-dead revival lives in + ``ensure_installed``, and a diagnostic that quietly bounced the agent on + every failure streak would turn a crash-loop into a crash-loop nobody can + read the log of. + """ + agent = self.bridge_process + if agent is None: + return ("The bridge node's LaunchAgent has not been started by this plugin session — " + "export at least one device, or reload the plugin.") + tail = agent.tail_error_log() + if tail: + return f"Recent bridge node errors:\n{tail}" + return (f"Its error log is empty — the {bridge_agent.BRIDGE_PACKAGE} package may not be " + f"installed (checked {agent.project_dir}). Use Plugins ▸ Matter ▸ " + f"Install/update the Matter export bridge.") + + def menuInstallBridgeNode(self): # noqa: N802 + """Install/update the ``indigo-matter-bridge`` npm package. + + The export-side twin of ``menuInstallMatterServer``, and a sibling rather + than an extension of it: the two agents are separately versioned, and a + user recovering a wedged bridge must not also be made to reinstall a + controller that is working (or the reverse). They share the install + thread because ``~/indigo-matter`` is one npm root and two concurrent + ``npm install``s into it corrupt each other. + """ + if self._install_thread is not None and self._install_thread.is_alive(): + self.logger.warning("An npm install is already in progress — wait for it to finish.") + return + self.logger.info("Installing the Matter export bridge node in the background — watch the " + "log for progress; this can take a minute.") + self._install_thread = threading.Thread( + target=self._install_bridge_node, name="matter-bridge-install", daemon=True) + self._install_thread.start() + + def _install_bridge_node(self) -> None: + """npm-install the bridge package, then restart it if anything is exported. + + The restart is conditional on there being something to export, which is + the difference from the controller's install: bringing the agent up + because a package was updated would violate XG5 on an install with an + empty allow-list, and leave a bridge process running for nothing. + """ + try: + agent = bridge_agent.BridgeProcess(dict(self.pluginPrefs), self.logger) + if not agent.install(): + self.logger.error( + "Install/update of the Matter export bridge did not complete — see the error " + "above. Nothing was changed; retry when resolved.") + return + if self._stopping: # plugin is tearing down — don't mutate its state + return + self.bridge_process = bridge_agent.BridgeProcess(dict(self.pluginPrefs), self.logger) + if self.exports is None or not len(self.exports): + self.logger.info( + "Matter export bridge installed. It is NOT being started: nothing is exported " + "yet, and the bridge only runs while the export list is non-empty. Add a " + "device in 'Manage Matter Exports…' and it will start itself.") + return + # A running LaunchAgent does not pick up new files on disk, so the + # freshly-installed version only takes effect on a real restart. + if not self.bridge_process.restart(): + self.logger.error( + "The Matter export bridge was installed but the restart onto the new version " + "FAILED — the old version may still be running. Check " + "~/Library/Logs/indigo-matter/%s.", bridge_agent.BRIDGE_ERR_LOG) + return + self.logger.info("Matter export bridge installed and restarted onto the new version — " + "the plugin reconnects automatically.") + except Exception as exc: # noqa: BLE001 + self.logger.exception(exc) + self.logger.error( + "Install of the Matter export bridge did not complete after the npm step — the " + "package may be installed but the agent was not restarted. See the trace above, " + "then retry Plugins ▸ Matter ▸ Install/update the Matter export bridge.") + + # ------------------------------------------------------------------ + # Pairing and fabric management (PRD §6, BRIDGE_PROTOCOL §3.7-§3.9) + # ------------------------------------------------------------------ + def _pairing_client(self, errors, field: str): + """The bridge client for a pairing action, or ``None`` with ``errors`` set. + + ``connected`` rather than ``attached``, for the same §1.1 reason the + recovery menus use it: the node answers ``get_pairing`` while refusing to + serve endpoints, and a user whose bridge is in that state still needs to + be able to see and manage their pairings. + + The message names the real precondition, which is not obvious: the client + exists only while something is exported (XG5), so "pair the bridge" is + genuinely unreachable until the user has exported a device. That is XAC2's + ordering, not an accident — a bridge with no accessories is nothing worth + pairing, and Apple Home would show an empty one. + """ + bridge = self.export_bridge + client = bridge.client if bridge is not None else None + if client is None or not client.connected: + exported = len(self.exports) if self.exports is not None else 0 + why = ("Export at least one device in 'Manage Matter Exports…' first — the bridge only " + "runs while something is exported." if not exported else + "The bridge node is not answering; see the log for what its own error log says.") + self.logger.warning("Matter export: cannot reach the bridge node for pairing. %s", why) + errors[field] = "Not connected to the bridge node — see the log." + return None + return client + + def menuPairMatterBridge(self, valuesDict, menuId=""): # noqa: N802, ARG002 + """Open a pairing window and put the codes where the user can read them. + + **Why this is "open a window" and not "show the code" (PRD §6).** A Matter + commissioning passcode is not durable: the moment the first ecosystem + commissions, the basic window closes and the original code stops working. + Every ecosystem after that needs an *enhanced* window with a freshly + derived code (§3.8), so there is no such thing as "the" pairing code to + display. + + **Why the event log.** Indigo dialogs have no dynamic labels and no image + fields, so a runtime string cannot be shown in the dialog that produced + it. The log is this plugin's established channel for exactly that, and it + is also the one place the codes survive being scrolled past — a window + lasts up to 15 minutes and users do not type 11 digits first time. + """ + errors = indigo.Dict() + duration = self._window_duration(valuesDict, errors) + if duration is None: + return (False, valuesDict, errors) + client = self._pairing_client(errors, "duration") + if client is None: + return (False, valuesDict, errors) + try: + pairing = self.runtime.submit(client.get_pairing()).result(timeout=15) + except Exception as exc: # noqa: BLE001 + self.logger.error("Matter export: could not read the bridge node's pairing state — %s. " + "No pairing window was opened.", exc) + self.logger.exception(exc) + errors["duration"] = "Could not reach the bridge node — see the log." + return (False, valuesDict, errors) + # Two states already have a usable code, and opening a window in either + # would be actively harmful: §3.8's `assertClosed` refuses a second one, + # and on a never-commissioned node the basic window is ALREADY open with + # the persisted originals (§3.7) — deriving a fresh enhanced code there + # would invalidate a code the user may already be typing. + if pairing.window_open and pairing.manual_pairing_code: + self._log_pairing_codes(pairing.manual_pairing_code, pairing.qr_pairing_code, + pairing.window_expires_at, + already_open=not pairing.commissioned) + return (True, valuesDict) + try: + window = self.runtime.submit( + client.open_commissioning_window(duration)).result(timeout=45) + except Exception as exc: # noqa: BLE001 + self.logger.error( + "Matter export: opening a pairing window FAILED — %s. Nothing was changed and " + "existing pairings are untouched. If the bridge says a window is already open, " + "wait for it to expire (up to 15 minutes) and try again.", exc) + self.logger.exception(exc) + errors["duration"] = "Could not open a pairing window — see the log." + return (False, valuesDict, errors) + if self.export_bridge is not None: + self.export_bridge.note_window_opened(window.window_expires_at) + self._log_pairing_codes(window.manual_pairing_code, window.qr_pairing_code, + window.window_expires_at, already_open=False) + return (True, valuesDict) + + @staticmethod + def _window_duration(values_dict, errors) -> Optional[int]: + """Validate the duration field against §3.8's 180-900s band. + + Rejected in the dialog rather than clamped silently by the node: the + number is how long the user has to walk to another room with a phone, and + being given 900 when they asked for 60 is a difference they should be + told about while the dialog is still open. + """ + raw = str((values_dict or {}).get("duration", "") or "").strip() + if not raw: + return bridge_protocol.DEFAULT_WINDOW_SECONDS + try: + duration = int(raw) + except (TypeError, ValueError): + errors["duration"] = "Enter a whole number of seconds between 180 and 900." + return None + if not 180 <= duration <= 900: + errors["duration"] = "Matter allows 180 to 900 seconds (3 to 15 minutes)." + return None + return duration + + def _log_pairing_codes(self, manual: Optional[str], qr: Optional[str], + expires_at: Optional[str], *, already_open: bool) -> None: + """Write the codes, the expiry and the QR page URL to the event log.""" + when = f" It expires at {expires_at}." if expires_at else "" + opening = ("The bridge has never been paired, so it is ALREADY advertising with its " + "original code — no new window was opened." if already_open else + "A pairing window is now open.") + self.logger.info( + "Matter export — %s%s\n" + " Manual pairing code: %s\n" + " QR payload: %s\n" + " QR code page: %s\n" + "Add the bridge in your ecosystem's app as you would any Matter accessory, and type " + "the manual code if it asks for one. Expect an 'uncertified accessory' warning — that " + "is normal for a bridge like this one; choose Add Anyway.", + opening, when, manual or "(none)", qr or "(none)", self._pairing_page_url()) + + def _pairing_page_url(self) -> str: + """The IWS URL of the QR page (Actions.xml ``pairing``). + + ``getWebServerURL`` picks the reflector, then the Bonjour name, then + localhost — so this is reachable from the phone the user is holding + whenever a reflector or a ``.local`` name exists, which is the case the + page is FOR. A failure falls back to the loopback default rather than + omitting the line: a wrong-host URL a user can edit beats no URL. + """ + base = "http://localhost:8176" + try: + base = str(indigo.server.getWebServerURL() or base) + except Exception as exc: # noqa: BLE001 + self.logger.debug("could not resolve the Indigo web server URL (%s)", exc) + return f"{base}/message/{self._export_plugin_id()}/pairing/" + + def getBridgeFabrics(self, filter="", valuesDict=None, typeId="", targetId=0): + # pylint: disable=redefined-builtin, unused-argument + """Picker rows for the unpair menu: one per commissioned ecosystem. + + Built from the fabric set the bridge already reported (attach, then every + §5 ``fabrics_changed``), never from a fresh WS round trip: a dynamic list + callback runs on the Indigo UI's thread while the dialog is opening, and + blocking it on a node that may be down would hang the dialog rather than + render an empty one. + """ + try: + bridge = self.export_bridge + fabrics = bridge.fabrics if bridge is not None else None + if not fabrics: + # None and [] are different facts, and both are un-pickable, but + # only one of them should read as "you are not paired". + return [(NO_SELECTION_ID, + "(no paired ecosystems)" if fabrics == [] + else "(not connected to the bridge node)")] + return [(str(fabric.fabric_index), export_bridge.describe_fabric(fabric)) + for fabric in fabrics] + except Exception as exc: # pylint: disable=broad-except + self.logger.exception(exc) + return [LIST_ERROR_OPTION] + + def menuUnpairEcosystem(self, valuesDict, menuId=""): # noqa: N802, ARG002 + """§3.9 — remove one ecosystem's fabric from the bridge. + + Two gates, like the reset menu, because the outcome is the same size for + the ecosystem being removed: every accessory Indigo exports disappears + from it, with the names, rooms and automations built on them. + """ + errors = indigo.Dict() + selected = str(valuesDict.get("fabric", "") or "") + if not selected or selected == NO_SELECTION_ID: + errors["fabric"] = "Select an ecosystem to unpair." + return (False, valuesDict, errors) + try: + fabric_index = int(selected) + except (TypeError, ValueError): + errors["fabric"] = "Invalid selection." + return (False, valuesDict, errors) + if not self._truthy(valuesDict.get("confirm")) \ + or not self._truthy(valuesDict.get("confirmAgain")): + field = "confirm" if not self._truthy(valuesDict.get("confirm")) else "confirmAgain" + errors[field] = "Tick BOTH boxes — this removes every exported accessory from that "\ + "ecosystem." + return (False, valuesDict, errors) + client = self._pairing_client(errors, "confirmAgain") + if client is None: + return (False, valuesDict, errors) + last = self._is_last_fabric(fabric_index) + try: + self.runtime.submit(client.remove_fabric(fabric_index)).result(timeout=45) + except Exception as exc: # noqa: BLE001 + self.logger.error("Matter export: unpairing ecosystem %s FAILED — %s. Pairings are " + "unchanged.", fabric_index, exc) + self.logger.exception(exc) + errors["confirmAgain"] = "Unpair failed — see the log. Nothing was changed." + return (False, valuesDict, errors) + if last: + # §3.9: matter.js factory-resets itself when the fabric set empties, + # and the node clears its commissioning witness to match. Say what + # that actually means rather than reporting a routine removal, because + # the user has just reset the whole bridge without using the reset menu. + self.logger.warning( + "Matter export: ecosystem %s was the LAST one paired, so the bridge node has " + "reset itself and is advertising for commissioning again — exactly as 'Reset " + "Matter Export Pairings…' would have done. Nothing in Indigo changed. Use " + "'Pair Matter Bridge…' to pair it again.", fabric_index) + else: + self.logger.warning( + "Matter export: ecosystem %s has been unpaired. Every accessory Indigo exports " + "has been removed from it; remove any leftover 'Indigo' bridge entry in that " + "ecosystem's app by hand.", fabric_index) + return (True, valuesDict) + + def _is_last_fabric(self, fabric_index: int) -> bool: + """Whether removing ``fabric_index`` empties the fabric set. + + Read BEFORE the removal, from the set the bridge last reported: the §5 + ``fabrics_changed`` that follows arrives asynchronously, so asking + afterwards races it. Unknown (nothing reported yet) reads as False — + the message it selects is only the difference between two warnings. + """ + bridge = self.export_bridge + fabrics = bridge.fabrics if bridge is not None else None + if not fabrics: + return False + return [f.fabric_index for f in fabrics] == [fabric_index] + + # ------------------------------------------------------------------ + # The QR page (IWS hidden action — PRD §6 "display mechanism") + # ------------------------------------------------------------------ + def http_pairing(self, action, dev=None, caller_waiting_for_result=None): # noqa: N802, ARG002 + """Serve the pairing page. GET only; authenticated by IWS before we run. + + This is the *only* handler here that returns HTML rather than JSON, and + it exists because the one thing the event log cannot carry is a QR code. + """ + method, _path_args, _query = self._parse_request(action) + if method.upper() != "GET": + return self._reply(405, {"error": "method_not_allowed"}) + reply = indigo.Dict() + reply["status"] = 200 + reply["headers"] = indigo.Dict({"Content-Type": "text/html; charset=utf-8"}) + reply["content"] = self._pairing_page() + return reply + + def _pairing_page(self) -> str: + """Build the pairing page's HTML from a live ``get_pairing``. + + **No QR is generated here, and that is a deliberate choice.** Rendering + one needs either a Python dependency (Indigo's framework Python has no + image stack and this plugin ships none) or a hand-written JS encoder — + a few hundred lines of Reed-Solomon and bit-masking whose failure mode is + a plausible-looking square that no phone can read. Neither is worth it + for a code that Apple Home, Alexa and Google all accept *typed in*: the + page therefore shows the manual code at a size you can read across a + room, the raw ``MT:`` payload for copying, and a link to the CHIP + project's own QR viewer for anyone who wants to scan. The tradeoff is + recorded in ``docs/HANDOVER.md`` rather than only in this docstring. + """ + client = self.export_bridge.client if self.export_bridge is not None else None + if client is None or not client.connected: + return _pairing_html(None, "The plugin is not connected to the Matter bridge node. " + "Export at least one device, then reload this page.") + try: + pairing = self.runtime.submit(client.get_pairing()).result(timeout=15) + except Exception as exc: # noqa: BLE001 + self.logger.exception(exc) + return _pairing_html(None, f"Could not read the bridge node's pairing state: {exc}") + if not pairing.manual_pairing_code: + return _pairing_html( + pairing, + "No pairing window is open, so there is no code to show. Open one with " + "Plugins ▸ Matter ▸ Pair Matter Bridge… in Indigo.") + return _pairing_html(pairing, "") + def _resolve_storage_path(self) -> str: """Storage dir path in BOTH managed and manual modes. @@ -2045,16 +2626,14 @@ def _resolve_storage_path(self) -> str: def _bridge_storage_path(self) -> str: """The **export** bridge node's storage dir — sibling of the controller's. - Hard-coded relative to the controller's rather than read from a pref - because there is no pref: E3's node is started by hand and E7 owns the - agent that will have one. The path is the PRD §4.3 default - (``…/com.simons-plugins.indigo-matter/bridge-node``), which is also - `bridge-node/src/config.ts`'s `DEFAULT_STORAGE_PATH`. A user who moved - it gets a backup without those members and a log line saying so, which - is the same outcome as not having exported anything. + Derived rather than read from a pref, and derived by the module that also + hands it to the agent as ``--storage-path`` (E7), so the directory this + backs up and the directory the node actually writes cannot disagree. The + path is the PRD §4.3 default (``…/com.simons-plugins.indigo-matter/ + bridge-node``), which is also ``bridge-node/src/config.ts``'s + ``DEFAULT_STORAGE_PATH``. """ - controller = os.path.normpath(self._resolve_storage_path()) - return os.path.join(os.path.dirname(controller), "bridge-node") + return bridge_agent.bridge_storage_path(self._resolve_storage_path()) @staticmethod def _human_size(num_bytes: int) -> str: diff --git a/tests/test_bridge_agent.py b/tests/test_bridge_agent.py new file mode 100644 index 0000000..4d21f88 --- /dev/null +++ b/tests/test_bridge_agent.py @@ -0,0 +1,235 @@ +"""E7: the export bridge node's LaunchAgent (`bridge_agent`). + +``test_launch_agent.py`` pins what the ``AgentSpec`` extraction ADDS in general; +this file pins the bridge's own specialisation — the identity it carries, the +command line it builds, and the two things that would be silently wrong if they +drifted: the storage path (which the fabric backup archives and the node writes) +and the loopback port (which the plugin's client dials). + +Every agent here is built against a ``tmp_path`` home. Never construct one +against the real ``$HOME``: ``ensure_installed()``'s preflight-failure path calls +``uninstall()``, which would delete the developer's live LaunchAgent. +""" +from __future__ import annotations + +import os + +import pytest + +import bridge_agent +import bridge_protocol +from bridge_agent import BridgeProcess +from server_process import APPLIED_PLIST_MARKER, LABEL as CONTROLLER_LABEL, ServerProcess + +from test_server_process import FakeRunner + + +def _bridge(tmp_path, mock_logger, prefs=None, installed: bool = True) -> BridgeProcess: + """A BridgeProcess whose node and package entry exist, so preflight() passes.""" + home = tmp_path / "home" + bindir = home / "bin" + bindir.mkdir(parents=True, exist_ok=True) + (bindir / "npx").write_text("#!/bin/sh\n") + (bindir / "node").write_text("#!/bin/sh\n") + if installed: + entry = (home / "indigo-matter" / "node_modules" / bridge_agent.BRIDGE_PACKAGE + / "dist" / "main.js") + entry.parent.mkdir(parents=True, exist_ok=True) + entry.write_text("// fake entry\n") + return BridgeProcess(dict(prefs or {}), mock_logger, home=str(home), + npx_path=str(bindir / "npx"), runner=FakeRunner(), + sleep=lambda *_a: None) + + +# --------------------------------------------------------------------------- +# Identity: the bridge must never be mistakable for the controller +# --------------------------------------------------------------------------- + +def test_the_bridge_is_a_second_agent_with_its_own_everything(tmp_path, mock_logger): + """Label, package, storage, logs and marker all distinct from the controller. + + Every one of these being separate is what lets both agents live in one npm + root and one LaunchAgents directory without either standing on the other. + """ + bridge = _bridge(tmp_path, mock_logger) + controller = ServerProcess({}, mock_logger, home=str(tmp_path / "home"), + npx_path=str(tmp_path / "home" / "bin" / "npx"), + runner=FakeRunner()) + assert bridge.spec.label == "com.simons-plugins.indigo-matter.bridge" + assert bridge.spec.label != controller.spec.label + assert bridge.spec.package == "indigo-matter-bridge" != controller.spec.package + assert bridge.storage_path != controller.storage_path + assert bridge.plist_path != controller.plist_path + assert bridge.spec.out_log != controller.spec.out_log + assert bridge.spec.err_log != controller.spec.err_log + # The controller keeps the legacy un-suffixed marker (every install has it); + # a later agent takes the per-label default. Sharing it would make each read + # the other's digest and bootout a healthy job on every reload. + assert bridge.spec.applied_marker_name != APPLIED_PLIST_MARKER + assert bridge.spec.label in bridge.spec.applied_marker_name + + +def test_the_install_spec_is_a_registry_pin(tmp_path, mock_logger): + """One constant, exact-pinned, exactly like matter-server@1.2.2. + + This is the whole point of the AgentSpec extraction: installing the bridge is + ``npm install --prefix ~/indigo-matter `` with a different string, not + a second install mechanism. + """ + bridge = _bridge(tmp_path, mock_logger) + assert bridge.spec.install_spec == bridge_agent.DEFAULT_INSTALL_SPEC + name, _, version = bridge_agent.DEFAULT_INSTALL_SPEC.partition("@") + assert name == bridge_agent.BRIDGE_PACKAGE + assert version and not version.startswith(("^", "~")), "must be exact-pinned, not a range" + + +def test_the_default_entry_matches_the_packages_main(tmp_path, mock_logger): + """The fallback entry is only a fallback if it agrees with package.json.""" + import json + from pathlib import Path + + manifest = json.loads((Path(__file__).parent.parent / "bridge-node" / "package.json") + .read_text(encoding="utf-8")) + assert manifest["main"] == bridge_agent.DEFAULT_BRIDGE_ENTRY + assert manifest["version"] == bridge_agent.DEFAULT_INSTALL_SPEC.partition("@")[2] + bridge = _bridge(tmp_path, mock_logger) + assert bridge.spec.default_entry == bridge_agent.DEFAULT_BRIDGE_ENTRY + + +# --------------------------------------------------------------------------- +# Storage: one derivation, three consumers +# --------------------------------------------------------------------------- + +def test_storage_is_the_siblING_of_the_controllers(tmp_path, mock_logger): + bridge = _bridge(tmp_path, mock_logger) + home = str(tmp_path / "home") + expected = os.path.join(home, "Library", "Application Support", CONTROLLER_LABEL, + "bridge-node") + assert bridge.storage_path == expected + + +def test_storage_follows_a_relocated_controller_storage_path(tmp_path, mock_logger): + """A user who moves the controller's storage moves the bridge's with it. + + Derived, not configured — and derived from the pref the fabric backup also + reads, so the directory backed up and the directory the node writes cannot + disagree. + """ + bridge = _bridge(tmp_path, mock_logger, {"storagePath": "~/elsewhere/matter-server"}) + assert bridge.storage_path == os.path.join(str(tmp_path / "home"), "elsewhere", + "bridge-node") + + +def test_the_plugin_and_the_agent_derive_the_same_storage_path(): + """`bridge_storage_path` is the single derivation both sides call.""" + assert bridge_agent.bridge_storage_path("/x/y/matter-server") == "/x/y/bridge-node" + # A trailing slash must not produce ".../matter-server/bridge-node". + assert bridge_agent.bridge_storage_path("/x/y/matter-server/") == "/x/y/bridge-node" + + +# --------------------------------------------------------------------------- +# argv: the node validates every flag and refuses to start on a bad one +# --------------------------------------------------------------------------- + +def test_program_arguments_run_node_on_the_package_main(tmp_path, mock_logger): + bridge = _bridge(tmp_path, mock_logger) + args = bridge.program_arguments() + assert args[0].endswith("/node") and not args[0].endswith("/npx") + assert args[1].endswith(f"/node_modules/{bridge_agent.BRIDGE_PACKAGE}/dist/main.js") + assert args[args.index("--storage-path") + 1] == bridge.storage_path + assert args[args.index("--ws-port") + 1] == "5581" + assert args[args.index("--matter-port") + 1] == "5540" + + +def test_ports_come_from_prefs(tmp_path, mock_logger): + bridge = _bridge(tmp_path, mock_logger, + {bridge_protocol.PREF_WS_PORT: "5599", + bridge_agent.PREF_MATTER_PORT: "5541"}) + args = bridge.program_arguments() + assert args[args.index("--ws-port") + 1] == "5599" + assert args[args.index("--matter-port") + 1] == "5541" + # The reaper's EADDRINUSE signal is the PROTOCOL port, and it must track it. + assert bridge.spec.port == 5599 + + +@pytest.mark.parametrize("bad", ["", " ", "not-a-port", "0", "70000", None]) +def test_an_unusable_port_pref_falls_back_rather_than_reaching_the_cli(bad, tmp_path, + mock_logger): + """⊗ The controller's pre-2026.7.1 crash-loop, not repeated here. + + `config.ts` throws on a value it cannot parse AND on a flag with no value — + deliberately, so a bad plist fails loudly. Under KeepAlive that is a respawn + loop, so a blank pref must never reach the command line. + """ + bridge = _bridge(tmp_path, mock_logger, + {bridge_protocol.PREF_WS_PORT: bad, bridge_agent.PREF_MATTER_PORT: bad}) + args = bridge.program_arguments() + assert args[args.index("--ws-port") + 1] == bridge_protocol.DEFAULT_WS_PORT + assert args[args.index("--matter-port") + 1] == bridge_agent.DEFAULT_MATTER_PORT + assert "" not in args + + +def test_the_mdns_flag_is_omitted_when_no_interface_is_pinned(tmp_path, mock_logger): + """The node refuses to start on an interface this host does not have. + + Right when the user asked for one; wrong when we invented an empty string — + so the flag is absent rather than blank. + """ + assert "--mdns-interface" not in _bridge(tmp_path, mock_logger).program_arguments() + assert "--mdns-interface" not in _bridge(tmp_path, mock_logger, + {"primaryInterface": " "}).program_arguments() + + +def test_the_mdns_flag_reuses_the_controllers_interface_pref(tmp_path, mock_logger): + args = _bridge(tmp_path, mock_logger, {"primaryInterface": "en1"}).program_arguments() + assert args[args.index("--mdns-interface") + 1] == "en1" + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +def test_constructing_one_writes_nothing(tmp_path, mock_logger): + """⊗ XAC1: a fresh install must be completely inert. + + This is what lets the plugin build a BridgeProcess lazily from a menu + callback or a device-delete without installing a bridge nobody asked for. + """ + bridge = _bridge(tmp_path, mock_logger, installed=False) + assert not os.path.exists(bridge.plist_path) + assert not os.path.exists(bridge.storage_path) + assert not os.path.exists(bridge.log_dir) + + +def test_ensure_installed_writes_the_plist_and_creates_the_storage(tmp_path, mock_logger): + bridge = _bridge(tmp_path, mock_logger) + assert bridge.ensure_installed() is True + assert os.path.exists(bridge.plist_path) + assert os.path.isdir(bridge.storage_path) + import plistlib + with open(bridge.plist_path, "rb") as handle: + plist = plistlib.load(handle) + assert plist["Label"] == bridge_agent.LABEL + assert plist["ProgramArguments"] == bridge.program_arguments() + assert plist["StandardErrorPath"].endswith(bridge_agent.BRIDGE_ERR_LOG) + + +def test_preflight_names_the_bridge_install_action_when_the_package_is_missing(tmp_path, + mock_logger): + """A user reading this must be sent at the BRIDGE's install menu, not the + controller's — the two packages are separately versioned and separately + installed.""" + bridge = _bridge(tmp_path, mock_logger, installed=False) + problem = bridge.preflight() + assert problem is not None + assert bridge_agent.BRIDGE_PACKAGE in problem + assert bridge_agent.DEFAULT_INSTALL_SPEC in problem + + +def test_uninstall_never_touches_the_storage(tmp_path, mock_logger): + """The pairings of every ecosystem live in there. Sacred both ways.""" + bridge = _bridge(tmp_path, mock_logger) + bridge.ensure_installed() + bridge.uninstall() + assert not os.path.exists(bridge.plist_path) + assert os.path.isdir(bridge.storage_path) diff --git a/tests/test_export_agent_wiring.py b/tests/test_export_agent_wiring.py new file mode 100644 index 0000000..b24e8d8 --- /dev/null +++ b/tests/test_export_agent_wiring.py @@ -0,0 +1,406 @@ +"""E6/E7: the export engine's agent lifecycle, its §5 pairing events, and §5.5's switch. + +Three things land here that `test_export_bridge.py` predates: + +* **the LaunchAgent seams (XG5/XAC1)** — the bridge node is started by the + empty→non-empty allow-list transition and stopped by the reverse, *after* the + un-export has landed. A fresh install must touch nothing at all; +* **the §5 pairing events** — ``fabrics_changed``/``commissioned``/ + ``decommissioned``/``window_closed`` were emitted by the node from E5 and + consumed by nobody, so pairing activity was invisible without polling; +* **PRD §5.5's wholesale switch** — and specifically that turning it OFF is not + the same statement as emptying the allow-list: it must never un-export. + +References to ``§N`` are ``docs/BRIDGE_PROTOCOL.md``. +""" +from __future__ import annotations + +import importlib + +import pytest + +import bridge_protocol +import export_catalog +from export_store import ExportEntry, ExportStore + +from fakes import ( + DimmerDevice, + FakeBridgeClient, + FakeIndigoDevices, + InlineExecutor, + RecordingRuntime, + RelayDevice, +) + +OURS = export_catalog.DEFAULT_PLUGIN_ID + + +@pytest.fixture +def bridge_mod(mock_indigo_base): + import export_handlers + import export_bridge as module + importlib.reload(export_handlers) + importlib.reload(module) + return module + + +@pytest.fixture +def devices(mock_indigo_base): + collection = FakeIndigoDevices([ + RelayDevice(101, "Study Plug"), + DimmerDevice(102, "Hall Dimmer"), + ]) + mock_indigo_base.devices = collection + return collection + + +class AgentHarness: + """An ExportBridge with the E7 agent seams recorded rather than performed.""" + + def __init__(self, module, mock_logger, devices, entries=(), prefs=None): + self.logger = mock_logger + self.prefs: dict = dict(prefs or {}) + self.devices = devices + self.store = ExportStore(lambda: self.prefs, mock_logger) + for entry in entries: + self.store.upsert(entry) + self.runtime = RecordingRuntime() + self.clients: list[FakeBridgeClient] = [] + #: Ordered agent seam calls — the ORDER is part of the contract. + self.agent: list[str] = [] + self.diagnosis: str | None = None + self.start_raises: Exception | None = None + self.bridge = module.ExportBridge( + self.store, self.runtime, mock_logger, lambda: self.prefs, + plugin_version="2026.8.1", plugin_id=OURS, + device_getter=lambda dev_id: self.devices.get(dev_id), + client_factory=self._client, + executor_factory=InlineExecutor, + agent_start=self._agent_start, + agent_stop=lambda: self.agent.append("stop"), + agent_diagnose=lambda: self.diagnosis, + ) + + def _agent_start(self): + self.agent.append("start") + if self.start_raises is not None: + raise self.start_raises + + def _client(self, logger, prefs, **kwargs): + client = FakeBridgeClient(logger, prefs, **kwargs) + self.clients.append(client) + return client + + @property + def client(self) -> FakeBridgeClient: + assert self.clients, "no bridge client was created" + return self.clients[-1] + + +def _rendered(calls) -> str: + """Lazily-formatted log calls, as the user would actually read them.""" + return " ".join(str(call.args[0]) % call.args[1:] if len(call.args) > 1 + else str(call.args[0]) + for call in calls) + + +def warnings_of(logger) -> str: + return _rendered(logger.warning.call_args_list) + + +def infos_of(logger) -> str: + return _rendered(logger.info.call_args_list) + + +def errors_of(logger) -> str: + return _rendered(logger.error.call_args_list) + + +# --------------------------------------------------------------------------- +# XAC1 / XG5 — the agent follows the allow-list, nothing else +# --------------------------------------------------------------------------- + +class TestAgentLifecycle: + def test_a_fresh_install_starts_no_agent_and_no_client(self, bridge_mod, mock_logger, + devices): + """⊗ XAC1. The whole acceptance criterion in one assertion pair.""" + h = AgentHarness(bridge_mod, mock_logger, devices, []) + h.bridge.exports_changed() + assert h.agent == [] + assert h.clients == [] + assert h.bridge.active is False + + def test_the_first_export_starts_the_agent_BEFORE_the_client(self, bridge_mod, + mock_logger, devices): + """Order matters: a client built first spends its whole backoff dialling + a port nothing is listening on yet.""" + h = AgentHarness(bridge_mod, mock_logger, devices, []) + h.store.upsert(ExportEntry(101, "onOffLight")) + h.bridge.exports_changed() + assert h.agent == ["start"] + assert len(h.clients) == 1 + assert h.client.ran is True + + def test_a_second_export_does_not_restart_the_agent(self, bridge_mod, mock_logger, + devices): + h = AgentHarness(bridge_mod, mock_logger, devices, [ExportEntry(101, "onOffLight")]) + h.bridge.exports_changed() + h.store.upsert(ExportEntry(102, "dimmableLight")) + h.bridge.exports_changed() + assert h.agent == ["start"], "start() is idempotent, so the seam must be too" + + def test_emptying_the_allow_list_stops_the_agent_AFTER_the_un_export( + self, bridge_mod, mock_logger, devices): + """⊗ The ordering XG5's other half depends on. + + Stopping first would take the node down with the §3.1 removal request + still unsent, leaving the accessories in every paired ecosystem and the + debt pointing at a bridge the plugin has just switched off. + """ + h = AgentHarness(bridge_mod, mock_logger, devices, [ExportEntry(101, "onOffLight")]) + h.bridge.exports_changed() + client = h.client + h.store.remove(101) + h.bridge.exports_changed() + # The un-export went out as the deliberate replace_all attach… + assert ("attach", [], True) in client.calls + assert client.closed is True + # …and only then was the agent stopped. + assert h.agent == ["start", "stop"] + + def test_the_agent_is_still_stopped_when_the_un_export_attach_FAILED( + self, bridge_mod, mock_logger, devices): + """The socket is closed and the list is empty either way; the DEBT is what + carries the un-export forward, and it survives in prefs.""" + h = AgentHarness(bridge_mod, mock_logger, devices, [ExportEntry(101, "onOffLight")]) + h.bridge.exports_changed() + h.client.fail["attach"] = RuntimeError("node went away") + h.store.remove(101) + h.bridge.exports_changed() + assert h.agent == ["start", "stop"] + assert h.prefs.get(bridge_mod.PREF_PENDING_REPLACE_ALL) == 1 + + def test_a_re_export_during_the_un_export_keeps_the_agent_up(self, bridge_mod, + mock_logger, devices): + """The deferred start wins over the stop — there is something to serve again. + + The race is real: ``_replace_all_then_stop`` sets ``client = None`` the + instant it fires, so a user who empties the list and immediately re-adds + a device lands inside the un-export's own coroutine. Stopping the agent + there would take the node down underneath the client that is about to be + rebuilt. + """ + h = AgentHarness(bridge_mod, mock_logger, devices, [ExportEntry(101, "onOffLight")]) + h.bridge.exports_changed() + h.agent.clear() + client = h.client + + # Re-export from inside the un-export attach, which is where a user who + # is quick with the dialog actually lands. + original_attach = client.attach + + async def _attach_then_readd(endpoints=None, *, replace_all=False, timeout=None): + result = await original_attach(endpoints, replace_all=replace_all, timeout=timeout) + h.store.upsert(ExportEntry(102, "dimmableLight")) + h.bridge.start() + return result + client.attach = _attach_then_readd + + h.store.remove(101) + h.bridge.exports_changed() + + assert "stop" not in h.agent, "something is exported again — do not stop the node" + assert len(h.clients) == 2, "the deferred start must actually happen" + + def test_an_agent_that_will_not_start_still_builds_the_client(self, bridge_mod, + mock_logger, devices): + """A launchd fault must not also remove the diagnosis. + + The client's unreachable path is what reports the outage WITH the node's + own error log attached; refusing to build it would replace one diagnosis + with none. + """ + h = AgentHarness(bridge_mod, mock_logger, devices, [ExportEntry(101, "onOffLight")]) + h.start_raises = OSError("launchctl exploded") + h.bridge.exports_changed() + assert h.clients, "the client must still exist" + said = errors_of(mock_logger) + assert "LaunchAgent" in said + # And the fault must be scoped to export. + assert "inbound Matter control are unaffected" in said + + def test_an_agent_that_will_not_stop_is_a_warning_not_a_failure(self, bridge_mod, + mock_logger, devices): + h = AgentHarness(bridge_mod, mock_logger, devices, [ExportEntry(101, "onOffLight")]) + h.bridge.exports_changed() + h.bridge._agent_stop = lambda: (_ for _ in ()).throw(OSError("nope")) + h.store.remove(101) + h.bridge.exports_changed() + assert "could not stop" in warnings_of(mock_logger) + assert "pairings are untouched" in warnings_of(mock_logger) + + def test_the_unreachable_report_carries_the_agents_diagnosis(self, bridge_mod, + mock_logger, devices): + """"Connection refused" is what a missing package, a bound Matter port and + a crash-loop all look like at the socket. The agent's log tells them apart.""" + h = AgentHarness(bridge_mod, mock_logger, devices, [ExportEntry(101, "onOffLight")]) + h.bridge.exports_changed() + h.diagnosis = "Recent bridge node errors:\nlisten EADDRINUSE 5540" + h.bridge._on_unreachable(4) + assert "EADDRINUSE" in warnings_of(mock_logger) + + def test_a_failing_diagnostic_costs_only_the_extra_sentence(self, bridge_mod, + mock_logger, devices): + h = AgentHarness(bridge_mod, mock_logger, devices, [ExportEntry(101, "onOffLight")]) + h.bridge.exports_changed() + h.bridge._agent_diagnose = lambda: (_ for _ in ()).throw(OSError("no log")) + h.bridge._on_unreachable(4) + assert "not responding" in warnings_of(mock_logger) + + +# --------------------------------------------------------------------------- +# PRD §5.5 — the wholesale switch +# --------------------------------------------------------------------------- + +class TestExportEnabledSwitch: + def test_absent_means_ON(self, bridge_mod, mock_logger, devices): + """⊗ Every install predating E6 has a working allow-list and no key.""" + h = AgentHarness(bridge_mod, mock_logger, devices, [ExportEntry(101, "onOffLight")]) + assert h.bridge.enabled is True + h.bridge.exports_changed() + assert h.clients and h.agent == ["start"] + + @pytest.mark.parametrize("raw,expected", [ + (True, True), (False, False), + ("true", True), ("false", False), ("no", False), ("off", False), ("0", False), + ("YES", True), ("", True), ("nonsense", True), (None, True), + ]) + def test_the_pref_fails_OPEN(self, raw, expected, bridge_mod, mock_logger, devices): + """Opposite direction from the controller's attestation flag, deliberately: + the harm of misreading THIS one is un-running a working export.""" + h = AgentHarness(bridge_mod, mock_logger, devices, [], + prefs={bridge_mod.PREF_EXPORT_ENABLED: raw}) + assert h.bridge.enabled is expected + + def test_switched_off_never_starts_anything(self, bridge_mod, mock_logger, devices): + h = AgentHarness(bridge_mod, mock_logger, devices, [ExportEntry(101, "onOffLight")], + prefs={bridge_mod.PREF_EXPORT_ENABLED: False}) + h.bridge.exports_changed() + assert h.clients == [] and h.agent == [] + + def test_switching_off_disconnects_but_NEVER_un_exports(self, bridge_mod, mock_logger, + devices): + """⊗ The one that would delete every accessory from every ecosystem. + + Turning a switch off is not the statement "remove everything" (PRD §7) — + it must not send the §3.1 replace_all, must not record a debt, and must + leave the allow-list alone so ticking it back on restores everything. + """ + h = AgentHarness(bridge_mod, mock_logger, devices, [ExportEntry(101, "onOffLight")]) + h.bridge.exports_changed() + client = h.client + client.calls.clear() + + h.prefs[bridge_mod.PREF_EXPORT_ENABLED] = False + h.bridge.exports_changed() + + assert not [call for call in client.calls if call[0] == "attach"], \ + "switching export off must never send a replace_all attach" + assert bridge_mod.PREF_PENDING_REPLACE_ALL not in h.prefs + assert len(h.store) == 1, "the allow-list is the user's declaration" + assert client.closed is True + assert h.agent == ["start", "stop"] + assert "LEFT paired" in infos_of(mock_logger) + + def test_switching_back_on_reconnects(self, bridge_mod, mock_logger, devices): + h = AgentHarness(bridge_mod, mock_logger, devices, [ExportEntry(101, "onOffLight")], + prefs={bridge_mod.PREF_EXPORT_ENABLED: False}) + h.bridge.exports_changed() + assert h.clients == [] + h.prefs[bridge_mod.PREF_EXPORT_ENABLED] = True + h.bridge.exports_changed() + assert len(h.clients) == 1 and h.agent == ["start"] + + +# --------------------------------------------------------------------------- +# §5 pairing events — emitted since E5, consumed by nobody until E6 +# --------------------------------------------------------------------------- + +class TestPairingEvents: + def _started(self, bridge_mod, mock_logger, devices): + h = AgentHarness(bridge_mod, mock_logger, devices, [ExportEntry(101, "onOffLight")]) + h.bridge.exports_changed() + return h + + def test_all_four_callbacks_reach_the_client(self, bridge_mod, mock_logger, devices): + """⊗ The wiring itself: these were declared, documented, carried by golden + frames and dispatched by the client — and handed to it by nothing.""" + h = self._started(bridge_mod, mock_logger, devices) + for name in ("on_fabrics_changed", "on_commissioned", "on_decommissioned", + "on_window_closed"): + assert h.client.kwargs.get(name) is not None, f"{name} was not wired" + + def test_fabrics_changed_is_logged_and_remembered(self, bridge_mod, mock_logger, + devices): + h = self._started(bridge_mod, mock_logger, devices) + fabrics = bridge_protocol.parse_fabrics( + [{"fabricIndex": 1, "label": "", "vendorId": 0x1349}]) + h.bridge._on_fabrics_changed(fabrics, "added") + assert "Apple" in infos_of(mock_logger) + assert h.bridge.fabrics == fabrics + + def test_an_attach_seeds_the_fabric_set(self, bridge_mod, mock_logger, devices): + """fabrics_changed fires on CHANGES, so a long-paired bridge emits nothing + at all — the attach's own §4.3 report is the only source on a fresh + connection.""" + h = self._started(bridge_mod, mock_logger, devices) + status = bridge_protocol.parse_status({ + "commissioned": True, "endpointCount": 1, "endpoints": [], "drift": [], + "driftChecked": True, "warnings": [], + "fabrics": [{"fabricIndex": 2, "label": "", "vendorId": 0x100B}], + }) + h.bridge._on_attached(status, False) + assert [f.fabric_index for f in h.bridge.fabrics] == [2] + + def test_commissioned_says_what_to_do_next(self, bridge_mod, mock_logger, devices): + h = self._started(bridge_mod, mock_logger, devices) + h.bridge._on_commissioned() + said = infos_of(mock_logger) + assert "PAIRED for the first time" in said + assert "no longer works" in said, "the original code is dead — say so" + + def test_decommissioned_is_a_WARNING_and_clears_the_state(self, bridge_mod, + mock_logger, devices): + """An ecosystem removing US takes every accessory with it, and this line + is the only notice the user gets.""" + h = self._started(bridge_mod, mock_logger, devices) + h.bridge.window_expires_at = "2026-08-05T12:00:00Z" + h.bridge._on_decommissioned() + assert "no longer paired with ANY ecosystem" in warnings_of(mock_logger) + assert h.bridge.fabrics == [] + assert h.bridge.window_expires_at is None + + @pytest.mark.parametrize("reason,expected", [ + ("commissioned", "completed commissioning"), + ("expired", "expired without an ecosystem"), + ]) + def test_window_closed_distinguishes_its_two_reasons(self, reason, expected, bridge_mod, + mock_logger, devices): + h = self._started(bridge_mod, mock_logger, devices) + h.bridge.note_window_opened("2026-08-05T12:00:00Z") + h.bridge._on_window_closed(reason) + assert expected in infos_of(mock_logger) + assert h.bridge.window_expires_at is None + + +def test_fabrics_are_described_by_vendor_not_by_their_own_label(bridge_mod): + """A fabric's `label` is whatever the commissioner wrote — for Apple, a + UUID-ish string that tells nobody anything. The index is always shown + because that is what §3.9 removes a fabric BY.""" + fabric = bridge_protocol.FabricInfo(fabric_index=1, label="", vendor_id=0x1349) + assert bridge_mod.describe_fabric(fabric) == "Apple (index 1)" + labelled = bridge_protocol.FabricInfo(fabric_index=3, label="Kitchen Hub", + vendor_id=0x100B) + assert bridge_mod.describe_fabric(labelled) == "Google — Kitchen Hub (index 3)" + unknown = bridge_protocol.FabricInfo(fabric_index=9, label="", vendor_id=0x1234) + assert bridge_mod.describe_fabric(unknown) == "vendor 0x1234 (index 9)" diff --git a/tests/test_export_bridge.py b/tests/test_export_bridge.py index 6191c5f..0ea7a6b 100644 --- a/tests/test_export_bridge.py +++ b/tests/test_export_bridge.py @@ -674,7 +674,10 @@ def test_an_unreachable_node_is_reported_once_per_outage(self, bridge_mod, mock_ for attempt in range(1, 6): h.bridge._on_unreachable(attempt) assert mock_logger.warning.call_count == 1 - assert "started by hand" in warnings_of(mock_logger) + said = warnings_of(mock_logger) + assert "not responding" in said + # E7: Indigo control must never be implicated by an export-side fault. + assert "Indigo devices and inbound Matter control are unaffected" in said class TestHealthTick: diff --git a/tests/test_launch_agent.py b/tests/test_launch_agent.py index 5115a2a..90a7c0d 100644 --- a/tests/test_launch_agent.py +++ b/tests/test_launch_agent.py @@ -194,16 +194,15 @@ def test_preflight_fail_returns_none_and_spares_the_sibling(tmp_path, mock_logge assert all("com.example.a" not in " ".join(call) for call in broken._run.calls) -def test_remove_package_pins_the_shared_destruction_semantics(tmp_path, mock_logger): - """TODO(E7): remove_package() destroys the SIBLING's package too. Pinned, not endorsed. - - It rmtree's the shared node_modules wholesale while booting out only its own - label — so the sibling keeps a loaded job definition and a stale applied marker - pointing at a package that no longer exists (crash-loop on next respawn). - Tolerable while exactly one agent exists; NOT tolerable once the bridge agent - lands. E7 must make this per-package (npm-uninstall semantics) or sibling-aware, - and this test exists so that change is deliberate: when E7 fixes the semantics, - rewrite these assertions to pin the new isolation instead. +def test_remove_package_leaves_the_sibling_agent_entirely_alone(tmp_path, mock_logger): + """E7 closed the deferred hazard: remove_package() is now PER PACKAGE. + + It used to ``rmtree`` the shared ``node_modules`` wholesale while booting out only + its own label — so the sibling kept a loaded job definition and a matching applied + marker pointing at a package that no longer existed, and crash-looped on the next + respawn with nothing in the plugin log saying why. That was pinned-not-endorsed + while exactly one agent existed. This now pins the isolation instead: npm is asked + to uninstall one package by name, and the fallback (below) removes one directory. """ home = tmp_path / "home" controller = _agent(home, _spec("com.example.a", "pkg-a", str(tmp_path / "a-store")), mock_logger) @@ -211,15 +210,45 @@ def test_remove_package_pins_the_shared_destruction_semantics(tmp_path, mock_log assert controller.ensure_installed() is True assert sibling.ensure_installed() is True sibling_entry = os.path.join(sibling.project_dir, "node_modules", "pkg-b", "dist", "Main.js") + sibling_digest = sibling._read_applied_digest() assert os.path.exists(sibling_entry) + with open(os.path.join(controller.resolved_bin_dir, "npm"), "w", encoding="utf-8") as handle: + handle.write("#!/bin/sh\n") controller._run = FakeRunner() controller.remove_package() - assert not os.path.exists(sibling_entry) # sibling's package gone (today's semantics) - assert sibling.preflight() is not None # so its preflight now fails - assert os.path.exists(sibling.plist_path) # while its job definition remains - assert sibling._read_applied_digest() # with a stale-but-present marker + + assert os.path.exists(sibling_entry) # ⊗ the whole point + assert sibling.preflight() is None # so the sibling still passes preflight + assert os.path.exists(sibling.plist_path) + assert sibling._read_applied_digest() == sibling_digest + # And it is still a targeted npm call, naming only our package. + npm_calls = [call for call in controller._run.calls if "uninstall" in call] + assert npm_calls and "pkg-a" in npm_calls[0] and "pkg-b" not in " ".join(npm_calls[0]) assert all("com.example.b" not in " ".join(call) for call in controller._run.calls) + # Our own marker is dropped, so the next ensure_installed re-bootstraps. + assert controller._read_applied_digest() is None + + +def test_remove_package_fallback_deletes_only_its_own_package_dir(tmp_path, mock_logger): + """No npm available: the directory delete is scoped to our package too.""" + home = tmp_path / "home" + controller = _agent(home, _spec("com.example.a", "pkg-a", str(tmp_path / "a-store")), mock_logger) + sibling = _agent(home, _spec("com.example.b", "pkg-b", str(tmp_path / "b-store")), mock_logger) + assert controller.ensure_installed() is True + assert sibling.ensure_installed() is True + ours = os.path.join(controller.project_dir, "node_modules", "pkg-a") + theirs = os.path.join(sibling.project_dir, "node_modules", "pkg-b") + + controller._run = FakeRunner() + # `exists` is the injected seam preflight uses; make npm specifically absent. + npm = os.path.join(controller.resolved_bin_dir, "npm") + controller._exists = lambda path: path != npm and os.path.exists(path) + controller.remove_package() + + assert not os.path.exists(ours) + assert os.path.exists(theirs) + assert not any("uninstall" in call for call in controller._run.calls) # --------------------------------------------------------------------------- diff --git a/tests/test_pairing_menu.py b/tests/test_pairing_menu.py new file mode 100644 index 0000000..7ce273d --- /dev/null +++ b/tests/test_pairing_menu.py @@ -0,0 +1,672 @@ +"""E6: pairing, fabric management, the QR page, and the §5.5 config readout. + +The user-facing half of PRD-indigo-matter-export §6. Everything the *node* needs +for this shipped in E5 (`get_pairing`, `open_commissioning_window`, +`remove_fabric`, and the §5 events); until now the plugin could not reach any of +it, so a bridge could be built and never paired. + +Three shapes are pinned here because they are forced by what Indigo dialogs can +actually do, not by preference: + +* **the codes go to the EVENT LOG**, because Indigo dialogs have no dynamic + labels — a value computed by the callback cannot be shown in the dialog that + produced it; +* **the QR goes on an IWS-served page**, because a log line cannot carry an + image; +* **the config readout is a read-only textfield seeded by + ``getPrefsConfigUiValues``**, for the same no-dynamic-labels reason. + +Every confirm-gate test asserts against **the CLIENT mock**, not merely against +the errors dict: the E5 round-two review found both recovery gates were inert in +the tests and live in production, because a second gate one line below filled the +same ``errors`` key and the assertion could not tell the two apart. +""" +from __future__ import annotations + +import importlib +import xml.etree.ElementTree as ET +from pathlib import Path +from unittest.mock import Mock + +import pytest + +import bridge_protocol +import export_catalog +from export_store import ExportEntry, ExportStore + +from fakes import FakeIndigoDevices, RelayDevice + +SERVER_PLUGIN = (Path(__file__).parent.parent + / "indigo-matter.indigoPlugin" / "Contents" / "Server Plugin") +MENU_ITEMS_XML = SERVER_PLUGIN / "MenuItems.xml" +PLUGIN_CONFIG_XML = SERVER_PLUGIN / "PluginConfig.xml" +ACTIONS_XML = SERVER_PLUGIN / "Actions.xml" +OURS = export_catalog.DEFAULT_PLUGIN_ID + + +@pytest.fixture +def plugin_mod(mock_indigo_base): + mock_indigo_base.server.getWebServerURL.return_value = "http://jarvis.local:8176" + import plugin as plugin_module + importlib.reload(plugin_module) + return plugin_module + + +@pytest.fixture +def devices(mock_indigo_base): + collection = FakeIndigoDevices([RelayDevice(101, "Study Plug")]) + mock_indigo_base.devices = collection + return collection + + +class _FakeRuntime: + """Resolves whatever the menu submits, or raises. Mirrors test_export_menu's.""" + + def __init__(self, results=None, error=None): + self.results = list(results or []) + self.error = error + self.submitted: list = [] + + def submit(self, coro): + self.submitted.append(coro) + coro.close() + return self + + def result(self, timeout=None): # noqa: ARG002 + if self.error is not None: + raise self.error + return self.results.pop(0) if self.results else None + + +@pytest.fixture +def plug(plugin_mod, devices): # noqa: ARG001 - devices installs indigo.devices + p = plugin_mod.Plugin.__new__(plugin_mod.Plugin) + p.logger = Mock() + p.pluginId = OURS + p.pluginPrefs = {} + p.exports = ExportStore(lambda: p.pluginPrefs, p.logger) + p.exports.upsert(ExportEntry(101, "onOffPlugInUnit")) + p.export_bridge = None + p.bridge_process = None + p.runtime = _FakeRuntime() + p._exported_ids = frozenset({101}) + p._install_thread = None + p._stopping = False + return p + + +def _bridge_with(plug, *, connected=True, fabrics=None, active=True, enabled=True, + window=None): + client = Mock(connected=connected, attached=connected, halted=False, recovery=False) + bridge = Mock(client=client, fabrics=fabrics, active=active, enabled=enabled, + window_expires_at=window) + plug.export_bridge = bridge + return client + + +def _pairing(commissioned=True, window_open=True, manual="1234-567-8901", + qr="MT:ABCDEFG", expires="2026-08-05T12:00:00Z", fabrics=()): + return bridge_protocol.PairingReport( + commissioned=commissioned, window_open=window_open, window_expires_at=expires, + manual_pairing_code=manual, qr_pairing_code=qr, fabrics=list(fabrics)) + + +def _fabric(index, vendor=0x1349, label=""): + return bridge_protocol.FabricInfo(fabric_index=index, label=label, vendor_id=vendor) + + +def _logged(logger) -> str: + calls = (logger.info.call_args_list + logger.warning.call_args_list + + logger.error.call_args_list) + return " ".join(str(c.args[0]) % c.args[1:] if len(c.args) > 1 else str(c.args[0]) + for c in calls) + + +# --------------------------------------------------------------------------- +# XML shape +# --------------------------------------------------------------------------- +def _menu_item(item_id): + for item in ET.parse(MENU_ITEMS_XML).getroot().findall("MenuItem"): + if item.get("id") == item_id: + return item + raise AssertionError(f"{item_id} menu item missing") + + +def _config_fields(): + return {f.get("id"): f for f in ET.parse(PLUGIN_CONFIG_XML).getroot().findall("Field")} + + +class TestMenuShape: + def test_the_pair_menu_exists_and_is_wired(self, plugin_mod): + item = _menu_item("pairMatterBridge") + assert item.findtext("CallbackMethod") == "menuPairMatterBridge" + assert hasattr(plugin_mod.Plugin, "menuPairMatterBridge") + # A CallbackMethod plus a ConfigUI means Execute/Cancel; the button says + # what it does rather than "Execute". + assert item.findtext("ButtonTitle") == "Open pairing window" + + def test_the_pair_dialog_defaults_to_the_900_second_maximum(self): + fields = {f.get("id"): f for f in _menu_item("pairMatterBridge") + .find("ConfigUI").findall("Field")} + assert fields["duration"].get("defaultValue") == "900" + intro = fields["pairIntro"].findtext("Label") + assert "EVENT LOG" in intro, "the user must be told where the code appears" + assert "uncertified" in intro, "expected in every ecosystem — say so first" + + def test_the_unpair_menu_demands_TWO_ticks_and_a_dynamic_picker(self, plugin_mod): + item = _menu_item("unpairEcosystem") + assert item.findtext("CallbackMethod") == "menuUnpairEcosystem" + assert hasattr(plugin_mod.Plugin, "menuUnpairEcosystem") + fields = {f.get("id"): f for f in item.find("ConfigUI").findall("Field")} + for name in ("confirm", "confirmAgain"): + assert fields[name].get("type") == "checkbox" + assert fields[name].get("defaultValue") == "false" + picker = fields["fabric"].find("List") + assert picker.get("method") == "getBridgeFabrics" + assert hasattr(plugin_mod.Plugin, "getBridgeFabrics") + # The last-fabric case E5 hardened must be named before it happens. + assert "resets itself" in fields["unpairWarning"].findtext("Label") + + def test_the_bridge_install_menu_is_a_sibling_of_the_controllers(self, plugin_mod): + """Separately versioned packages, separately installed: a user recovering + a wedged bridge must not be made to reinstall a working controller.""" + item = _menu_item("installBridgeNode") + assert item.findtext("CallbackMethod") == "menuInstallBridgeNode" + assert hasattr(plugin_mod.Plugin, "menuInstallBridgeNode") + assert _menu_item("installMatterServer").findtext("CallbackMethod") \ + == "menuInstallMatterServer" + + +class TestConfigShape: + def test_the_export_section_carries_the_switch_the_ports_and_the_readout(self): + fields = _config_fields() + assert fields["exportEnabled"].get("type") == "checkbox" + assert fields["exportEnabled"].get("defaultValue") == "true" + assert fields["bridgeWsPort"].get("defaultValue") == "5581" + assert fields["bridgeMatterPort"].get("defaultValue") == "5540" + # Labels are static in Indigo, so the readout has to be a read-only field. + assert fields["exportReadout"].get("type") == "textfield" + assert fields["exportReadout"].get("readonly") == "true" + + def test_every_conditional_export_field_reserves_its_height(self): + """Without it the window is sized for the default view and the taller + conditional sections overflow.""" + fields = _config_fields() + for name in ("bridgeWsPort", "bridgeMatterPort"): + assert fields[name].get("visibleBindingId") == "showExportAdvanced" + assert fields[name].get("alwaysUseInDialogHeightCalc") == "true" + + def test_the_port_prefs_are_the_keys_the_code_actually_reads(self): + import bridge_agent + fields = _config_fields() + assert bridge_protocol.PREF_WS_PORT in fields + assert bridge_agent.PREF_MATTER_PORT in fields + assert fields[bridge_protocol.PREF_WS_PORT].get("defaultValue") \ + == bridge_protocol.DEFAULT_WS_PORT + assert fields[bridge_agent.PREF_MATTER_PORT].get("defaultValue") \ + == bridge_agent.DEFAULT_MATTER_PORT + + +def test_the_pairing_page_is_a_hidden_iws_action(plugin_mod): + for action in ET.parse(ACTIONS_XML).getroot().findall("Action"): + if action.get("id") == "pairing": + assert action.get("uiPath") == "hidden" + assert action.findtext("CallbackMethod") == "http_pairing" + assert hasattr(plugin_mod.Plugin, "http_pairing") + return + raise AssertionError("the pairing action is missing from Actions.xml") + + +# --------------------------------------------------------------------------- +# Pair +# --------------------------------------------------------------------------- +class TestPairMenu: + def test_it_refuses_with_no_bridge_connection_and_says_why(self, plug): + """The precondition is not obvious: the client exists only while + something is exported (XG5), so pairing is genuinely unreachable until + the user has exported a device. That is XAC2's ordering.""" + plug.pluginPrefs.clear() # the allow-list lives in prefs — empty it properly + plug.exports = ExportStore(lambda: plug.pluginPrefs, plug.logger) + assert not len(plug.exports) + plug.export_bridge = None + ok, _values, errors = plug.menuPairMatterBridge({"duration": "900"}) + assert ok is False and "duration" in errors + assert "Export at least one device" in _logged(plug.logger) + + @pytest.mark.parametrize("bad", ["60", "1000", "abc", "-1"]) + def test_a_duration_outside_matters_band_is_refused_before_any_call(self, bad, plug): + """⊗ Asserted on the CLIENT: clamping silently would give a user 900 + seconds when they asked for 60, which is a difference they should be told + about while the dialog is still open.""" + client = _bridge_with(plug) + ok, _values, errors = plug.menuPairMatterBridge({"duration": bad}) + assert ok is False and "duration" in errors + client.open_commissioning_window.assert_not_called() + client.get_pairing.assert_not_called() + + def test_a_blank_duration_uses_the_900_second_default(self, plug): + client = _bridge_with(plug) + plug.runtime = _FakeRuntime([_pairing(commissioned=True, window_open=False, + manual=None, qr=None), + bridge_protocol.CommissioningWindow( + "1111-222-3333", "MT:XYZ", + "2026-08-05T12:00:00Z")]) + ok, _values = plug.menuPairMatterBridge({"duration": ""}) + assert ok is True + client.open_commissioning_window.assert_called_once_with( + bridge_protocol.DEFAULT_WINDOW_SECONDS) + + def test_it_opens_a_window_and_logs_the_codes_the_expiry_and_the_page(self, plug): + client = _bridge_with(plug) + plug.runtime = _FakeRuntime([_pairing(commissioned=True, window_open=False, + manual=None, qr=None), + bridge_protocol.CommissioningWindow( + "1111-222-3333", "MT:XYZ", + "2026-08-05T12:00:00Z")]) + ok, _values = plug.menuPairMatterBridge({"duration": "300"}) + assert ok is True + client.open_commissioning_window.assert_called_once_with(300) + said = _logged(plug.logger) + assert "1111-222-3333" in said and "MT:XYZ" in said + assert "2026-08-05T12:00:00Z" in said + assert "/message/com.simons-plugins.indigo-matter/pairing/" in said + assert "Add Anyway" in said + # The §5.5 readout has to know a window is open without polling. + plug.export_bridge.note_window_opened.assert_called_once_with( + "2026-08-05T12:00:00Z") + + def test_a_NEVER_commissioned_bridge_reports_its_existing_code(self, plug): + """⊗ §3.7: an uncommissioned node already has its basic window open with + the persisted originals. Deriving a fresh enhanced code there would + invalidate a code the user may already be typing.""" + client = _bridge_with(plug) + plug.runtime = _FakeRuntime([_pairing(commissioned=False, window_open=True, + manual="0000-111-2222", expires=None)]) + ok, _values = plug.menuPairMatterBridge({"duration": "900"}) + assert ok is True + client.open_commissioning_window.assert_not_called() + said = _logged(plug.logger) + assert "0000-111-2222" in said + assert "ALREADY advertising" in said + + def test_a_window_that_is_already_open_is_reported_not_re_opened(self, plug): + """§3.8's assertClosed refuses a second one — and doing it anyway would + kill the code the user is holding.""" + client = _bridge_with(plug) + plug.runtime = _FakeRuntime([_pairing(commissioned=True, window_open=True, + manual="9999-888-7777")]) + ok, _values = plug.menuPairMatterBridge({"duration": "900"}) + assert ok is True + client.open_commissioning_window.assert_not_called() + assert "9999-888-7777" in _logged(plug.logger) + + def test_a_failed_get_pairing_opens_no_window(self, plug): + client = _bridge_with(plug) + plug.runtime = _FakeRuntime(error=RuntimeError("socket gone")) + ok, _values, errors = plug.menuPairMatterBridge({"duration": "900"}) + assert ok is False and "duration" in errors + client.open_commissioning_window.assert_not_called() + assert "No pairing window was opened" in _logged(plug.logger) + + def test_commissioning_window_failed_is_reported_as_a_failure(self, plug): + """Never report success over a window that did not open: the user would + stand in front of the Home app waiting for a code that does not exist.""" + _bridge_with(plug) + + class _Runtime(_FakeRuntime): + def result(self, timeout=None): # noqa: ARG002 + if not self.results: + raise bridge_protocol.BridgeProtocolError( + bridge_protocol.ERR_COMMISSIONING_WINDOW_FAILED, "already open") + return self.results.pop(0) + + plug.runtime = _Runtime([_pairing(commissioned=True, window_open=False, + manual=None, qr=None)]) + ok, _values, errors = plug.menuPairMatterBridge({"duration": "900"}) + assert ok is False and "duration" in errors + said = _logged(plug.logger) + assert "existing pairings are untouched" in said + + +# --------------------------------------------------------------------------- +# Unpair +# --------------------------------------------------------------------------- +class TestFabricPicker: + def test_it_lists_each_fabric_by_vendor_and_index(self, plug): + _bridge_with(plug, fabrics=[_fabric(1), _fabric(2, vendor=0x1217)]) + rows = dict(plug.getBridgeFabrics()) + assert rows["1"] == "Apple (index 1)" + assert rows["2"] == "Amazon (index 2)" + + def test_never_connected_and_not_paired_are_DIFFERENT_answers(self, plug): + """Both are unpickable, but only one of them means "you are not paired".""" + _bridge_with(plug, fabrics=None) + assert "not connected" in dict(plug.getBridgeFabrics())["0"] + _bridge_with(plug, fabrics=[]) + assert "no paired ecosystems" in dict(plug.getBridgeFabrics())["0"] + + def test_it_never_blocks_the_dialog_on_a_wS_round_trip(self, plug): + """A list callback runs while the dialog opens; a node that is down would + hang it rather than render an empty one.""" + client = _bridge_with(plug, fabrics=[_fabric(1)]) + plug.getBridgeFabrics() + client.get_status.assert_not_called() + client.get_pairing.assert_not_called() + + def test_a_broken_picker_says_so_rather_than_rendering_empty(self, plug): + plug.export_bridge = Mock() + type(plug.export_bridge).fabrics = property( + lambda _self: (_ for _ in ()).throw(RuntimeError("boom"))) + rows = dict(plug.getBridgeFabrics()) + assert "error building list" in list(rows.values())[0] + + +class TestUnpairMenu: + def test_neither_tick_removes_nothing(self, plug): + """⊗ Asserted against THE CLIENT, with a live client present.""" + client = _bridge_with(plug, fabrics=[_fabric(1)]) + ok, _values, errors = plug.menuUnpairEcosystem( + {"fabric": "1", "confirm": False, "confirmAgain": False}) + assert ok is False and "confirm" in errors + client.remove_fabric.assert_not_called() + + def test_one_tick_is_not_enough(self, plug): + client = _bridge_with(plug, fabrics=[_fabric(1)]) + ok, _values, errors = plug.menuUnpairEcosystem( + {"fabric": "1", "confirm": True, "confirmAgain": False}) + assert ok is False and "confirmAgain" in errors + client.remove_fabric.assert_not_called() + + def test_the_second_tick_alone_is_not_enough_either(self, plug): + client = _bridge_with(plug, fabrics=[_fabric(1)]) + ok, _values, errors = plug.menuUnpairEcosystem( + {"fabric": "1", "confirm": False, "confirmAgain": True}) + assert ok is False and "confirm" in errors + client.remove_fabric.assert_not_called() + + def test_no_selection_removes_nothing(self, plug): + client = _bridge_with(plug, fabrics=[_fabric(1)]) + for value in ("", "0"): + ok, _values, errors = plug.menuUnpairEcosystem( + {"fabric": value, "confirm": True, "confirmAgain": True}) + assert ok is False and "fabric" in errors + client.remove_fabric.assert_not_called() + + def test_both_ticks_call_remove_fabric_with_the_picked_index(self, plug): + client = _bridge_with(plug, fabrics=[_fabric(1), _fabric(2, vendor=0x100B)]) + ok, _values = plug.menuUnpairEcosystem( + {"fabric": "2", "confirm": True, "confirmAgain": True}) + assert ok is True + client.remove_fabric.assert_called_once_with(2) + assert "has been unpaired" in _logged(plug.logger) + + def test_removing_the_LAST_fabric_says_the_bridge_has_reset_itself(self, plug): + """⊗ E5's hardened case. matter.js factory-resets when the fabric set + empties, so the user has just reset the whole bridge without using the + reset menu — reporting a routine removal would be a lie.""" + client = _bridge_with(plug, fabrics=[_fabric(1)]) + ok, _values = plug.menuUnpairEcosystem( + {"fabric": "1", "confirm": True, "confirmAgain": True}) + assert ok is True + client.remove_fabric.assert_called_once_with(1) + said = _logged(plug.logger) + assert "LAST one paired" in said and "reset itself" in said + + def test_a_failed_removal_says_pairings_are_unchanged(self, plug): + _bridge_with(plug, fabrics=[_fabric(1), _fabric(2)]) + plug.runtime = _FakeRuntime(error=RuntimeError("node refused")) + ok, _values, errors = plug.menuUnpairEcosystem( + {"fabric": "2", "confirm": True, "confirmAgain": True}) + assert ok is False and "confirmAgain" in errors + assert "Pairings are unchanged" in _logged(plug.logger) + + def test_it_refuses_without_a_bridge_connection(self, plug): + plug.export_bridge = None + ok, _values, errors = plug.menuUnpairEcosystem( + {"fabric": "1", "confirm": True, "confirmAgain": True}) + assert ok is False and "confirmAgain" in errors + + +# --------------------------------------------------------------------------- +# The QR page +# --------------------------------------------------------------------------- +class TestPairingPage: + def _action(self, method="GET"): + return Mock(props={"incoming_request_method": method, "file_path": [], + "url_query_args": {}}) + + def test_it_serves_html_carrying_both_codes(self, plug): + _bridge_with(plug) + plug.runtime = _FakeRuntime([_pairing(manual="1234-567-8901", qr="MT:Y.K90")]) + reply = plug.http_pairing(self._action()) + assert reply["status"] == 200 + assert reply["headers"]["Content-Type"].startswith("text/html") + body = reply["content"] + assert "1234-567-8901" in body and "MT:Y.K90" in body + assert "" in body + + def test_the_qr_viewer_link_url_encodes_the_payload(self, plug): + """An `MT:` string is base-38 and can legitimately contain `+`, `/` or + `%` — unencoded, the tool opens on a silently truncated payload, which is + a QR that scans and means the wrong thing.""" + _bridge_with(plug) + plug.runtime = _FakeRuntime([_pairing(qr="MT:A+B/C%D")]) + body = plug.http_pairing(self._action())["content"] + assert "MT%3AA%2BB%2FC%25D" in body + + def test_it_says_so_when_no_window_is_open(self, plug): + _bridge_with(plug) + plug.runtime = _FakeRuntime([_pairing(window_open=False, manual=None, qr=None, + fabrics=[_fabric(1)])]) + body = plug.http_pairing(self._action())["content"] + assert "No pairing window is open" in body + assert "Apple (index 1)" in body, "still say who is paired" + + def test_it_renders_a_page_even_with_no_bridge_at_all(self, plug): + """A blank page over a bridge that is merely not running is + indistinguishable from a broken handler.""" + plug.export_bridge = None + body = plug.http_pairing(self._action())["content"] + assert "not connected to the Matter bridge node" in body + + def test_a_failed_get_pairing_becomes_a_page_not_a_500(self, plug): + _bridge_with(plug) + plug.runtime = _FakeRuntime(error=RuntimeError("socket gone")) + reply = plug.http_pairing(self._action()) + assert reply["status"] == 200 + assert "socket gone" in reply["content"] + + def test_only_GET_is_served(self, plug): + _bridge_with(plug) + assert plug.http_pairing(self._action("POST"))["status"] == 405 + + def test_the_page_escapes_what_it_renders(self, plugin_mod): + """Everything on the page comes from the node or from an exception + string; neither is trusted markup.""" + page = plugin_mod._pairing_html(None, "") + assert "" not in page + assert "<script>" in page + + +# --------------------------------------------------------------------------- +# E7: the agent seams and the bridge install action +# --------------------------------------------------------------------------- +class TestBridgeAgentWiring: + def test_startup_hands_the_export_engine_all_three_seams(self, plugin_mod, plug, + monkeypatch): + """⊗ The seams have to be PASSED, or the agent is never started at all — + the exact "the helper is covered, the real caller is not" shape the E5 + round-two review named.""" + captured = {} + + def _fake_export_bridge(*_args, **kwargs): + captured.update(kwargs) + return Mock() + + monkeypatch.setattr(plugin_mod, "ExportBridge", _fake_export_bridge) + monkeypatch.setattr(plugin_mod, "MatterClient", Mock()) + monkeypatch.setattr(plugin_mod, "CommissionJobs", Mock()) + monkeypatch.setattr(plugin_mod, "HttpApi", Mock()) + monkeypatch.setattr(plugin_mod, "AsyncRuntime", Mock()) + monkeypatch.setattr(plugin_mod, "ServerProcess", Mock()) + plug.debug = False + plug._version = "2026.8.1" + plug.proto = Mock() + plug.registry = Mock() + plug.device_sync = Mock() + plug.server_process = None + plug.matter = None + plug._subscribed_to_devices = False + plug._start_ts = 0.0 + plug.startup() + for seam in ("agent_start", "agent_stop", "agent_diagnose"): + assert captured.get(seam) is not None, f"{seam} was not passed" + + def test_a_fresh_start_builds_no_bridge_process(self, plug): + """⊗ XAC1: constructing one is side-effect free, but building it eagerly + is one ensure_installed away from a plist nobody asked for.""" + assert plug.bridge_process is None + + def test_the_diagnosis_before_any_agent_names_the_real_precondition(self, plug): + assert "export at least one device" in plug._bridge_agent_diagnosis().lower() + + def test_the_diagnosis_reads_the_agents_own_error_log(self, plug): + plug.bridge_process = Mock() + plug.bridge_process.tail_error_log.return_value = "listen EADDRINUSE :::5540" + assert "EADDRINUSE" in plug._bridge_agent_diagnosis() + + def test_the_diagnosis_never_restarts_anything(self, plug): + """launchd owns respawn; a diagnostic that bounced the agent on every + failure streak would turn a crash-loop into an unreadable one.""" + plug.bridge_process = Mock() + plug.bridge_process.tail_error_log.return_value = "boom" + plug._bridge_agent_diagnosis() + plug.bridge_process.restart.assert_not_called() + plug.bridge_process.ensure_installed.assert_not_called() + plug.bridge_process.stop.assert_not_called() + + def test_an_empty_error_log_points_at_the_bridge_install_menu(self, plug): + plug.bridge_process = Mock() + plug.bridge_process.tail_error_log.return_value = None + plug.bridge_process.project_dir = "/Users/x/indigo-matter" + said = plug._bridge_agent_diagnosis() + assert "indigo-matter-bridge" in said and "export bridge" in said + + def test_stop_is_a_stop_not_an_uninstall(self, plug): + """The plist is cheap to keep and re-exporting should not re-derive it; + the storage dir is never touched by either.""" + plug.bridge_process = Mock() + plug.bridge_process.stop.return_value = True + plug._stop_bridge_agent() + plug.bridge_process.stop.assert_called_once() + plug.bridge_process.uninstall.assert_not_called() + + def test_start_rebuilds_the_agent_from_CURRENT_prefs(self, plug, plugin_mod, + monkeypatch): + """A snapshotted agent writes yesterday's ports while reporting success — + the fault menuRestartMatterServer learned the hard way.""" + built = [] + + def _factory(prefs, _logger): + built.append(dict(prefs)) + return Mock(ensure_installed=Mock(return_value=True), ws_port="5581", + matter_port="5540") + + monkeypatch.setattr(plugin_mod.bridge_agent, "BridgeProcess", _factory) + plug.pluginPrefs["bridgeMatterPort"] = "5541" + plug._start_bridge_agent() + assert built[-1]["bridgeMatterPort"] == "5541" + + def test_start_says_nothing_reassuring_when_preflight_failed(self, plug, plugin_mod, + monkeypatch): + """ensure_installed() returning None means the plist was torn down and + the reason logged. There is nothing running to announce.""" + monkeypatch.setattr(plugin_mod.bridge_agent, "BridgeProcess", + lambda *_a, **_k: Mock(ensure_installed=Mock(return_value=None))) + plug._start_bridge_agent() + assert "LaunchAgent is running" not in _logged(plug.logger) + + def test_the_install_action_refuses_a_concurrent_npm_run(self, plug): + """One npm root; two concurrent installs into it corrupt each other.""" + plug._install_thread = Mock(is_alive=Mock(return_value=True)) + plug.menuInstallBridgeNode() + assert "already in progress" in _logged(plug.logger) + + def test_installing_with_nothing_exported_does_NOT_start_the_bridge(self, plug, + plugin_mod, + monkeypatch): + """⊗ XG5. The difference from the controller's install: bringing the agent + up because a package was updated would leave a bridge running for nothing.""" + agent = Mock(install=Mock(return_value=True)) + monkeypatch.setattr(plugin_mod.bridge_agent, "BridgeProcess", lambda *_a, **_k: agent) + plug.pluginPrefs.clear() + plug.exports = ExportStore(lambda: plug.pluginPrefs, plug.logger) + plug._install_bridge_node() + agent.restart.assert_not_called() + assert "nothing is exported yet" in _logged(plug.logger) + + def test_installing_with_an_export_restarts_onto_the_new_version(self, plug, + plugin_mod, + monkeypatch): + """A running LaunchAgent does not pick up new files on disk.""" + agent = Mock(install=Mock(return_value=True), restart=Mock(return_value=True)) + monkeypatch.setattr(plugin_mod.bridge_agent, "BridgeProcess", lambda *_a, **_k: agent) + plug._install_bridge_node() + agent.restart.assert_called_once() + assert "restarted onto the new version" in _logged(plug.logger) + + def test_a_failed_install_changes_nothing(self, plug, plugin_mod, monkeypatch): + agent = Mock(install=Mock(return_value=False)) + monkeypatch.setattr(plugin_mod.bridge_agent, "BridgeProcess", lambda *_a, **_k: agent) + plug._install_bridge_node() + agent.restart.assert_not_called() + assert "Nothing was changed" in _logged(plug.logger) + + +# --------------------------------------------------------------------------- +# The §5.5 readout +# --------------------------------------------------------------------------- +class TestConfigReadout: + def test_it_is_seeded_into_the_dialog(self, plug): + _bridge_with(plug, fabrics=[_fabric(1)]) + values, errors = plug.getPrefsConfigUiValues() + assert values["exportReadout"] + assert errors == {} + + def test_both_callback_spellings_agree(self, plug): + """Indigo 2025.2 carries snake_case aliases alongside the camelCase + ConfigUI callbacks, and which it dispatches on is undocumented.""" + _bridge_with(plug, fabrics=[_fabric(1)]) + assert plug.get_prefs_config_ui_values() == plug.getPrefsConfigUiValues() + + def test_it_names_the_paired_ecosystems_not_the_slot_count(self, plug): + """PRD §5.5: matter.js allows 254 fabrics, so the count is never the + interesting number — WHICH ecosystems hold one is.""" + _bridge_with(plug, fabrics=[_fabric(1), _fabric(2, vendor=0x1217)]) + readout = plug._export_readout() + assert "Apple (index 1)" in readout and "Amazon (index 2)" in readout + + def test_it_reports_an_open_window(self, plug): + _bridge_with(plug, fabrics=[_fabric(1)], window="2026-08-05T12:00:00Z") + assert "window is open until 2026-08-05T12:00:00Z" in plug._export_readout() + + def test_it_reports_the_switch_being_off(self, plug): + _bridge_with(plug, fabrics=[_fabric(1)], enabled=False) + assert "switched off" in plug._export_readout() + + def test_it_distinguishes_not_running_from_not_yet_connected(self, plug): + _bridge_with(plug, active=False) + assert "bridge node is not running" in plug._export_readout() + _bridge_with(plug, active=True, fabrics=None) + assert "not yet connected" in plug._export_readout() + + def test_it_does_no_io(self, plug): + """This runs while the dialog is opening.""" + client = _bridge_with(plug, fabrics=[_fabric(1)]) + plug.getPrefsConfigUiValues() + client.get_status.assert_not_called() + client.get_pairing.assert_not_called() + + def test_it_survives_the_plugin_not_having_started(self, plug): + plug.export_bridge = None + assert "still starting" in plug._export_readout() diff --git a/tests/test_server_process.py b/tests/test_server_process.py index 45a08e0..1d059a3 100644 --- a/tests/test_server_process.py +++ b/tests/test_server_process.py @@ -1015,17 +1015,51 @@ def test_restart_reaps_orphan_before_bootstrap(tmp_path, mock_logger): assert ("TERM", "545") in runner.signals -def test_remove_package_deletes_node_modules_but_keeps_storage(sp): +def test_remove_package_uninstalls_via_npm_and_keeps_storage(sp): + """E7: the clean reinstall is `npm uninstall `, not `rm -rf node_modules`. + + The shared install root holds a second agent's package since E7, so the old + wholesale delete took the bridge down with the controller. npm is preferred over + deleting the directory because it also prunes the transitive deps nothing else + needs — see LaunchAgent.remove_package. + """ os.makedirs(sp.storage_path, exist_ok=True) node_modules = os.path.join(sp.project_dir, "node_modules") assert os.path.isdir(node_modules) + with open(os.path.join(sp.resolved_bin_dir, "npm"), "w", encoding="utf-8") as handle: + handle.write("#!/bin/sh\n") sp._run = FakeRunner() sp.remove_package() - assert not os.path.exists(node_modules) # package blown away + uninstalls = [call for call in sp._run.calls + if "uninstall" in call and "matter-server" in call] + assert uninstalls, f"expected an npm uninstall, got {sp._run.calls}" + assert "--prefix" in uninstalls[0] and sp.project_dir in uninstalls[0] assert os.path.isdir(sp.storage_path) # storage is sacred — pairings survive assert "bootout" in sp._run.subcommands() # server stopped first +def test_remove_package_falls_back_to_deleting_only_its_own_dir(sp, monkeypatch): + """No npm (or npm refuses): delete OUR package dir — never the whole root. + + package-lock.json describes the entire install root, not one package, so it is + left alone too: deleting it on behalf of one agent unpins the other's transitive + dependency tree at its next install. + """ + ours = os.path.join(sp.project_dir, "node_modules", "matter-server") + sibling = os.path.join(sp.project_dir, "node_modules", "indigo-matter-bridge") + os.makedirs(sibling, exist_ok=True) + lock = os.path.join(sp.project_dir, "package-lock.json") + with open(lock, "w", encoding="utf-8") as handle: + handle.write("{}") + assert os.path.isdir(ours) + sp._run = FakeRunner() + monkeypatch.setattr(sp, "_npm_uninstall", lambda: False) + sp.remove_package() + assert not os.path.exists(ours) + assert os.path.isdir(sibling) # the other agent's package survives + assert os.path.exists(lock) # and so does the shared lock file + + def test_tail_error_log_returns_last_lines(sp): os.makedirs(sp.log_dir, exist_ok=True) with open(os.path.join(sp.log_dir, "matter-server.err.log"), "w") as handle: From 444e8e5b5d1ec356b4767d4e2fb96c42d4ca598e Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Wed, 5 Aug 2026 23:13:28 +0100 Subject: [PATCH 2/3] fix: resolve the Info.plist conflict left by a mid-rebase commit The E6+E7 rebase onto main conflicted on PluginVersion (main carried 2026.8.2 from the CI change, the branch 2026.8.1) and the markers were committed. Resolved to 2026.8.3, above both. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JxqGhP3DcENf68AZK21U4S --- indigo-matter.indigoPlugin/Contents/Info.plist | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/indigo-matter.indigoPlugin/Contents/Info.plist b/indigo-matter.indigoPlugin/Contents/Info.plist index 337223c..5140db5 100644 --- a/indigo-matter.indigoPlugin/Contents/Info.plist +++ b/indigo-matter.indigoPlugin/Contents/Info.plist @@ -20,11 +20,7 @@ IwsApiVersion 1.0.0 PluginVersion -<<<<<<< HEAD - 2026.8.2 -======= - 2026.8.1 ->>>>>>> feat(export): E6+E7 — pairing UX, fabric management, bridge LaunchAgent + 2026.8.3 ServerApiVersion 3.6 From a6f10741448f4dfc9116114b0ab1940fa1c099d5 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Thu, 6 Aug 2026 00:21:39 +0100 Subject: [PATCH 3/3] fix(export): harden E6+E7 per PR #128 three-review batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reviewers converged on one family: claiming success from the wrong signal. Every message below was true of a nearby fact and false of what the user was told. CRITICAL - A1 First-run dead end: menuInstallBridgeNode went install() → restart() with no ensure_installed(), but the plist is written only by _start_bridge_agent, which cannot pass preflight before the package exists. Result on a fresh Mac: two wrong messages and no bridge. Now ensure_installed() first, restart() only when it returns False. The old test asserted against Mock(restart=True); it is now a real BridgeProcess. - A2 LaunchAgent.run_state()/is_alive() distinguish not_loaded / running / loaded_not_running / unknown. _start_bridge_agent no longer prints "the LaunchAgent is running" off `ensure_installed() is not None`; start() and restart() stop reporting a loaded-but-dead job as started. - A3 remove_fabric answers {removed, remaining} on both sides (protocol, golden frames, docs) and emits fabrics_changed even when the index held nothing — the picker is built from a cached list, so a stale index is the designed path. The menu branches on it and re-reads the fabric set. - A4 The §5.5 readout consults client.connected/attached instead of `bridge.active` ("a client object exists"), and dates its fabric list. - A5 _stop_bridge_agent uninstalls rather than stops: the plist carries RunAtLoad, so the old behaviour started an unpaired bridge with an empty allow-list after every reboot. - A6/A7/A8/A9 test-only: the bridged identity is now pinned against a real ServerNode's BasicInformation; the XAC7 debt-path agent stop, the disconnected-client gates, and closedPrefsConfigUi's exports_changed re-run all have tests that die when the code goes. SECURITY - S1 The pairing page and the menu log line now state that the passcode is a live credential over IWS, which authenticates only if enabled; INSTALL gains "Before you pair the export bridge". No auth built into the handler. IMPORTANT - I1 Vendor table verified against the CSA DCL and the vendored matter.js: 0x1075 is not an issued id, 0x100B is Signify not Google (0x6006), and Apple's second fabric 0x1384 was missing. Pinned against matter.js source. - I2 no-selection row + seeding on the destructive unpair picker. - I3 AgentSpec.install_menu — messages named the npm package, not the menu. - I4/I5 stop failures are logged and the two Falses distinguished; the diagnosis asks preflight() and stops calling never-truncated lines "Recent". - I6 remove_package reports the truth by looking at the package directory. - I7 window_expires_at is compared against now and re-derived on attach. - I8 the un-export warning no longer promises a retry that does not exist. - I9 clean-reinstall and stop menus for the bridge. - I10 bundle scan: no .js/.ts ships inside indigo-matter.indigoPlugin. MEDIUM - M1 dead Plugin._bridge_agent() removed. M2 the rebuild test now calls twice with changed prefs. M3/M4/M5 getWebServerURL, note_window_opened, _escape(None), package.json name, the _agent_started latch and the UI-thread deadlines are all pinned. M6 PRD/HANDOVER reconciled to Info.plist 2026.8.3 and to the measured suite counts (the 345 TS claim was wrong; npm test reported 347 before this batch). M7 bridge-node README. M8 HANDOVER records that bridgedInfoFor's identity expansion arrived in this PR. Suites: 2243 Python, 348 TS. pylint 9.42. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JxqGhP3DcENf68AZK21U4S --- bridge-node/README.md | 100 +++ bridge-node/src/node.ts | 49 +- bridge-node/src/protocol.ts | 24 +- bridge-node/src/ws-server.ts | 10 +- bridge-node/test/fixture-shapes.ts | 12 +- bridge-node/test/fixtures.test.ts | 2 + bridge-node/test/integration.test.ts | 63 +- bridge-node/test/registry.test.ts | 10 +- bridge-node/test/stub-bridge.ts | 6 +- docs/BRIDGE_PROTOCOL.md | 16 +- docs/HANDOVER.md | 102 ++- docs/INSTALL.md | 30 + docs/PRD-indigo-matter-export.md | 4 +- .../Contents/Server Plugin/MenuItems.xml | 36 + .../Contents/Server Plugin/bridge_agent.py | 9 + .../Contents/Server Plugin/bridge_client.py | 14 +- .../Contents/Server Plugin/bridge_protocol.py | 39 + .../Contents/Server Plugin/export_bridge.py | 98 ++- .../Contents/Server Plugin/launch_agent.py | 101 ++- .../Contents/Server Plugin/plugin.py | 428 +++++++++-- .../Contents/Server Plugin/server_process.py | 9 + tests/fakes.py | 9 + tests/fixtures/bridge_protocol/frames.json | 21 +- tests/test_bridge_agent.py | 53 ++ tests/test_export_agent_wiring.py | 208 +++++- tests/test_export_bridge.py | 6 +- tests/test_launch_agent.py | 128 ++++ tests/test_pairing_menu.py | 680 +++++++++++++++++- tests/test_plugin_behaviour.py | 25 +- tests/test_xac10_no_matter_js.py | 47 ++ 30 files changed, 2190 insertions(+), 149 deletions(-) create mode 100644 bridge-node/README.md diff --git a/bridge-node/README.md b/bridge-node/README.md new file mode 100644 index 0000000..839581f --- /dev/null +++ b/bridge-node/README.md @@ -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. diff --git a/bridge-node/src/node.ts b/bridge-node/src/node.ts index 6466963..7c50c1d 100644 --- a/bridge-node/src/node.ts +++ b/bridge-node/src/node.ts @@ -45,6 +45,7 @@ import { type PairingReport, ProtocolError, RefuseReason, + type RemoveFabricResult, type RemoveResult, type StatusReport, type UpsertResult, @@ -700,11 +701,19 @@ export class BridgeNode implements BridgeFacade { * `OperationalCredentialsServer.removeFabric` opens with `assertRemoteActor`, * so it cannot be invoked from an offline agent at all. * - * An index with no fabric behind it succeeds. The plugin picks an index out - * of a `get_pairing` readout that is by definition a moment old, and the way - * it goes stale is the ecosystem unpairing itself — so "it is already gone" - * is the request being granted, not refused. Said out loud rather than - * silently, because the other way to get here is a typo. + * An index with no fabric behind it succeeds — but it succeeds as + * `{removed: false}`, and that distinction is the whole reason this returns + * anything. The plugin picks an index out of a `get_pairing` readout that is + * by definition a moment old, and the way it goes stale is the ecosystem + * unpairing itself, so "it is already gone" is the request being granted, + * not refused. Answering `{}` for both made the plugin tell the user "that + * ecosystem has been unpaired, every accessory has been removed" over a + * no-op — and, because the early return also skipped {@link noteFabrics}, + * no `fabrics_changed` followed and the ghost row stayed in the plugin's + * cached list (which is what its picker is built from) forever. Both halves + * are fixed here: the outcome is reported, and the fabric set is + * re-published either way so a stale cache is corrected by the very request + * that tripped over it. * * **Removing the LAST fabric is a factory reset that we did not perform.** * matter.js's `CommissioningServer` watches `commissioned` and, when the @@ -717,12 +726,17 @@ export class BridgeNode implements BridgeFacade { * handles it, from {@link noteFabrics}, so the route where an ecosystem * unpairs *us* is covered too. */ - async removeFabric(fabricIndex: number): Promise { + async removeFabric(fabricIndex: number): Promise { const fabrics = this.server.env.get(FabricManager); const fabric = fabrics.maybeFor(FabricIndex(fabricIndex)); if (fabric === undefined) { this.log(`No fabric at index ${fabricIndex}; nothing to remove`); - return; + // Re-publish the set the caller's index came from. Nothing changed + // HERE, but the asker demonstrably holds a list that says otherwise, + // and this is the only moment we know that. `"unchanged"` names the + // §5 `change` for what it is rather than borrowing an action word. + this.noteFabrics("unchanged"); + return { removed: false, remaining: this.fabricCountOrNull() }; } // `leave` rather than `delete`: it flushes subscriptions and emits the // leave event first, which is how a controller learns it was removed @@ -732,21 +746,34 @@ export class BridgeNode implements BridgeFacade { // Inside a try: the leave has already happened and succeeded, and a // read of server state that a self-reset is concurrently rebuilding // must not turn a completed removal into a failed command. - let remaining: number; + let remaining: number | null; try { remaining = this.fabrics().length; + this.log(`Removed fabric ${fabricIndex} (${remaining} remaining)`); } catch (error) { + remaining = null; this.log( `Removed fabric ${fabricIndex}; the remaining fabric count is unavailable ` + `(${describeError(error)})`, ); - return; } - this.log(`Removed fabric ${fabricIndex} (${remaining} remaining)`); // Re-checked rather than assumed: `fabric.leave()` does not necessarily // produce a `fabricsChanged` observation we saw, and the empty-set case - // is the one that matters (see noteLastFabricGone). + // is the one that matters (see noteLastFabricGone). Runs on the + // count-unavailable path too — it is precisely the last-fabric leave + // that makes the count unreadable, and that is the case whose witness + // must be cleared. this.noteFabrics(); + return { removed: true, remaining }; + } + + /** The fabric count, or `null` if server state cannot be read right now. */ + private fabricCountOrNull(): number | null { + try { + return this.fabrics().length; + } catch { + return null; + } } /** diff --git a/bridge-node/src/protocol.ts b/bridge-node/src/protocol.ts index 2bebc7d..1a643e6 100644 --- a/bridge-node/src/protocol.ts +++ b/bridge-node/src/protocol.ts @@ -231,6 +231,28 @@ export interface FabricInfo { vendorId: number; } +/** + * §3.9's answer — what the removal actually DID, not merely that it returned. + * + * `remove_fabric` used to answer `{}` whether it dropped a fabric or found + * nothing at the index, and the plugin then told the user "that ecosystem has + * been unpaired. Every accessory has been removed" over a node-side no-op. The + * stale-index case is not an edge: the plugin's picker is built from a CACHED + * fabric list, so an ecosystem that unpaired *us* between the last event and + * the dialog opening is the designed way to land here. + * + * `remaining` is `null` — never a number — when the count could not be read. + * The fabric set is legitimately mid-rebuild after a last-fabric leave + * (matter.js factory-resets itself), and reporting a made-up `0` there would + * be the same class of lie this type exists to end. + */ +export interface RemoveFabricResult { + /** False when there was no fabric at that index: the request was already true. */ + removed: boolean; + /** Fabrics the node holds now, or `null` when the count could not be read. */ + remaining: number | null; +} + /** §4.3 */ export interface StatusReport { commissioned: boolean; @@ -347,7 +369,7 @@ export interface BridgeFacade { */ endpointMapRefusal(): string | undefined; /** §3.9 — drop one ecosystem's fabric. */ - removeFabric(fabricIndex: number): Promise; + removeFabric(fabricIndex: number): Promise; /** §3.10 — wipe commissioning credentials and advertise fresh. */ factoryReset(preserveEndpointNumbers: boolean): Promise; /** §3.11 — adopt the live endpoint numbers as the new persisted map. */ diff --git a/bridge-node/src/ws-server.ts b/bridge-node/src/ws-server.ts index 325c54f..7f0b45a 100644 --- a/bridge-node/src/ws-server.ts +++ b/bridge-node/src/ws-server.ts @@ -89,10 +89,12 @@ export class BridgeWsServer { ); this.#handlers.set("set_state", async args => this.handleSetState(args)); this.#handlers.set("set_reachable", async args => this.handleSetReachable(args)); - this.#handlers.set("remove_fabric", async args => { - await this.options.bridge.removeFabric(parseFabricIndex(args.fabricIndex)); - return {}; - }); + // Returns the node's own outcome rather than a blanket `{}`: the plugin + // reports this removal to the user in the past tense, and "there was no + // fabric at that index" has to be able to reach it (§3.9). + this.#handlers.set("remove_fabric", async args => + this.options.bridge.removeFabric(parseFabricIndex(args.fabricIndex)), + ); this.#handlers.set("factory_reset", async args => { await this.options.bridge.factoryReset(parsePreserveEndpointNumbers(args.preserveEndpointNumbers)); return {}; diff --git a/bridge-node/test/fixture-shapes.ts b/bridge-node/test/fixture-shapes.ts index 7a55ead..b7e1d91 100644 --- a/bridge-node/test/fixture-shapes.ts +++ b/bridge-node/test/fixture-shapes.ts @@ -225,8 +225,16 @@ export const openWindowInternal = { details: "mDNS advertiser is down", } satisfies ErrorFrame; -/** §3.9/§3.10 both answer with an empty result; named for what they mean. */ -export const removeFabricResult = {}; +/** + * §3.9's two outcomes. It stopped answering `{}` when it turned out that "there + * was no fabric at that index" and "the ecosystem has been unpaired" were + * reaching the user as the same sentence — over a picker built from a cached + * fabric list, which makes the stale index the designed path rather than a typo. + */ +export const removeFabricResult = { removed: true, remaining: 1 }; +export const removeFabricAlreadyGone = { removed: false, remaining: 2 }; + +/** §3.10 still answers with an empty result; named for what it means. */ export const factoryResetResult = {}; /** diff --git a/bridge-node/test/fixtures.test.ts b/bridge-node/test/fixtures.test.ts index 7847b02..39bb282 100644 --- a/bridge-node/test/fixtures.test.ts +++ b/bridge-node/test/fixtures.test.ts @@ -70,6 +70,8 @@ describe("golden fixtures match their typed mirror", () => { ["open_commissioning_window internal", golden.open_window_internal.response, shapes.openWindowInternal], ["remove_fabric result", golden.remove_fabric.response.result, shapes.removeFabricResult], + ["remove_fabric (already gone) result", golden.remove_fabric_already_gone.response.result, + shapes.removeFabricAlreadyGone], ["factory_reset result", golden.factory_reset.response.result, shapes.factoryResetResult], ["factory_reset (discard map) result", golden.factory_reset_discard_map.response.result, shapes.factoryResetResult], diff --git a/bridge-node/test/integration.test.ts b/bridge-node/test/integration.test.ts index bd0ed53..1da6119 100644 --- a/bridge-node/test/integration.test.ts +++ b/bridge-node/test/integration.test.ts @@ -24,7 +24,15 @@ import { after, describe, it } from "node:test"; import { type Endpoint, Logger } from "@matter/main"; import { endpointIdFor } from "../src/endpoints.js"; -import { BridgeNode, matterJsVersion } from "../src/node.js"; +import { + BridgeNode, + HARDWARE_VERSION, + HARDWARE_VERSION_STRING, + matterJsVersion, + PRODUCT_NAME, + VENDOR_ID, + VENDOR_NAME, +} from "../src/node.js"; import { PROTOCOL_VERSION } from "../src/protocol.js"; import { BridgeWsServer } from "../src/ws-server.js"; import { TestClient } from "./client.js"; @@ -147,6 +155,41 @@ describe("plugin ⇄ node, end to end", () => { const lounge = [...aggregator.parts].find(part => part.id === endpointIdFor(LOUNGE)); assert.ok(lounge !== undefined, "the attach did not build the lounge endpoint"); assert.equal((lounge.stateOf("levelControl") as Record).currentLevel, 254); + const kitchen = [...aggregator.parts].find(part => part.id === endpointIdFor(KITCHEN)); + assert.ok(kitchen !== undefined, "the attach did not build the kitchen endpoint"); + + // PRD §5.3 — every bridged child publishes the ROOT's identity. + // + // ⊗ This is the only test that can fail on `node.ts`'s + // `bridgedIdentity`. `registry.test.ts` asserts a hand-written copy + // of the same object literal, so the two agreed with each other and + // with nothing else: publishing Apple's vendor id (0x1349) on every + // bridged accessory left the whole suite green. Here the child is + // read off a REAL ServerNode and compared with that node's own + // BasicInformation, which is the property the docstring claims — + // "an ecosystem is never shown two answers". + const root = bridge.server.state.basicInformation as Record; + assert.equal(root.vendorName, VENDOR_NAME); + assert.equal(Number(root.vendorId), VENDOR_ID); + assert.equal(root.productName, PRODUCT_NAME); + assert.equal(root.hardwareVersion, HARDWARE_VERSION); + assert.equal(root.hardwareVersionString, HARDWARE_VERSION_STRING); + assert.equal(root.softwareVersionString, BRIDGE_VERSION); + for (const child of [kitchen, lounge]) { + const info = child.stateOf("bridgedDeviceBasicInformation") as Record; + for (const field of [ + "vendorName", + "productName", + "hardwareVersion", + "hardwareVersionString", + "softwareVersion", + "softwareVersionString", + ] as const) { + assert.equal(info[field], root[field], `${child.id}.${field} must equal the root's`); + } + // Branded on both sides; compare the numbers they carry. + assert.equal(Number(info.vendorId), Number(root.vendorId), `${child.id}.vendorId`); + } // §6.4: our own write must not come back as a command. await assert.rejects(client.next(200), "a local set_state echoed onto the wire"); @@ -165,6 +208,24 @@ describe("plugin ⇄ node, end to end", () => { event: "command", data: { indigoDeviceId: LOUNGE, command: "onOff", args: { value: false } }, }); + + // §3.9 over an index that holds nothing — the ONE removal outcome a + // never-commissioned node can reach, and the one that mattered: + // ⊗ this used to answer `{}`, indistinguishable from a real removal, + // and the plugin reported "that ecosystem has been unpaired. Every + // accessory has been removed" over a node-side no-op. It also used + // to return before `noteFabrics`, so the caller's stale list — which + // is what its picker is built from — was never corrected. + client.send({ message_id: "i4", command: "remove_fabric", args: { fabricIndex: 7 } }); + assert.deepEqual( + await client.next(), + { event: "fabrics_changed", data: { fabrics: [], change: "unchanged" } }, + "an already-gone index must still re-publish the fabric set", + ); + assert.deepEqual(await client.next(), { + message_id: "i4", + result: { removed: false, remaining: 0 }, + }); } finally { client.close(); await server.close(); diff --git a/bridge-node/test/registry.test.ts b/bridge-node/test/registry.test.ts index f801513..8d3e276 100644 --- a/bridge-node/test/registry.test.ts +++ b/bridge-node/test/registry.test.ts @@ -44,8 +44,14 @@ const PRODUCT_NAME = "Indigo Matter Bridge"; /** * The bridge identity every child endpoint publishes, as node.ts builds it. * Spelled out here rather than imported from node.ts so these tests keep their - * distance from the ServerNode wiring — and so a field going missing from the - * real one is a test failure rather than a silently-shared blank. + * distance from the ServerNode wiring. + * + * **This literal pins nothing about node.ts, and used to claim it did.** It is a + * hand-written copy, so it and `node.ts`'s `bridgedIdentity` agreed with each + * other and with nothing else — publishing Apple's vendor id on every bridged + * accessory left this suite green. What these tests actually pin is that the + * registry PROPAGATES whatever identity it is given; that the identity is the + * root node's own is pinned in `integration.test.ts`, against a real ServerNode. */ const BRIDGE_IDENTITY = { vendorName: "simons-plugins", diff --git a/bridge-node/test/stub-bridge.ts b/bridge-node/test/stub-bridge.ts index c645f38..e497123 100644 --- a/bridge-node/test/stub-bridge.ts +++ b/bridge-node/test/stub-bridge.ts @@ -19,6 +19,7 @@ import { type FabricInfo, type PairingReport, ProtocolError, + type RemoveFabricResult, type RemoveResult, type RoleValue, type StatusReport, @@ -101,6 +102,8 @@ export interface GoldenFrames { * of them are about. */ remove_fabric: GoldenExchange; + /** §3.9 over a stale index — the outcome `{}` used to hide. */ + remove_fabric_already_gone: GoldenExchange; factory_reset: GoldenExchange; factory_reset_discard_map: GoldenExchange; rebuild_endpoint_map: GoldenExchange; @@ -277,8 +280,9 @@ export class StubBridge implements BridgeFacade { return this.refusal; } - async removeFabric(fabricIndex: number): Promise { + async removeFabric(fabricIndex: number): Promise { this.removedFabrics.push(fabricIndex); + return structuredClone(golden.remove_fabric.response.result) as RemoveFabricResult; } async factoryReset(preserveEndpointNumbers: boolean): Promise { diff --git a/docs/BRIDGE_PROTOCOL.md b/docs/BRIDGE_PROTOCOL.md index 91fbf25..d30097e 100644 --- a/docs/BRIDGE_PROTOCOL.md +++ b/docs/BRIDGE_PROTOCOL.md @@ -217,7 +217,21 @@ commissioner completes. {"command": "remove_fabric", "args": {"fabricIndex": 2}} ``` -Result: `{}`. Emits `fabrics_changed` (§5). +Result: `{"removed": true, "remaining": 1}`. Emits `fabrics_changed` (§5). + +**`removed` is the point of the result.** An index with no fabric behind it +answers `{"removed": false, "remaining": N}` rather than failing — the caller's +index comes from a `get_pairing` readout that is by definition a moment old, and +the way it goes stale is the ecosystem unpairing *itself*, so "it is already +gone" is the request being granted. It is not the same thing as removing a live +fabric, though, and a caller that reports this to a user in the past tense must +be able to tell them apart. `remaining` is the fabric count after the call, or +`null` when server state could not be read (legitimate mid-reset). + +`fabrics_changed` is emitted **either way** — with `change: "unchanged"` on the +already-gone path. Nothing changed on the node, but a caller that asked to +remove a fabric that is not there is demonstrably holding a stale list, and this +is the only moment the node knows that. **Removing the last fabric is a factory reset the node did not ask for.** matter.js watches its own `commissioned` state and, when the fabric set diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 2ddd5a4..84ba62b 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,6 +1,6 @@ # indigo-matter — Build Handover -**Last updated:** 2026-08-05 20:11 UTC +**Last updated:** 2026-08-05 23:21 UTC **Active work:** `feat/e6-e7-pairing-and-agent` — E6 + E7, the last functional export milestones (E8 is docs only). See the section immediately below; the `main` summary in this header describes the last merge, not that branch. @@ -14,9 +14,99 @@ export milestones (E8 is docs only). See the section immediately below; the ## 2026-08-05 — E6 + E7: pairing UX, fabric management, the bridge LaunchAgent -Plugin `2026.8.1`, bridge-node `0.5.0`. Suites: **2171 Python**, **345 TS** +Plugin `2026.8.3`, bridge-node `0.5.0`. Suites: **2243 Python**, **348 TS** (from 2056/344). pylint 9.41. +> The counts above are the ones this branch actually produces. Two earlier +> numbers here were wrong and both were wrong the *reassuring* way: the TS suite +> was recorded as 345 (`npm test` reported 347 before this batch, 348 after) and +> the plugin version as `2026.8.1` while `Info.plist` said `2026.8.3`. A suite +> count is a claim about coverage and a version is what a user reads back to you +> in a bug report — both are only useful if they are measured. + +### The PR #128 review batch (2026-08-05) — read this before touching E6/E7 + +Three reviewers converged on one family of faults, and it is worth naming +because it will recur: **claiming success from the wrong signal.** Every one of +these shipped a message that was true of some *nearby* fact and false of the +thing the user was told. + +| Signal that was read | What it actually means | What was claimed | +|---|---|---| +| `ensure_installed() is not None` | "preflight passed" | "the LaunchAgent is running" | +| `is_running()` | "launchd knows this label" | "the process is up" | +| `bridge.active` | "a client object exists" | "the bridge node is running" | +| `remove_fabric` returned | "the frame came back" | "that ecosystem has been unpaired" | +| `remove_package` returned | it ran | "Removed the … package" | + +The corrections, in case any of them looks like an over-reaction later: + +* **`LaunchAgent.run_state()`** now returns four distinguishable states and + `is_alive()` is the positive one. `is_running()` keeps its old name **and its + old meaning** ("is loaded") because the bootout/leave-alone callers want + exactly that — a loaded-but-DEAD job (issue #104's fault 2) passes it, which is + the whole point of the split. +* **The first-run dead end.** The bridge's plist is written by exactly one place + (`_start_bridge_agent`), which cannot get past its own preflight before the + package exists. So on a fresh machine: export → preflight fails → no plist → + Install/update → npm succeeds → `restart()` finds no plist → "nothing to + restart, fix the problem reported above" (there was none) → "the restart + FAILED — the old version may still be running" (nothing was). `menuInstallBridgeNode` + now calls `ensure_installed()` first and only `restart()`s when it returns + `False` (i.e. launchd left an old job alone). The old test could not catch it: + it asserted against `Mock(restart=…True)`, which has a plist by virtue of being + a Mock. It is now a real `BridgeProcess` on a `tmp_path` home. +* **`_stop_bridge_agent` uninstalls rather than stops.** Keeping the plist looked + thrifty until you notice it carries `RunAtLoad: True`: after the next login + launchd started an unpaired bridge node, with an EMPTY allow-list, advertising + on 5540, that the plugin never started and — because the XAC1 latch is false in + a session that never brought it up — would never stop. XG5's guarantee has to + survive a reboot. Re-deriving the plist costs one `ensure_installed`. +* **§3.9 answers `{removed, remaining}`** on both sides (protocol, fixtures, + docs). The already-gone case is not an edge: the unpair picker is built from + the *cached* fabric list, so an ecosystem that unpaired us since the dialog + opened is the designed way to reach it. The node also emits `fabrics_changed` + (`change: "unchanged"`) on that path, because a caller asking to remove a + fabric that is not there is demonstrably holding a stale list and this is the + only moment the node knows. +* **The vendor-ID table was wrong on the destructive picker.** `0x1075` is not an + issued vendor id at all (SmartThings is `0x110A`) and `0x100B` is Signify, not + Google (`0x6006`). Apple's *second* fabric (`0x1384`, "Apple Keychain" — the + one ADR-0005 predicted from the observed three-fabric count) was missing + entirely. Every entry is now verified against the CSA's Distributed Compliance + Ledger, and `test_export_agent_wiring.py` pins the overlap against the + **vendored matter.js source** rather than mirroring our own table. On a picker + whose Execute button removes every exported accessory from the chosen + ecosystem, a wrong name reads as the right ecosystem. + +**Scope note, so it is not attributed to the wrong milestone later:** +`bridgedInfoFor`'s identity expansion — every bridged child publishing the full +`BridgedIdentity` (vendor name/id, product name, hardware and software versions) +rather than just label/serial/uniqueId/reachable — arrived in **this PR (#128, +E6+E7)**, not in E4 with the role factories it sits beside in `endpoints.ts`. +Until this batch nothing pinned it to the root node's `BasicInformation`: +`registry.test.ts` asserted a hand-written copy of the same literal (its comment +claimed otherwise and has been corrected), so publishing Apple's vendor id on +every child left the suite green. `integration.test.ts` now compares each child +against a real `ServerNode`'s own `basicInformation`. + +**Security note, and it needs a decision from Simon, not from the code.** The +pairing page serves a **live commissioning passcode** over IWS, which +authenticates only if the user has switched authentication on. While a window is +open, anyone who can reach that URL can commission the bridge — and every +exported Indigo device — onto *their* Apple Home, Alexa or Google account. +Building auth into the handler was deliberately **not** attempted (IWS owns +authentication; a second scheme underneath it is worse than none). What this +batch does instead is say so, prominently, on the page itself and in the menu's +log line. See `docs/INSTALL.md` → "Before you pair the export bridge". + +**Two new bridge menu items** close the gap where the controller had recovery +exits and the bridge had none: "Reinstall the Matter export bridge (clean)…" +(safe only because `remove_package` became per-package in E7) and "Stop the +Matter export bridge…" — for the user who disables the plugin and is otherwise +left with a running node and no UI at all, since the allow-list lever needs the +plugin to be running. + These are the last two *functional* milestones. E8 is docs. ### E6 — the user surface for protocol that already existed @@ -278,12 +368,14 @@ was **removed** (flagged deliberately — it is what blocks `npm publish`), `files` is limited to `dist`, `main` is `dist/main.js` (which is what `bridge_agent.DEFAULT_BRIDGE_ENTRY` expects), `engines.node` is `>=22.13.0`, and a `prepublishOnly` script runs `clean` then `build` so a stale `dist` can never -ship. `npm pack --dry-run` produces 34 files / ~112kB. +ship. `npm pack --dry-run` produces 35 files / ~116kB — the extra one is +`README.md`, which npm always includes regardless of `files` and which was +missing until this batch (the package's npm page was blank). ```sh cd bridge-node npm login # Simon's npm account — nobody else can do this step -npm test # 345 tests; publishing an untested build is the one unrecoverable mistake +npm test # 348 tests; publishing an untested build is the one unrecoverable mistake npm publish --access public ``` @@ -348,7 +440,7 @@ the next: 1. **Publish `indigo-matter-bridge@0.5.0`** (above). Without it step 4 fails. For a dry run, use the local-install workaround instead. -2. **Install the plugin bundle** — `2026.8.1`. This is an *update* to an +2. **Install the plugin bundle** — `2026.8.3`. This is an *update* to an existing install, so a copy of the changed files plus a plugin restart is enough; only a first-time install needs the double-click. 3. **Configure ▸ Matter export.** Confirm the new Export section renders, that diff --git a/docs/INSTALL.md b/docs/INSTALL.md index b54703d..d2681ec 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -263,6 +263,36 @@ The plugin defaults the managed server to `--listen-address 127.0.0.1` (loopback **matter-server listen address** config field. **Keep it on loopback** unless matter-server runs on a different host and you have a trusted firewall in front of it. +### Before you pair the export bridge + +**Turn on Indigo Web Server authentication first.** + +*Plugins ▸ Matter ▸ Pair Matter Bridge…* opens a Matter commissioning window and +writes a **live pairing passcode** to the Event Log, together with the URL of a +page that shows the same code and its QR payload. That page is served by the +Indigo Web Server, which asks for a password **only if you have switched +authentication on**. + +While the window is open — up to 15 minutes — anyone who can reach that URL, or +who reads that code, can add the bridge to **their own** Apple Home, Alexa or +Google account. What they get is every Indigo device you export: they can see its +state and they can operate it. Removing them afterwards means *Plugins ▸ Matter ▸ +Unpair an Ecosystem…*, and you would first have to notice. + +So, in order: + +1. Turn on authentication in Indigo's Web Server preferences (Indigo ▸ + Preferences ▸ Web Server), or make sure the reflector is the only route in. +2. Only then open a pairing window. +3. Do not paste the pairing URL or the manual code into a chat, an email or a + forum post — treat both as the credential they are. +4. Pair the ecosystem you meant to, and let the window close (it expires by + itself; it also closes the moment an ecosystem completes commissioning). + +The plugin cannot enforce any of this: authentication belongs to the Indigo Web +Server, and a second password scheme underneath it would be worse than none. It +does say so on the page and in the log line. + --- ## Troubleshooting diff --git a/docs/PRD-indigo-matter-export.md b/docs/PRD-indigo-matter-export.md index 86a3a96..b4c1579 100644 --- a/docs/PRD-indigo-matter-export.md +++ b/docs/PRD-indigo-matter-export.md @@ -373,8 +373,8 @@ not slot arithmetic. Per-export settings live in the §5.1 dialog. | E3 | Relay + dimmer export | 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) | | E4 | Sensors + thermostat export | Mapping table complete for v1 | | E5 | Endpoint persistence | XAC5 — the highest-risk correctness requirement | -| E6 | Pairing/unpairing UX + fabric readout — **BUILT** (2026-08-05, plugin `2026.8.1`) | §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's live pairing is unverified** — it needs jarvis and a real ecosystem | -| E7 | launchd agent + failure recovery — **BUILT** (2026-08-05, plugin `2026.8.1`, 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. **`indigo-matter-bridge` is not yet on the npm registry** — publishing is Simon's action (`docs/HANDOVER.md` → "Publishing the bridge node"), and until then the install action cannot resolve the pinned spec | +| 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's live pairing is unverified** — it needs jarvis and a real ecosystem | +| 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. **`indigo-matter-bridge` is not yet on the npm registry** — publishing is Simon's action (`docs/HANDOVER.md` → "Publishing the bridge node"), and until then the install action cannot resolve the pinned spec | | E8 | Docs | `INSTALL.md` export section, uncertified-prompt explanation, ecosystems-untested note (§10), `MATTER.md` outbound architecture | **E0 is the whole validation loop.** It answers the only question that can kill diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/MenuItems.xml b/indigo-matter.indigoPlugin/Contents/Server Plugin/MenuItems.xml index c4c066d..0051392 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/MenuItems.xml +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/MenuItems.xml @@ -260,6 +260,42 @@ If you unpair the LAST ecosystem the bridge resets itself completely and starts Install/update the Matter export bridge menuInstallBridgeNode
+ + + Reinstall the Matter export bridge (clean)… + menuReinstallBridgeNodeClean + + + + + + + + + + + Stop the Matter export bridge… + menuStopBridgeNode + + + + + + + + + Export fabric backup… menuExportFabricBackup diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_agent.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_agent.py index 1d230b5..5cd388c 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_agent.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_agent.py @@ -68,6 +68,14 @@ BRIDGE_OUT_LOG = "bridge-node.log" BRIDGE_ERR_LOG = "bridge-node.err.log" +#: The EXACT wording of ``MenuItems.xml``'s ``installBridgeNode`` item. Every +#: message that sends a user there used to interpolate the package name instead, +#: producing "Plugins ▸ Matter ▸ Install/update indigo-matter-bridge" — a menu +#: that does not exist. It fires on the first-run path (no package, so no plist, +#: so the preflight error), where a name the user cannot find in the menu is the +#: difference between a fixable state and giving up. +BRIDGE_INSTALL_MENU = "Install/update the Matter export bridge" + #: Matter UDP port the node binds (PRD §4.4). 5540 is Matter's default and the #: one matter.js's ECOSYSTEMS.md records as Alexa's hard requirement; the pref is #: the escape hatch when another Matter stack on the same Mac already holds it. @@ -198,6 +206,7 @@ def __init__( # 5581. 5540 is UDP and a clash there is the node's own # first-class §7 failure, reported by the node at startup. port=int(ws_port), + install_menu=BRIDGE_INSTALL_MENU, ), prefs, logger, home=resolved_home, npx_path=npx_path, diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py index 98ca3bc..80e232d 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py @@ -630,9 +630,17 @@ async def open_commissioning_window( result = await self._request_frame(self.proto.build_open_window(duration_seconds), timeout) return bridge_protocol.parse_window(result) - async def remove_fabric(self, fabric_index: int, timeout: float = LONG_TIMEOUT) -> None: - """Drop one ecosystem's fabric (§3.9).""" - await self._request_frame(self.proto.build_remove_fabric(fabric_index), timeout) + async def remove_fabric(self, fabric_index: int, + timeout: float = LONG_TIMEOUT) -> bridge_protocol.FabricRemoval: + """Drop one ecosystem's fabric (§3.9). Returns what the node actually did. + + Returning the outcome rather than ``None`` is the whole §3.9 fix: an + index with no fabric behind it is a *success* on the wire, and a caller + that tells the user "that ecosystem has been unpaired" in the past tense + has to be able to tell that apart from a real removal. + """ + result = await self._request_frame(self.proto.build_remove_fabric(fabric_index), timeout) + return bridge_protocol.parse_fabric_removal(result) async def factory_reset(self, preserve_endpoint_numbers: bool = True, timeout: float = LONG_TIMEOUT) -> None: diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_protocol.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_protocol.py index b90fc70..46f2a42 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_protocol.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_protocol.py @@ -317,6 +317,27 @@ class CommissioningWindow: window_expires_at: str +@dataclass +class FabricRemoval: + """The result of ``remove_fabric`` (§3.9) — what it DID, not that it returned. + + The node used to answer ``{}`` whether it dropped a fabric or found nothing + at the index, so the unpair menu reported "that ecosystem has been unpaired. + Every accessory has been removed" over a node-side no-op. The stale index is + not an edge case: the picker is built from the CACHED fabric list, so an + ecosystem that unpaired *us* since the last §5 event is the designed way to + land there. + + ``remaining`` is ``None`` — never a number — when the node could not read + its own fabric count (legitimate while matter.js rebuilds after a last-fabric + leave). Defaulted so a report from a node predating this result still parses, + and defaulted to the truthful direction: an old node that answered ``{}`` + really had removed something. + """ + removed: bool = True + remaining: Optional[int] = None + + @dataclass class BridgeCommand: """An ecosystem-originated action, from a ``command`` event (§5). @@ -430,6 +451,24 @@ def parse_window(result: Any) -> CommissioningWindow: ) +def parse_fabric_removal(result: Any) -> FabricRemoval: + """Normalise a ``remove_fabric`` payload (§3.9). + + ``removed`` defaults to True and ``remaining`` to None so a node predating + this result (which answered ``{}`` and only ever answered it after a real + ``leave()``) is read as the removal it was, not as a no-op. A present-but- + unreadable ``remaining`` is None for the same reason the node sends null + there: a fabricated count is exactly the lie this shape exists to end. + """ + data = result if isinstance(result, dict) else {} + raw_remaining = data.get("remaining") + try: + remaining = None if raw_remaining is None else int(raw_remaining) + except (TypeError, ValueError): + remaining = None + return FabricRemoval(removed=bool(data.get("removed", True)), remaining=remaining) + + def parse_command(data: Any) -> BridgeCommand: """Normalise a ``command`` event's ``data`` (§5).""" payload = data or {} diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py index b0de267..2906bcf 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py @@ -472,6 +472,17 @@ async def _stop_agent_off_loop(self) -> None: loop = asyncio.get_running_loop() await loop.run_in_executor(None, self._stop_agent) + def note_agent_stopped(self) -> None: + """Clear the XAC1 latch: something outside this engine stopped the agent. + + The latch means "this plugin session brought the agent up and is + therefore entitled to take it down". A user who stops the bridge from the + menu has made that false, and leaving it set would have the next + empty-transition bootout a job the *user* may have started again by hand + — the exact thing the latch exists to prevent. + """ + self._agent_started = False + def _pending_replace_all(self) -> int: """**How many** endpoints an un-export still owes the node (XAC7). @@ -632,8 +643,11 @@ async def _un_export() -> None: except Exception as exc: # pylint: disable=broad-except self._logger.warning( "Matter export: could not tell the bridge node the export list is empty " - "(%s). Accessories will linger in paired ecosystems until the plugin can " - "reach the node again — it will retry on its own.", exc) + "(%s). Accessories will LINGER in paired ecosystems until this is finished. " + "It is recorded and will be finished automatically the next time the plugin " + "connects to the node — which is when you next export a device, or when the " + "plugin next starts up. Nothing happens before then; there is no retry loop.", + exc) finally: # The socket must be released whatever happened above — # including a CancelledError at shutdown, which is a @@ -1206,6 +1220,14 @@ def _on_attached(self, status, carried_replace_all: bool = False) -> None: # been paired for months emits nothing at all and the §5.5 readout would # sit on "not known yet" forever. self.fabrics = list(getattr(status, "fabrics", ()) or []) + # ⊗ The §5.5 window readout's other half. `window_expires_at` is written + # by the pairing menu and cleared only by the §5 `window_closed` event, + # which the node does NOT send on shutdown — so a plugin reload during a + # real window left it None and the readout HID an open window, while a + # node restart after one left it set forever. An attach is the one moment + # both sides are present and the node can simply be asked. Fire-and- + # forget: a readout is not worth blocking a handshake for. + self._fire(self._refresh_pairing_window(), "reading the bridge node's pairing window") self._logger.info("Matter export: bridge node attached — %d endpoint(s) live, %s", status.endpoint_count, "commissioned" if status.commissioned else "not yet paired") @@ -1373,6 +1395,33 @@ def note_window_opened(self, expires_at: str) -> None: """Record a window the pairing menu just opened, for the §5.5 readout.""" self.window_expires_at = expires_at + async def _refresh_pairing_window(self) -> None: + """Re-derive :attr:`window_expires_at` from the node (§3.7). + + The node is the only thing that knows; the plugin's copy is a cache with + two failure directions (see the caller in :meth:`_on_attached`). Silent + on failure at anything above debug: this is a config-dialog readout, and + a warning about one would out-shout the outage that caused it. + """ + client = self.client + if client is None: + return + try: + pairing = await client.get_pairing() + except Exception as exc: # pylint: disable=broad-except + self._logger.debug("Matter export: could not read the pairing window (%s)", exc) + return + self.window_expires_at = pairing.window_expires_at if pairing.window_open else None + + def note_fabrics(self, fabrics: list) -> None: + """Replace the cached fabric set from a fresh authoritative read. + + The unpair menu's after-the-fact ``get_pairing`` uses this: a removal + that the §5 event has not landed for yet must not leave the picker + offering a fabric that has just gone. + """ + self.fabrics = list(fabrics) + def _on_unreachable(self, attempts: int) -> None: """The node is not answering — and since E7 the agent is asked why. @@ -1557,16 +1606,43 @@ def _log_future(self, future, what: str) -> None: self._logger.warning("Matter export: %s failed — %s", what, exc) -#: Matter vendor IDs whose ecosystems a user is likely to recognise. Purely -#: cosmetic: a fabric's own ``label`` is whatever the commissioner wrote there, -#: which for Apple is a UUID-ish string that tells nobody anything. Unknown ids -#: are rendered as hex, never guessed at. +#: Matter vendor IDs whose ecosystems a user is likely to recognise. +#: +#: **Not cosmetic, whatever the previous version of this comment said.** These +#: names are what the "Unpair an Ecosystem…" picker shows, and that picker +#: destroys every exported accessory in the ecosystem the user selects. A wrong +#: name there does not read as a wrong name; it reads as the right ecosystem, +#: and the user removes Apple Home believing they are removing Google. +#: +#: Every entry is verified against the CSA's Distributed Compliance Ledger +#: (``https://on.dcl.csa-iot.org/dcl/vendorinfo/vendors/``), which is +#: the registry that issues them, and the three matter.js also names agree with +#: it (``@matter/node``'s ``IcdMultiAdminError.TRUSTED_ECOSYSTEM_VENDORS``: +#: 0x1384, 0x110A, 0x134B). Two entries were WRONG before that check: +#: +#: * ``0x100B`` was labelled "Google". The DCL says Signify (Philips Hue). +#: * ``0x1075`` was labelled "SmartThings" and is not an issued vendor id at +#: all; Samsung SmartThings is ``0x110A``. +#: +#: Apple appears TWICE by design, and the second one is not a duplicate: an +#: Apple Home pairing creates an ``Apple Home`` fabric AND an ``Apple Keychain`` +#: fabric, which is the second Apple fabric ADR-0005 predicted from the observed +#: three-fabric count. A user seeing "vendor 0x1384" beside "Apple Home" cannot +#: tell it is theirs, and unpairing the wrong one of the pair is the same +#: accident as unpairing the wrong ecosystem. +#: +#: Unknown ids are rendered as hex, never guessed at — which is why an entry +#: that cannot be verified is removed rather than left in: hex is a question, +#: a wrong name is a false answer. VENDOR_NAMES = { - 0x1349: "Apple", - 0x100B: "Google", - 0x1217: "Amazon", - 0x1075: "SmartThings", - 0xFFF1: "test vendor", + 0x1349: "Apple Home", + 0x1384: "Apple Keychain", # Apple's SECOND fabric, alongside Apple Home + 0x1217: "Amazon Alexa", + 0x6006: "Google", + 0x110A: "Samsung SmartThings", + 0x134B: "Home Assistant", + 0x100B: "Signify (Philips Hue)", + 0xFFF1: "test vendor", # the spec's reserved test id; not in the DCL } diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/launch_agent.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/launch_agent.py index 405297f..163dd11 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/launch_agent.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/launch_agent.py @@ -118,6 +118,15 @@ class AgentSpec: (via :attr:`applied_marker_name`) to a per-label name so two agents sharing one project_dir cannot clobber each other's digest and trigger spurious bootout/bootstrap cycles. + :param install_menu: the EXACT wording of this agent's Install/update menu + item. Every message that tells a user to run it interpolated + :attr:`package` instead, producing "Plugins ▸ Matter ▸ Install/update + indigo-matter-bridge" — a menu that does not exist (the real one is + "Install/update the Matter export bridge"). It fires on the first-run + path, where the user is already stuck, so a menu name they cannot find + is the difference between a fixable state and a support thread. Blank + falls back to the old wording via :attr:`install_menu_name`, which is + right for an agent that has no menu at all. """ label: str @@ -133,12 +142,18 @@ class AgentSpec: argv: Callable[[Any], list[str]] port: Optional[int] = None applied_marker: Optional[str] = None + install_menu: str = "" @property def applied_marker_name(self) -> str: """Filename of this agent's applied-plist digest stamp.""" return self.applied_marker or f".launchagent-{self.label}.sha256" + @property + def install_menu_name(self) -> str: + """What to call this agent's Install/update menu item in a message.""" + return self.install_menu or f"Install/update {self.package}" + class LaunchAgent: """Install / control one launchd LaunchAgent described by an :class:`AgentSpec`.""" @@ -384,7 +399,7 @@ def preflight(self) -> Optional[str]: if not self._exists(entry): return ( f"the {self.spec.package} package is not installed ({entry} is missing). " - f"Use the plugin menu: Plugins ▸ Matter ▸ Install/update {self.spec.package} " + f"Use the plugin menu: Plugins ▸ Matter ▸ {self.spec.install_menu_name} " f"(or run 'npm install {self.spec.install_spec}' in {self.project_dir} " f"with the same node, {self.node_path}), then restart the plugin." ) @@ -409,7 +424,7 @@ def abi_warning(self) -> Optional[str]: return ( f"{self.spec.package} was installed with Node {stamped}.x but the resolved node " f"({self.node_path}) is {current}.x. If it fails to start, reinstall via " - f"Plugins ▸ Matter ▸ Install/update {self.spec.package}, or clear the stale stamp " + f"Plugins ▸ Matter ▸ {self.spec.install_menu_name}, or clear the stale stamp " f"({self._install_stamp_path()})." ) @@ -728,8 +743,8 @@ def uninstall(self) -> None: except FileNotFoundError: pass - def remove_package(self) -> None: - """Uninstall THIS agent's npm package for a clean reinstall. + def remove_package(self) -> bool: + """Uninstall THIS agent's npm package for a clean reinstall. True if it went. Stops the managed job and reaps any orphan first (so nothing holds the files or the storage lock), then removes the package, then drops this agent's @@ -756,6 +771,14 @@ def remove_package(self) -> None: corrupt *shared* dependency, and this no longer does. That was never what the menu action claimed to do, and rebuilding a sibling agent's install as a side effect of recovering this one is worse than the fault it happened to cure. + + **The return value is the fix for a message that was always the same.** + This used to log "Removed the … package" unconditionally: npm missing, + npm refusing, an ``OSError`` starting it and an ``rmtree`` that raised + were all reported as a completed removal, and the caller then reinstalled + on top of the wedged install the user was trying to clear — with a log + saying it had been cleared. The outcome is now decided by looking: the + package directory is either gone or it is not. """ self._bootout() self.reap_orphan_servers() @@ -765,8 +788,16 @@ def remove_package(self) -> None: os.remove(self._applied_marker_path()) except OSError: pass + if os.path.exists(self._package_dir()): + self.logger.error( + "Could NOT remove the %s package: %s is still there. Nothing was reinstalled over " + "it, so the wedged install you are trying to clear is still in place — remove the " + "directory by hand (or run 'npm uninstall --prefix %s %s'), then retry.", + self.spec.package, self._package_dir(), self.project_dir, self.spec.package) + return False self.logger.info("Removed the %s package under %s (storage left intact)", self.spec.package, self.project_dir) + return True def _npm_uninstall(self) -> bool: """``npm uninstall`` this agent's package. True if npm reported success. @@ -826,14 +857,19 @@ def start(self) -> bool: Part of the ``server_control`` seam used by fabric restore. Returns the REAL outcome so callers (notably fabric restore) are never told the server started when it did not: a successful ``bootstrap`` on the existing-plist - path, or ``is_running()`` after the install path (``ensure_installed`` logs + path, or :meth:`is_alive` after the install path (``ensure_installed`` logs its own launchctl failure but returns None, so we verify independently). + + :meth:`is_alive`, NOT ``is_running``: the latter means "launchd knows this + label", which a loaded-and-dead job satisfies — so a bootstrap that put a + job on the books and a crash-loop that never stayed up reported the same + success to fabric restore, the one caller least able to afford it. """ if os.path.exists(self.plist_path): self.reap_orphan_servers() # nothing legit runs after stop(); clear any orphan return self._bootstrap_and_record() self.ensure_installed() - return self.is_running() + return self.is_alive() def restart(self) -> bool: """Reload the agent from the on-disk plist so the CURRENT args take effect. @@ -869,12 +905,63 @@ def restart(self) -> bool: self.logger.warning("%s reload failed; falling back to reinstall", self.spec.package) self.uninstall() self.ensure_installed() - return self.is_running() + return self.is_alive() # "loaded" is not "running" — see is_running() def is_running(self) -> bool: + """Whether launchd knows this label. **"Loaded", NOT "alive".** + + Kept under its historical name and its historical meaning because callers + that ask "is there a job here to bootout / to leave alone" want exactly + this. A job that is loaded and DEAD passes it — the #104 fault-2 state + this file already handles at :meth:`_apply_plist` — so anything that + wants to report a process as running must use :meth:`run_state`. + """ result = self._launchctl("print", f"gui/{os.getuid()}/{self.spec.label}") return bool(result is not None and result.returncode == 0) + #: :meth:`run_state` outcomes. Four, because collapsing them is how "the + #: LaunchAgent is running" gets printed over a job that never started. + NOT_LOADED = "not_loaded" + RUNNING = "running" + LOADED_NOT_RUNNING = "loaded_not_running" + UNKNOWN = "unknown" + + def run_state(self) -> str: + """What launchd says about the job, as one of four distinguishable facts. + + * :data:`NOT_LOADED` — launchd has no such label. + * :data:`RUNNING` — loaded, with a pid we parsed. The ONLY positive + signal; nothing may claim the process is up without it. + * :data:`LOADED_NOT_RUNNING` — loaded with no ``pid =`` line at all. + launchd reports ``state = not running`` and, under our ``KeepAlive + {SuccessfulExit: false}``, has decided not to respawn it (a node that + exits 0 on a fatal startup error reads as a clean exit). Loaded and + dead, indefinitely. + * :data:`UNKNOWN` — a ``pid =`` line we could not parse. The job may + well be alive and we cannot prove it either way, so callers must + neither claim success nor report a failure. + + The facts were already parsed by :meth:`_managed_job`; only the readers + were missing, which is why ``ensure_installed() is not None`` was being + printed as "the LaunchAgent is running". + """ + job = self._managed_job() + if not job["loaded"]: + return self.NOT_LOADED + if job["pid"] is not None: + return self.RUNNING + return self.UNKNOWN if job["pid_line"] else self.LOADED_NOT_RUNNING + + def is_alive(self) -> bool: + """Whether a process is (or may be) running under this label. + + True for :data:`RUNNING` and for :data:`UNKNOWN` — an unparseable pid + line is not evidence of death, and treating it as failure would report a + healthy server as stopped. False only when launchd itself says there is + no job, or says the job is loaded and not running. + """ + return self.run_state() in (self.RUNNING, self.UNKNOWN) + # ------------------------------------------------------------------ # Orphan reaping — a server can outlive the LaunchAgent that started it # ------------------------------------------------------------------ diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py index a94e05c..fe6e2f1 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py @@ -51,6 +51,22 @@ COMMAND_TIMEOUT = 5.0 DECOMMISSION_TIMEOUT = 15.0 +#: Deadlines for the export/pairing menu actions, which block the **Indigo UI +#: thread** on a WS round trip: without one, a bridge node that accepts the +#: socket and then stops answering hangs the dialog — and Indigo's client — with +#: no way out but force-quitting it. Named rather than inline because a `.result()` +#: with no timeout looks like an ordinary call at a glance, so nothing about the +#: absence of one is visible at the call site. +#: +#: PAIRING_READ_TIMEOUT covers a plain read (`get_pairing`). The other two are +#: long because the node does real Matter work behind them: opening an enhanced +#: window derives a fresh passcode and re-advertises, and removing a fabric +#: flushes subscriptions and — on the last one — factory-resets the whole stack. +PAIRING_READ_TIMEOUT = 15.0 +WINDOW_OPEN_TIMEOUT = 45.0 +UNPAIR_TIMEOUT = 45.0 +FACTORY_RESET_TIMEOUT = 45.0 + #: Watchdog ticks (~15s each) of an active export with no ``deviceUpdated`` at #: all before ``subscribeToChanges`` is re-issued — see #: ``Plugin._resubscribe_tick``. ~1 minute, the same shape as every other streak @@ -63,6 +79,10 @@ #: Menu id of the export dialog (MenuItems.xml) — matched in #: ``get_menu_action_config_ui_values`` so other menus are never seeded. MENU_MANAGE_EXPORTS = "manageMatterExports" +#: Menu id of the unpair dialog. Seeded for the same reason the export dialog is +#: — Indigo pre-selects the first row of a picker, and this picker's rows are +#: real ecosystems whose Execute button removes them. +MENU_UNPAIR_ECOSYSTEM = "unpairEcosystem" #: Option-id prefix marking a picker row the user may look at but not choose #: (PRD §5.2: excluded devices are shown *with a reason*, never hidden — XAC9). EXCLUDED_OPTION_PREFIX = "x-" @@ -174,6 +194,13 @@ def _pairing_html(pairing, message: str) -> str: # a silently truncated payload — a QR that scans and means the wrong thing. viewer = f"{QR_VIEWER_URL}?data={quote(str(getattr(pairing, 'qr_pairing_code', '') or ''), safe='')}" codes = f""" +

This page shows a live commissioning passcode. + Anyone who can reach this URL can add the bridge — and every Indigo device you + export — to their Apple Home, Alexa or Google account, for as long as the + window is open. The Indigo Web Server only asks for a password if you have turned + authentication on, so if you have not, treat this URL as the code itself: do not + put it in a chat or an email, and close the window when you are done (it also + expires on its own).

Manual pairing code

{manual}

QR payload

@@ -200,11 +227,14 @@ def _pairing_html(pairing, message: str) -> str: .payload {{ font: .85rem ui-monospace, Menlo, monospace; word-break: break-all; background: #f4f4f6; padding: .6rem; border-radius: .4rem; }} .msg {{ background: #fff6d6; border: 1px solid #e8d48a; padding: .7rem; border-radius: .4rem; }} + .warn {{ background: #fdeaea; border: 1px solid #d99; padding: .7rem; border-radius: .4rem; + font-size: .92rem; }} .expiry {{ color: #a33; }} footer {{ margin-top: 2rem; font-size: .85rem; color: #777; }} @media (prefers-color-scheme: dark) {{ body {{ background: #16171a; color: #e6e6e6; }} h2 {{ color: #aaa; }} .payload {{ background: #26272b; }} .msg {{ background: #3a3320; border-color: #6b5c2e; }} + .warn {{ background: #3a2222; border-color: #7a4444; }} }}

Indigo Matter bridge

@@ -798,13 +828,59 @@ def _export_readout(self) -> str: if not bridge.active: return (f"{exported} device(s) exported; the bridge node is not running." if exported else "Nothing is exported yet, so the bridge node is not running.") + # ⊗ `active` means "a client OBJECT exists" — it is set the moment the + # engine builds one and stays set through every reconnect attempt. Read as + # "the bridge node is running", it told a user whose node had been + # crash-looping for a day "3 device(s) exported. Paired with: Apple", + # which is the readout PRD §5.5 exists to make impossible. The client's + # own socket state is the fact; `attached` narrows it further, because a + # connected-but-refusing node (§1.1) serves no accessories at all. + client = bridge.client + if client is None or not getattr(client, "connected", False): + return (f"{exported} device(s) exported, but the plugin is NOT connected to the bridge " + f"node — exported accessories are unavailable right now. See the Event Log.") fabrics = bridge.fabrics if fabrics is None: return f"{exported} device(s) exported; not yet connected to the bridge node." paired = ", ".join(export_bridge.describe_fabric(f) for f in fabrics) or "nothing yet" - window = (f" A pairing window is open until {bridge.window_expires_at}." - if bridge.window_expires_at else "") - return f"{exported} device(s) exported. Paired with: {paired}.{window}" + # Said as "last reported" because it is: this list is the one the last + # attach or §5 event left behind, deliberately not a WS round trip (this + # runs while the dialog is opening). An ecosystem that dropped us a + # second ago is still in it. + window = self._window_readout(bridge) + serving = ("" if getattr(client, "attached", False) else + " The node is connected but not serving accessories — see the Event Log.") + return (f"{exported} device(s) exported. Paired with (last reported): {paired}." + f"{serving}{window}") + + @staticmethod + def _window_readout(bridge) -> str: + """The pairing-window sentence, or "" — and never a window that has passed. + + ⊗ ``window_expires_at`` is set by the pairing menu and cleared only by + the §5 ``window_closed`` event, which the node does not send on shutdown. + So a node that was restarted (or a plugin that was) left the timestamp + standing and the readout claimed an open window indefinitely — including + for a time hours in the past. Comparing it against now costs one parse + and turns a permanent false claim into an expiry the user can read. + """ + raw = getattr(bridge, "window_expires_at", None) + if not raw: + return "" + try: + # The node sends RFC 3339 with a `Z`; `fromisoformat` learned `Z` in + # 3.11 but the substitution costs nothing and works on 3.10 too. + expires = datetime.fromisoformat(str(raw).replace("Z", "+00:00")) + except (TypeError, ValueError): + # Unparseable: report it as-is rather than dropping it. A timestamp + # we cannot compare is still the only thing we know. + return f" A pairing window was opened, expiring {raw}." + if expires.tzinfo is None: + expires = expires.replace(tzinfo=timezone.utc) + if expires <= datetime.now(timezone.utc): + return (f" The last pairing window expired at {raw} — open a new one with " + "Plugins ▸ Matter ▸ Pair Matter Bridge….") + return f" A pairing window is open until {raw}." def closedPrefsConfigUi(self, valuesDict, userCancelled): # noqa: N802 if userCancelled: @@ -821,11 +897,25 @@ def closedPrefsConfigUi(self, valuesDict, userCancelled): # noqa: N802 # ticking or unticking "Enable Matter export". Re-running the transition # applies it without a reload — and, because the transition is the same # code the allow-list uses, it also brings the agent up or down. - if self.export_bridge is not None: - try: - self.export_bridge.exports_changed() - except Exception as exc: # noqa: BLE001 - self.logger.exception(exc) + # + # This call is the ONLY thing that makes that switch act without a plugin + # reload, which is what PluginConfig.xml promises the user in prose. It + # is three lines with no local symptom if they go, so it has its own test + # (⊗ `test_saving_config_applies_the_export_switch_immediately`). + if self.export_bridge is None: + self.logger.debug( + "Matter export: the export engine is not running, so the export switch will take " + "effect when the plugin next starts.") + return + try: + self.export_bridge.exports_changed() + except Exception as exc: # noqa: BLE001 + # A bare traceback here reads as a crash in "save settings". Say what + # did not happen and what to do instead — the prefs ARE saved. + self.logger.error( + "Matter export: your settings were saved, but applying the export switch " + "immediately FAILED (%s). Reload the plugin to apply it.", exc) + self.logger.exception(exc) # ------------------------------------------------------------------ # Device lifecycle @@ -1251,7 +1341,14 @@ def _install_matter_server(self, clean: bool = False) -> None: # matter-server that won't start after an upgrade. self.logger.info("Removing the installed matter-server for a clean " "reinstall (your devices/pairings are kept)…") - sp.remove_package() + if not sp.remove_package(): + # remove_package has already said what is still there. Do NOT + # install over it: a clean reinstall that quietly became a + # plain reinstall leaves the wedge the user came here for. + self.logger.error( + "Clean reinstall ABANDONED — the old package could not be removed, so " + "nothing was reinstalled over it. Nothing was changed.") + return if not sp.install(): self.logger.error( "Install/update matter-server did not complete — see the error " @@ -1689,13 +1786,20 @@ def _export_health_note(status) -> str: return "" def get_menu_action_config_ui_values(self, menu_id): - """Seed the export dialog (menu dialogs never remember their values). + """Seed the export and unpair dialogs (menu dialogs never remember values). - Only the export menu is seeded — this callback fires for EVERY menu - item that has a ConfigUI, and returning values for another one would - overwrite its defaults. + Only those two are seeded — this callback fires for EVERY menu item that + has a ConfigUI, and returning values for another one would overwrite its + defaults. """ values = indigo.Dict() + if menu_id == MENU_UNPAIR_ECOSYSTEM: + # The picker leads with a no-selection row; seed the field to match + # it, or Indigo renders the seeded-but-unmatched value as a blank + # first item and the user is one click from unpairing whatever + # happens to be second. + values["fabric"] = NO_SELECTION_ID + return values if menu_id != MENU_MANAGE_EXPORTS: return values values["exportFilter"] = "" @@ -2172,7 +2276,7 @@ def menuResetBridgePairings(self, valuesDict, menuId=""): # noqa: N802, ARG002 # preserve_endpoint_numbers=True: a user resetting to re-pair the # same ecosystems should not also lose accessory identity. The # "the map itself is corrupt" path is the rebuild above. - self.runtime.submit(client.factory_reset(True)).result(timeout=45) + self.runtime.submit(client.factory_reset(True)).result(timeout=FACTORY_RESET_TIMEOUT) except Exception as exc: # noqa: BLE001 self.logger.error("Matter export: resetting the bridge pairings FAILED — %s. " "Pairings are unchanged.", exc) @@ -2188,22 +2292,6 @@ def menuResetBridgePairings(self, valuesDict, menuId=""): # noqa: N802, ARG002 # ------------------------------------------------------------------ # The export bridge node's LaunchAgent (E7 — PRD §4.2, XG5, XAC1) # ------------------------------------------------------------------ - def _bridge_agent(self): - """The bridge :class:`~bridge_agent.BridgeProcess`, built on first use. - - Cached on the plugin so the applied-plist digest and the resolved node - path are computed once, and rebuilt from CURRENT prefs by the two paths - that change them (the install action and a config save) — the same - discipline ``menuRestartMatterServer`` learned the hard way, where a - ``ServerProcess`` snapshotted at startup silently served stale settings. - - Constructing one writes nothing. That is what lets this be lazy at all - and what keeps a fresh install inert (XAC1). - """ - if self.bridge_process is None: - self.bridge_process = bridge_agent.BridgeProcess(dict(self.pluginPrefs), self.logger) - return self.bridge_process - def _start_bridge_agent(self) -> None: """Install (if needed) and start the bridge node's LaunchAgent. @@ -2226,24 +2314,79 @@ def _start_bridge_agent(self) -> None: # Preflight failed; the plist has been torn down and the reason # logged. Nothing to start, and starting would only crash-loop. return - self.logger.info("Matter export: bridge node LaunchAgent is running (protocol port %s, " - "Matter port %s)", agent.ws_port, agent.matter_port) + # ``ensure_installed() is not None`` is NOT the process being up. It is + # False for "the current definition was already loaded and healthy" AND + # for "bootout succeeded but neither bootstrap nor load did" AND for "the + # job is loaded with a pid line we could not parse" — and the middle one + # was reported here as "bridge node LaunchAgent is running". Ask launchd. + state = agent.run_state() + if state == agent.RUNNING: + self.logger.info("Matter export: bridge node LaunchAgent is running (protocol port %s, " + "Matter port %s)", agent.ws_port, agent.matter_port) + elif state == agent.UNKNOWN: + # A pid line we could not parse. It may well be serving; what we must + # not do is assert either way. + self.logger.info( + "Matter export: the bridge node's LaunchAgent is loaded (protocol port %s, Matter " + "port %s); launchd did not report a readable pid, so whether the process is up " + "will show as the plugin connects — or fails to.", agent.ws_port, agent.matter_port) + else: + self.logger.error( + "Matter export: the bridge node's LaunchAgent %s. Exported accessories will be " + "unavailable until it does. %s", + "did not start" if state == agent.LOADED_NOT_RUNNING + else "could not be loaded by launchd", + self._bridge_agent_diagnosis()) def _stop_bridge_agent(self) -> None: - """Stop the bridge agent, keeping its plist and its storage. + """Stop the bridge agent and REMOVE its plist. The storage is untouched. The ``agent_stop`` seam, called only once there is genuinely nothing to - serve. ``stop()`` rather than ``uninstall()``: the plist is cheap to keep - and re-exporting a device should not have to re-derive it, and the - storage dir — every ecosystem pairing plus the endpoint-number witness — - is never touched by either. + serve. + + **``uninstall()`` rather than ``stop()``, and that is a correction.** + ``stop()`` boots the job out and keeps the plist, which reads as a + thrifty choice until you notice the plist carries ``RunAtLoad: True``: + at the next login launchd started an *unpaired* bridge node with an + EMPTY allow-list, advertising on the Matter port, that this plugin never + started and — because ``_agent_started`` is false in a session that + never brought it up (XAC1) — would never stop. XG5's guarantee is that a + fresh or emptied install runs no bridge process, and a guarantee that + does not survive a reboot is not one. Re-deriving the plist costs one + ``ensure_installed`` on the next export. + + The storage dir — every ecosystem pairing plus the endpoint-number + witness — is never touched by either (PRD §5.4). """ agent = self.bridge_process if agent is None: return - if agent.stop(): - self.logger.info("Matter export: nothing is exported — the bridge node has been " - "stopped. Its pairings are kept.") + was_loaded = agent.is_running() # "is there a job on the books" + agent.uninstall() # bootout + remove the plist + if agent.is_running() or os.path.exists(agent.plist_path): + # ⊗ The silent branch. stop() returning False used to say nothing at + # all, and `_agent_started` had already been cleared, so nothing + # retried: the node kept serving every paired ecosystem with the log + # asserting the opposite by omission. + self.logger.warning( + "Matter export: nothing is exported, but the bridge node's LaunchAgent could not " + "be %s (%s). It keeps running and serving every paired ecosystem, and NOTHING " + "retries this on its own — reload the plugin, or run 'launchctl bootout " + "gui/$(id -u)/%s' and delete that file by hand.", + "stopped" if agent.is_running() else "removed", agent.plist_path, + bridge_agent.LABEL) + return + if was_loaded: + self.logger.info( + "Matter export: nothing is exported — the bridge node has been stopped and its " + "LaunchAgent removed, so a restart of this Mac cannot bring it back. Its pairings " + "are kept.") + else: + # The two Falses `stop()` conflated: this one is "there was no job", + # which is not a failure and must not be reported as one. + self.logger.debug( + "Matter export: nothing is exported and no bridge node LaunchAgent was loaded; " + "any plist has been removed. Pairings are kept.") def _bridge_agent_diagnosis(self) -> Optional[str]: """Why is the bridge node not answering? The ``agent_diagnose`` seam. @@ -2263,12 +2406,27 @@ def _bridge_agent_diagnosis(self) -> Optional[str]: if agent is None: return ("The bridge node's LaunchAgent has not been started by this plugin session — " "export at least one device, or reload the plugin.") + # ⊗ Asked FIRST, and it was not asked at all. preflight() holds the + # actual fact — is the node interpreter there, is the package installed — + # while the old code guessed at it from an empty error log and then said + # "checked {project_dir}", which it had not looked at. A missing package + # is also the case where the error log is empty *for the right reason*: + # launchd never got far enough to write one. + problem = agent.preflight() + if problem: + return f"The bridge node cannot start: {problem}" tail = agent.tail_error_log() if tail: - return f"Recent bridge node errors:\n{tail}" - return (f"Its error log is empty — the {bridge_agent.BRIDGE_PACKAGE} package may not be " - f"installed (checked {agent.project_dir}). Use Plugins ▸ Matter ▸ " - f"Install/update the Matter export bridge.") + # NOT "recent". The file is appended to and never truncated, so the + # last 20 lines can be from a crash-loop days ago that has since been + # fixed — naming the file is what lets the user check the timestamps. + return (f"The last lines of {os.path.join(agent.log_dir, bridge_agent.BRIDGE_ERR_LOG)} " + f"(appended to since the bridge was first started, so these may be old):\n" + f"{tail}") + return (f"The {bridge_agent.BRIDGE_PACKAGE} package is installed and its error log " + f"({os.path.join(agent.log_dir, bridge_agent.BRIDGE_ERR_LOG)}) is empty, so the " + f"node is failing without saying why — check that nothing else on this Mac holds " + f"Matter port {agent.matter_port} or protocol port {agent.ws_port}.") def menuInstallBridgeNode(self): # noqa: N802 """Install/update the ``indigo-matter-bridge`` npm package. @@ -2280,25 +2438,101 @@ def menuInstallBridgeNode(self): # noqa: N802 thread because ``~/indigo-matter`` is one npm root and two concurrent ``npm install``s into it corrupt each other. """ + self._run_bridge_install(clean=False) + + def menuReinstallBridgeNodeClean(self, valuesDict, menuId=""): # noqa: N802, ARG002 + """Remove the bridge package and install it fresh (the controller's twin). + + The controller has had this exit since 2026.7; the bridge shipped without + it, so a user whose bridge install was wedged had a menu that reinstalled + *over* the wedge and no way to clear it. ``remove_package`` is per-package + since E7, which is what makes this safe to offer at all — it used to + rmtree the shared ``node_modules`` and take the controller with it. + + Pairings are untouched: they live in the storage dir, which nothing in + the install path goes near. + """ + errors = indigo.Dict() + if not self._truthy(valuesDict.get("confirm")): + errors["confirm"] = "Tick the box to confirm." + return (False, valuesDict, errors) + if not self._run_bridge_install(clean=True): + errors["confirm"] = "An npm install is already running — wait for it to finish." + return (False, valuesDict, errors) + return (True, valuesDict) + + def _run_bridge_install(self, *, clean: bool) -> bool: + """Start the background bridge install. False if one is already running.""" if self._install_thread is not None and self._install_thread.is_alive(): self.logger.warning("An npm install is already in progress — wait for it to finish.") - return - self.logger.info("Installing the Matter export bridge node in the background — watch the " - "log for progress; this can take a minute.") + return False + self.logger.info( + "%s the Matter export bridge node in the background — watch the log for progress; " + "this can take a minute.", + "Removing and reinstalling" if clean else "Installing") self._install_thread = threading.Thread( - target=self._install_bridge_node, name="matter-bridge-install", daemon=True) + target=self._install_bridge_node, args=(clean,), + name="matter-bridge-install", daemon=True) self._install_thread.start() + return True + + def menuStopBridgeNode(self, valuesDict, menuId=""): # noqa: N802, ARG002 + """Stop the bridge node and remove its LaunchAgent, by hand. + + The controller has "Restart matter-server"; the bridge had nothing at + all, because it is started and stopped by the allow-list. That leaves one + state with no UI: a user who disables the plugin (or whose plugin dies + mid-session) has a node still running and still serving every paired + ecosystem, and the only lever is ``launchctl``. Exporting nothing is not + that lever — it needs the plugin to be running to notice. + + Exports are NOT changed. The next export starts the node again, which is + exactly XG5 and is why this is safe to hand a user: the worst outcome is + a bridge that comes back. + """ + errors = indigo.Dict() + if not self._truthy(valuesDict.get("confirm")): + errors["confirm"] = "Tick the box to confirm." + return (False, valuesDict, errors) + # Built from CURRENT prefs rather than reused: this must work in a + # session that never started the agent, which is the whole point of it. + self.bridge_process = bridge_agent.BridgeProcess(dict(self.pluginPrefs), self.logger) + self._stop_bridge_agent() + if self.export_bridge is not None: + # The plugin no longer has an agent it started, so the XAC1 latch + # must not go on claiming it does. + self.export_bridge.note_agent_stopped() + return (True, valuesDict) - def _install_bridge_node(self) -> None: + def _install_bridge_node(self, clean: bool = False) -> None: """npm-install the bridge package, then restart it if anything is exported. The restart is conditional on there being something to export, which is the difference from the controller's install: bringing the agent up because a package was updated would violate XG5 on an install with an empty allow-list, and leave a bridge process running for nothing. + + **``ensure_installed()`` before ``restart()``, and its absence was the + first-run dead end.** The bridge's plist is written by exactly one place + — ``_start_bridge_agent`` — and on a machine where the package has never + been installed that place cannot get past its own preflight, so it writes + no plist and (correctly) tears any stale one down. The user's route out + of that is this menu; it then went install() → restart(), restart found + no plist, and printed "nothing to restart. Fix the problem reported + above" (there was no problem above — the install had just SUCCEEDED) + followed by "the restart FAILED — the old version may still be running" + (nothing was running). Two wrong messages, no bridge, and the only real + remedy — write the plist now that the package exists — never attempted. """ try: agent = bridge_agent.BridgeProcess(dict(self.pluginPrefs), self.logger) + if clean and not agent.remove_package(): + # remove_package has said what is still there. Installing over a + # wedged install is what the clean variant exists to avoid. + self.logger.error( + "Clean reinstall of the Matter export bridge ABANDONED — the old package " + "could not be removed, so nothing was reinstalled over it.") + return if not agent.install(): self.logger.error( "Install/update of the Matter export bridge did not complete — see the error " @@ -2313,13 +2547,25 @@ def _install_bridge_node(self) -> None: "yet, and the bridge only runs while the export list is non-empty. Add a " "device in 'Manage Matter Exports…' and it will start itself.") return - # A running LaunchAgent does not pick up new files on disk, so the - # freshly-installed version only takes effect on a real restart. - if not self.bridge_process.restart(): + # Write (or refresh) the plist first. On the first-run path there is + # none — this is where it comes from — and `ensure_installed` returning + # True means launchd has already bootstrapped the NEW files, so there + # is nothing left for restart() to do and bouncing again would be a + # second gratuitous outage. + applied = self.bridge_process.ensure_installed() + if applied is None: + self.logger.error( + "The Matter export bridge was installed, but its LaunchAgent could not be " + "written — see the reason above. The package is on disk; fix that and reload " + "the plugin.") + return + # A running LaunchAgent does not pick up new files on disk, so a job + # left alone by ensure_installed is still executing the OLD version. + if applied is False and not self.bridge_process.restart(): self.logger.error( "The Matter export bridge was installed but the restart onto the new version " - "FAILED — the old version may still be running. Check " - "~/Library/Logs/indigo-matter/%s.", bridge_agent.BRIDGE_ERR_LOG) + "FAILED — the old version may still be running. Check %s.", + os.path.join(self.bridge_process.log_dir, bridge_agent.BRIDGE_ERR_LOG)) return self.logger.info("Matter export bridge installed and restarted onto the new version — " "the plugin reconnects automatically.") @@ -2383,7 +2629,7 @@ def menuPairMatterBridge(self, valuesDict, menuId=""): # noqa: N802, ARG002 if client is None: return (False, valuesDict, errors) try: - pairing = self.runtime.submit(client.get_pairing()).result(timeout=15) + pairing = self.runtime.submit(client.get_pairing()).result(timeout=PAIRING_READ_TIMEOUT) except Exception as exc: # noqa: BLE001 self.logger.error("Matter export: could not read the bridge node's pairing state — %s. " "No pairing window was opened.", exc) @@ -2402,7 +2648,7 @@ def menuPairMatterBridge(self, valuesDict, menuId=""): # noqa: N802, ARG002 return (True, valuesDict) try: window = self.runtime.submit( - client.open_commissioning_window(duration)).result(timeout=45) + client.open_commissioning_window(duration)).result(timeout=WINDOW_OPEN_TIMEOUT) except Exception as exc: # noqa: BLE001 self.logger.error( "Matter export: opening a pairing window FAILED — %s. Nothing was changed and " @@ -2453,7 +2699,12 @@ def _log_pairing_codes(self, manual: Optional[str], qr: Optional[str], " QR code page: %s\n" "Add the bridge in your ecosystem's app as you would any Matter accessory, and type " "the manual code if it asks for one. Expect an 'uncertified accessory' warning — that " - "is normal for a bridge like this one; choose Add Anyway.", + "is normal for a bridge like this one; choose Add Anyway.\n" + "SECURITY: while this window is open, anyone who can reach that page (or read this " + "code) can add your exported Indigo devices to THEIR Apple Home, Alexa or Google " + "account. The page is served by the Indigo Web Server, which asks for a password only " + "if you have switched authentication on — turn it on before using this over anything " + "but a network you trust, and do not share the URL.", opening, when, manual or "(none)", qr or "(none)", self._pairing_page_url()) def _pairing_page_url(self) -> str: @@ -2480,7 +2731,16 @@ def getBridgeFabrics(self, filter="", valuesDict=None, typeId="", targetId=0): §5 ``fabrics_changed``), never from a fresh WS round trip: a dynamic list callback runs on the Indigo UI's thread while the dialog is opening, and blocking it on a node that may be down would hang the dialog rather than - render an empty one. + render an empty one. That is a deliberate trade and the reason + :meth:`menuUnpairEcosystem` re-reads the set *after* it acts, and the + reason §3.9 now reports whether it removed anything: the list can be + stale, so nothing downstream may assume it is not. + + **The first row is always "(select an ecosystem)".** Indigo pre-selects + row one, so without it the dialog opened with a real ecosystem already + chosen on a menu whose Execute button removes it — every other picker in + this plugin (device, node, backup) leads with a no-selection row for + exactly this reason, and the one destructive picker did not. """ try: bridge = self.export_bridge @@ -2491,8 +2751,9 @@ def getBridgeFabrics(self, filter="", valuesDict=None, typeId="", targetId=0): return [(NO_SELECTION_ID, "(no paired ecosystems)" if fabrics == [] else "(not connected to the bridge node)")] - return [(str(fabric.fabric_index), export_bridge.describe_fabric(fabric)) - for fabric in fabrics] + return [(NO_SELECTION_ID, "(select an ecosystem)")] + [ + (str(fabric.fabric_index), export_bridge.describe_fabric(fabric)) + for fabric in fabrics] except Exception as exc: # pylint: disable=broad-except self.logger.exception(exc) return [LIST_ERROR_OPTION] @@ -2523,15 +2784,35 @@ def menuUnpairEcosystem(self, valuesDict, menuId=""): # noqa: N802, ARG002 client = self._pairing_client(errors, "confirmAgain") if client is None: return (False, valuesDict, errors) - last = self._is_last_fabric(fabric_index) + cached_last = self._is_last_fabric(fabric_index) try: - self.runtime.submit(client.remove_fabric(fabric_index)).result(timeout=45) + removal = self.runtime.submit( + client.remove_fabric(fabric_index)).result(timeout=UNPAIR_TIMEOUT) except Exception as exc: # noqa: BLE001 self.logger.error("Matter export: unpairing ecosystem %s FAILED — %s. Pairings are " "unchanged.", fabric_index, exc) self.logger.exception(exc) errors["confirmAgain"] = "Unpair failed — see the log. Nothing was changed." return (False, valuesDict, errors) + # The picker is built from a CACHED fabric list, so the ecosystem may + # have unpaired itself since — which the node reports as a successful + # no-op. Re-read before saying anything, so the picker cannot keep + # offering a ghost and the sentence below is about the real outcome. + self._refresh_fabric_cache(client) + if not removal.removed: + # ⊗ This used to be indistinguishable from a real removal: the node + # answered `{}` either way and the menu logged "has been unpaired. + # Every accessory has been removed" over a node-side no-op. + self.logger.warning( + "Matter export: ecosystem %s was ALREADY gone from the bridge node — nothing was " + "removed by this action, because there was nothing there to remove. It had most " + "likely unpaired itself since this dialog was opened. The ecosystem list has been " + "refreshed%s.", fabric_index, + f"; {removal.remaining} pairing(s) remain" if removal.remaining is not None else "") + return (True, valuesDict) + # `remaining` is the node's own post-removal count and beats the cache; + # the cache is only the fallback for a node that could not read it. + last = cached_last if removal.remaining is None else removal.remaining == 0 if last: # §3.9: matter.js factory-resets itself when the fabric set empties, # and the node clears its commissioning witness to match. Say what @@ -2549,6 +2830,27 @@ def menuUnpairEcosystem(self, valuesDict, menuId=""): # noqa: N802, ARG002 "ecosystem's app by hand.", fabric_index) return (True, valuesDict) + def _refresh_fabric_cache(self, client) -> None: + """Re-read the fabric set from the node after an unpair. Never raises. + + The §5 ``fabrics_changed`` that follows a removal is asynchronous, and + the picker is built from the cache it updates — so without this a user + who unpairs and immediately re-opens the dialog is offered the ecosystem + they just removed. Blocking is fine HERE (a menu Execute already blocked + on the removal itself); it is not fine in the picker callback, which runs + on the UI thread while the dialog opens. + """ + bridge = self.export_bridge + if bridge is None: + return + try: + pairing = self.runtime.submit(client.get_pairing()).result(timeout=PAIRING_READ_TIMEOUT) + bridge.note_fabrics(pairing.fabrics) + except Exception as exc: # noqa: BLE001 + # The removal itself already succeeded or was already true; failing + # to re-read the list afterwards is not worth reporting as a failure. + self.logger.debug("Matter export: could not refresh the ecosystem list (%s)", exc) + def _is_last_fabric(self, fabric_index: int) -> bool: """Whether removing ``fabric_index`` empties the fabric set. @@ -2600,7 +2902,7 @@ def _pairing_page(self) -> str: return _pairing_html(None, "The plugin is not connected to the Matter bridge node. " "Export at least one device, then reload this page.") try: - pairing = self.runtime.submit(client.get_pairing()).result(timeout=15) + pairing = self.runtime.submit(client.get_pairing()).result(timeout=PAIRING_READ_TIMEOUT) except Exception as exc: # noqa: BLE001 self.logger.exception(exc) return _pairing_html(None, f"Could not read the bridge node's pairing state: {exc}") diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/server_process.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/server_process.py index 53e3bc0..2519357 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/server_process.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/server_process.py @@ -50,6 +50,10 @@ DEFAULT_SERVER_ENTRY = "dist/esm/MatterServer.js" SERVER_OUT_LOG = "matter-server.log" SERVER_ERR_LOG = "matter-server.err.log" + +#: The EXACT wording of ``MenuItems.xml``'s ``installMatterServer`` item, for the +#: messages that tell a user to run it. See ``AgentSpec.install_menu``. +INSTALL_MENU = "Install/update matter-server" # Records the sha256 of the plist launchd was last told to load (bootstrap). launchd # caches a job's ProgramArguments at bootstrap time — rewriting the plist FILE does not # touch an already-loaded job — so we compare against this to tell "current definition @@ -188,6 +192,11 @@ def __init__( argv=matter_server_arguments, port=_port_number(port), applied_marker=APPLIED_PLIST_MARKER, + # The exact wording of MenuItems.xml's `installMatterServer`. + # Happens to match the old "Install/update {package}" default — + # stated anyway, so a rename of the menu item is a change to one + # line rather than a message that silently stops being true. + install_menu=INSTALL_MENU, ), prefs, logger, home=resolved_home, npx_path=npx_path, diff --git a/tests/fakes.py b/tests/fakes.py index ecb7fc1..5272a14 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -250,6 +250,9 @@ def __init__(self, logger=None, prefs=None, **kwargs): #: one reader — the watchdog's poll — so a fake without this cannot #: exercise the only path a node's persistence failure has to a user. self.status = None + #: What ``get_pairing`` answers (§3.7). Read on every attach since the + #: §5.5 window readout stopped trusting its own cache. + self.pairing = None # -- the recorded surface ------------------------------------------- async def run(self): @@ -275,6 +278,12 @@ async def get_status(self, timeout=None): self._record("get_status") return self.status + async def get_pairing(self, timeout=None): + self._record("get_pairing") + if self.pairing is None: + raise ConnectionError("no pairing report configured") + return self.pairing + async def close(self): self.closed = True self._record("close") diff --git a/tests/fixtures/bridge_protocol/frames.json b/tests/fixtures/bridge_protocol/frames.json index c6022cb..ffc5282 100644 --- a/tests/fixtures/bridge_protocol/frames.json +++ b/tests/fixtures/bridge_protocol/frames.json @@ -1220,7 +1220,26 @@ }, "response": { "message_id": "m20", - "result": {} + "result": { + "removed": true, + "remaining": 1 + } + } + }, + "remove_fabric_already_gone": { + "request": { + "message_id": "m45", + "command": "remove_fabric", + "args": { + "fabricIndex": 7 + } + }, + "response": { + "message_id": "m45", + "result": { + "removed": false, + "remaining": 2 + } } }, "factory_reset": { diff --git a/tests/test_bridge_agent.py b/tests/test_bridge_agent.py index 4d21f88..92ff766 100644 --- a/tests/test_bridge_agent.py +++ b/tests/test_bridge_agent.py @@ -90,6 +90,11 @@ def test_the_default_entry_matches_the_packages_main(tmp_path, mock_logger): manifest = json.loads((Path(__file__).parent.parent / "bridge-node" / "package.json") .read_text(encoding="utf-8")) + # ⊗ The `name` was the one field of the three left unasserted, and it is the + # one `npm install ` resolves, `ps` is matched against when reaping + # orphans, and `node_modules/` is looked up under. A rename on either + # side would install one package and look for another. + assert manifest["name"] == bridge_agent.BRIDGE_PACKAGE assert manifest["main"] == bridge_agent.DEFAULT_BRIDGE_ENTRY assert manifest["version"] == bridge_agent.DEFAULT_INSTALL_SPEC.partition("@")[2] bridge = _bridge(tmp_path, mock_logger) @@ -226,6 +231,54 @@ def test_preflight_names_the_bridge_install_action_when_the_package_is_missing(t assert bridge_agent.DEFAULT_INSTALL_SPEC in problem +def _menu_names() -> set: + """Every ```` in MenuItems.xml, as the Plugins ▸ Matter menu shows it.""" + import xml.etree.ElementTree as ET + from pathlib import Path + + root = ET.parse(Path(__file__).parent.parent / "indigo-matter.indigoPlugin" + / "Contents" / "Server Plugin" / "MenuItems.xml").getroot() + return {item.findtext("Name") for item in root.findall("MenuItem")} + + +def test_the_messages_name_a_menu_item_that_actually_EXISTS(tmp_path, mock_logger): + """⊗ They named the npm package instead. + + ``preflight`` and ``abi_warning`` both interpolated ``spec.package``, + producing "Plugins ▸ Matter ▸ Install/update indigo-matter-bridge" — there + is no such menu item. This fires on the first-run path, where the user is + already stuck, so a name they cannot find in the menu is the difference + between a fixable state and giving up. + """ + bridge = _bridge(tmp_path, mock_logger, installed=False) + problem = bridge.preflight() + assert bridge.spec.install_menu_name in problem + assert bridge.spec.install_menu_name in _menu_names(), \ + "the message sends the user at a menu item that does not exist" + assert f"Install/update {bridge_agent.BRIDGE_PACKAGE}" not in problem + + +def test_the_controllers_message_names_ITS_menu_item(tmp_path, mock_logger): + from server_process import INSTALL_MENU + + controller = ServerProcess({}, mock_logger, home=str(tmp_path / "nowhere"), + npx_path=str(tmp_path / "nowhere" / "bin" / "npx"), + runner=FakeRunner()) + assert controller.spec.install_menu_name == INSTALL_MENU + assert INSTALL_MENU in _menu_names() + + +def test_an_agent_with_no_menu_still_gets_a_usable_sentence(): + """The fallback is the old wording, which is right for an agent that has no + Install/update menu at all.""" + from launch_agent import AgentSpec + + spec = AgentSpec(label="l", package="some-pkg", install_spec="some-pkg@1", + default_entry="m.js", storage_path="/s", out_log="o", err_log="e", + argv=lambda _agent: []) + assert spec.install_menu_name == "Install/update some-pkg" + + def test_uninstall_never_touches_the_storage(tmp_path, mock_logger): """The pairings of every ecosystem live in there. Sacred both ways.""" bridge = _bridge(tmp_path, mock_logger) diff --git a/tests/test_export_agent_wiring.py b/tests/test_export_agent_wiring.py index b24e8d8..fbe43cb 100644 --- a/tests/test_export_agent_wiring.py +++ b/tests/test_export_agent_wiring.py @@ -239,6 +239,81 @@ def test_an_agent_that_will_not_stop_is_a_warning_not_a_failure(self, bridge_mod assert "could not stop" in warnings_of(mock_logger) assert "pairings are untouched" in warnings_of(mock_logger) + def test_a_DEBT_discharged_on_reconnect_also_stops_the_agent(self, bridge_mod, + mock_logger, devices): + """⊗ XAC7's recovery path, and the only caller of `_stop_agent_off_loop`. + + Deleting that fire left the whole suite green: the debt is paid, the + store is empty, the client is closed — and the agent runs forever, with + an unpaired bridge node serving nothing. This is the state a user reaches + by emptying the allow-list while the node is unreachable and then + reloading the plugin, which is exactly when an un-export fails. + """ + h = AgentHarness(bridge_mod, mock_logger, devices, [ExportEntry(101, "onOffLight")]) + h.bridge.exports_changed() + h.client.fail["attach"] = RuntimeError("node went away") + h.store.remove(101) + h.bridge.exports_changed() # records the debt, stops the agent + assert h.prefs.get(bridge_mod.PREF_PENDING_REPLACE_ALL) == 1 + + # A later session brings the client back to discharge the debt (XG5's one + # exception), the attach lands, and there is still nothing to export. + h.agent.clear() + h.bridge.exports_changed() + assert h.agent == ["start"], "the debt path is the one that starts with an empty list" + status = bridge_protocol.parse_status({ + "commissioned": True, "endpointCount": 0, "endpoints": [], "drift": [], + "driftChecked": True, "warnings": [], "fabrics": [], + }) + h.bridge._on_attached(status, carried_replace_all=True) + assert bridge_mod.PREF_PENDING_REPLACE_ALL not in h.prefs, "the debt must be discharged" + assert h.bridge.active is False, "nothing is exported — the socket goes" + assert h.agent == ["start", "stop"], \ + "the agent must go too, or an unpaired node serves nothing forever" + + def test_a_debt_discharged_alongside_REAL_endpoints_keeps_the_agent(self, bridge_mod, + mock_logger, devices): + """The other half: an attach that also carried endpoints is an export to + keep serving, not one to hang up on.""" + h = AgentHarness(bridge_mod, mock_logger, devices, [ExportEntry(101, "onOffLight")]) + h.bridge.exports_changed() + h.bridge._record_pending_replace_all(1) + h.agent.clear() + status = bridge_protocol.parse_status({ + "commissioned": True, "endpointCount": 1, "endpoints": [], "drift": [], + "driftChecked": True, "warnings": [], "fabrics": [], + }) + h.bridge._on_attached(status, carried_replace_all=True) + assert "stop" not in h.agent + + def test_a_start_that_RAISES_still_leaves_the_agent_stoppable(self, bridge_mod, + mock_logger, devices): + """⊗ The `_agent_started` latch is set BEFORE the attempt, deliberately. + + A start that raised may still have written a plist and bootstrapped a + job, so "we never touched launchd" is a claim we cannot make afterwards — + and the latch is what gates the stop that would clean it up. Moving the + assignment after `_agent_start()` survives every other test in this file. + """ + h = AgentHarness(bridge_mod, mock_logger, devices, [ExportEntry(101, "onOffLight")]) + h.start_raises = OSError("launchctl exploded halfway") + h.bridge.exports_changed() + assert h.agent == ["start"] + h.store.remove(101) + h.bridge.exports_changed() + assert h.agent == ["start", "stop"], \ + "a start that raised may still have left a job behind — it must be stoppable" + + def test_note_agent_stopped_clears_the_latch(self, bridge_mod, mock_logger, devices): + """Something outside the engine (the stop menu) took the agent down.""" + h = AgentHarness(bridge_mod, mock_logger, devices, [ExportEntry(101, "onOffLight")]) + h.bridge.exports_changed() + h.bridge.note_agent_stopped() + h.agent.clear() + h.store.remove(101) + h.bridge.exports_changed() + assert h.agent == [], "this session no longer owns the agent; it must not bootout" + def test_the_unreachable_report_carries_the_agents_diagnosis(self, bridge_mod, mock_logger, devices): """"Connection refused" is what a missing package, a bound Matter port and @@ -357,7 +432,7 @@ def test_an_attach_seeds_the_fabric_set(self, bridge_mod, mock_logger, devices): status = bridge_protocol.parse_status({ "commissioned": True, "endpointCount": 1, "endpoints": [], "drift": [], "driftChecked": True, "warnings": [], - "fabrics": [{"fabricIndex": 2, "label": "", "vendorId": 0x100B}], + "fabrics": [{"fabricIndex": 2, "label": "", "vendorId": 0x6006}], }) h.bridge._on_attached(status, False) assert [f.fabric_index for f in h.bridge.fabrics] == [2] @@ -392,15 +467,142 @@ def test_window_closed_distinguishes_its_two_reasons(self, reason, expected, bri assert expected in infos_of(mock_logger) assert h.bridge.window_expires_at is None + def test_note_window_opened_actually_records_it(self, bridge_mod, mock_logger, devices): + """⊗ Its body → `pass` survived the whole suite. It is the only thing the + §5.5 readout has to report a window the pairing menu just opened.""" + h = self._started(bridge_mod, mock_logger, devices) + h.bridge.note_window_opened("2026-08-05T12:00:00Z") + assert h.bridge.window_expires_at == "2026-08-05T12:00:00Z" + + def test_an_attach_RE_DERIVES_the_window_from_the_node(self, bridge_mod, mock_logger, + devices): + """⊗ The window cache's other failure direction. + + `window_expires_at` is written by the pairing menu and cleared only by + the §5 `window_closed` event, which the node does NOT send on shutdown. + So a plugin reload during a real window left it None and the readout HID + an open window; the node is the only thing that knows, and an attach is + the moment to ask. + """ + h = self._started(bridge_mod, mock_logger, devices) + h.client.pairing = bridge_protocol.PairingReport( + commissioned=True, window_open=True, window_expires_at="2026-09-01T10:00:00Z", + manual_pairing_code="1", qr_pairing_code="MT:1", fabrics=[]) + status = bridge_protocol.parse_status({ + "commissioned": True, "endpointCount": 1, "endpoints": [], "drift": [], + "driftChecked": True, "warnings": [], "fabrics": [], + }) + h.bridge._on_attached(status, False) + assert h.bridge.window_expires_at == "2026-09-01T10:00:00Z" + + def test_a_closed_window_on_the_node_clears_a_stale_local_one(self, bridge_mod, + mock_logger, devices): + h = self._started(bridge_mod, mock_logger, devices) + h.bridge.window_expires_at = "2020-01-01T00:00:00Z" + h.client.pairing = bridge_protocol.PairingReport( + commissioned=True, window_open=False, window_expires_at=None, + manual_pairing_code=None, qr_pairing_code=None, fabrics=[]) + status = bridge_protocol.parse_status({ + "commissioned": True, "endpointCount": 1, "endpoints": [], "drift": [], + "driftChecked": True, "warnings": [], "fabrics": [], + }) + h.bridge._on_attached(status, False) + assert h.bridge.window_expires_at is None + def test_fabrics_are_described_by_vendor_not_by_their_own_label(bridge_mod): """A fabric's `label` is whatever the commissioner wrote — for Apple, a UUID-ish string that tells nobody anything. The index is always shown because that is what §3.9 removes a fabric BY.""" fabric = bridge_protocol.FabricInfo(fabric_index=1, label="", vendor_id=0x1349) - assert bridge_mod.describe_fabric(fabric) == "Apple (index 1)" + assert bridge_mod.describe_fabric(fabric) == "Apple Home (index 1)" labelled = bridge_protocol.FabricInfo(fabric_index=3, label="Kitchen Hub", - vendor_id=0x100B) + vendor_id=0x6006) assert bridge_mod.describe_fabric(labelled) == "Google — Kitchen Hub (index 3)" unknown = bridge_protocol.FabricInfo(fabric_index=9, label="", vendor_id=0x1234) assert bridge_mod.describe_fabric(unknown) == "vendor 0x1234 (index 9)" + + +# --------------------------------------------------------------------------- +# The vendor table — the labels on a DESTRUCTIVE picker +# --------------------------------------------------------------------------- + +#: matter.js's own list of ecosystem admin vendors, vendored under bridge-node. +#: The ONLY thing in this repo that is both authoritative about these ids and +#: version-controlled with us: the CSA's ledger is authoritative but is a network +#: call, and a test that mirrored our own table would pass on any value at all — +#: which is exactly how "0x1075 = SmartThings" (not an issued id) and "0x100B = +#: Google" (it is Signify) survived to ship on the unpair picker. +MATTER_JS_VENDOR_SOURCE = ( + "@matter/node/src/behavior/system/icd/IcdMultiAdminError.ts" +) + + +def _matter_js_vendor_names() -> dict: + """`{vendor_id: comment}` parsed out of matter.js's TRUSTED_ECOSYSTEM_VENDORS.""" + import re + from pathlib import Path + + source = (Path(__file__).parent.parent / "bridge-node" / "node_modules" + / MATTER_JS_VENDOR_SOURCE) + if not source.exists(): + pytest.skip(f"{source} is absent — run `npm install` in bridge-node/") + text = source.read_text(encoding="utf-8") + found = {int(vid, 16): comment.strip() + for vid, comment in re.findall( + r"VendorId\(0x([0-9a-fA-F]+)\)\s*/\*\s*([^*]+?)\s*\*/", text)} + assert len(found) >= 3, ( + f"parsed only {len(found)} vendor ids out of {source} — the upstream format has " + "changed and this check has quietly stopped checking anything") + return found + + +def test_the_vendor_table_agrees_with_the_vendored_matter_js(bridge_mod): + """⊗ Every id matter.js names must be in our table, under a matching name. + + Not "a name we like": every word of ours has to appear in matter.js's own + comment for that id, so a plausible-but-wrong relabel fails here. This is the + check that would have caught both of the errors this table shipped with. + """ + upstream = _matter_js_vendor_names() + for vendor_id, comment in upstream.items(): + ours = bridge_mod.VENDOR_NAMES.get(vendor_id) + assert ours is not None, ( + f"matter.js names 0x{vendor_id:04X} ({comment}) as an ecosystem admin vendor and " + "our table does not — the unpair picker would show it as raw hex") + lowered = comment.lower() + for word in ours.replace("(", " ").replace(")", " ").split(): + assert word.lower() in lowered, ( + f"we call 0x{vendor_id:04X} {ours!r}; matter.js calls it {comment!r}") + + +def test_apple_appears_twice_because_apple_creates_two_fabrics(bridge_mod): + """0x1384 is Apple's SECOND fabric — the one ADR-0005 predicted from the + observed three-fabric count — and it was missing entirely, so it rendered as + "vendor 0x1384" beside "Apple Home" on the picker that removes ecosystems.""" + assert "Apple" in bridge_mod.VENDOR_NAMES[0x1349] + assert "Apple" in bridge_mod.VENDOR_NAMES[0x1384] + assert bridge_mod.VENDOR_NAMES[0x1349] != bridge_mod.VENDOR_NAMES[0x1384], \ + "both are Apple; a user picking one to unpair must be able to tell them apart" + + +def test_the_two_wrong_entries_are_gone(bridge_mod): + """⊗ The regression itself, pinned by value. + + 0x1075 was labelled "SmartThings" and is not an issued vendor id at all + (CSA DCL: not found); Samsung SmartThings is 0x110A. 0x100B was labelled + "Google"; the DCL says Signify, and Google is 0x6006. On a picker whose + Execute button removes every exported accessory from the chosen ecosystem, a + wrong name does not read as wrong — it reads as the right ecosystem. + """ + assert 0x1075 not in bridge_mod.VENDOR_NAMES + assert "Google" not in bridge_mod.VENDOR_NAMES.get(0x100B, "") + assert bridge_mod.VENDOR_NAMES[0x6006] == "Google" + assert "SmartThings" in bridge_mod.VENDOR_NAMES[0x110A] + + +def test_an_unknown_vendor_is_rendered_as_hex_never_guessed(bridge_mod): + """The safe direction: hex is a question the user can look up, a wrong name + is an answer they will act on.""" + unknown = bridge_protocol.FabricInfo(fabric_index=4, label="", vendor_id=0x1075) + assert bridge_mod.describe_fabric(unknown) == "vendor 0x1075 (index 4)" diff --git a/tests/test_export_bridge.py b/tests/test_export_bridge.py index 0ea7a6b..0986c8e 100644 --- a/tests/test_export_bridge.py +++ b/tests/test_export_bridge.py @@ -362,7 +362,11 @@ def test_the_client_is_dropped_even_if_the_final_attach_fails( h.store.remove(101) h.bridge.exports_changed() assert h.bridge.active is False - assert "will linger" in warnings_of(mock_logger) + # The warning must name what actually resumes it. "It will retry on its + # own" was false: nothing retries until the client next comes up. + said = warnings_of(mock_logger) + assert "LINGER" in said + assert "no retry loop" in said # F4: the socket must still be released — the client is unreachable from # here on, so a skipped close() leaks it until the plugin reloads. assert client.closed is True diff --git a/tests/test_launch_agent.py b/tests/test_launch_agent.py index 90a7c0d..78c5b98 100644 --- a/tests/test_launch_agent.py +++ b/tests/test_launch_agent.py @@ -251,6 +251,134 @@ def test_remove_package_fallback_deletes_only_its_own_package_dir(tmp_path, mock assert not any("uninstall" in call for call in controller._run.calls) +# --------------------------------------------------------------------------- +# remove_package's outcome — it used to be the same sentence whatever happened +# --------------------------------------------------------------------------- + +def test_remove_package_reports_TRUE_only_when_the_package_is_actually_gone(tmp_path, + mock_logger): + agent = _agent(tmp_path / "home", _spec("com.example.a", "pkg-a", str(tmp_path / "s")), + mock_logger) + assert agent.ensure_installed() is True + npm = os.path.join(agent.resolved_bin_dir, "npm") + agent._exists = lambda path: path != npm and os.path.exists(path) + assert agent.remove_package() is True + assert "Removed the pkg-a package" in _infos(mock_logger) + + +def test_a_REFUSED_npm_uninstall_falls_back_and_the_package_still_goes(tmp_path, + mock_logger): + """⊗ Replacing `_npm_uninstall`'s non-zero-exit branch with `return True` left + the suite green: nothing exercised a *present* npm that fails, so the + fallback that actually removes the directory was never reached in a test. + """ + home = tmp_path / "home" + agent = _agent(home, _spec("com.example.a", "pkg-a", str(tmp_path / "s")), mock_logger, + runner=FakeRunner()) + assert agent.ensure_installed() is True + with open(os.path.join(agent.resolved_bin_dir, "npm"), "w", encoding="utf-8") as handle: + handle.write("#!/bin/sh\n") + ours = os.path.join(agent.project_dir, "node_modules", "pkg-a") + assert os.path.isdir(ours) + + class _NpmRefuses(FakeRunner): + def __call__(self, cmd, **kwargs): + result = super().__call__(cmd, **kwargs) + if "uninstall" in cmd: + result.returncode = 1 + result.stderr = "npm ERR! code EBUSY" + return result + + agent._run = _NpmRefuses() + assert agent.remove_package() is True + assert not os.path.exists(ours), \ + "npm refused, so the directory fallback had to run — and did not" + + +def test_remove_package_says_so_when_it_could_NOT_remove_it(tmp_path, mock_logger, + monkeypatch): + """⊗ Replacing `_npm_uninstall`'s non-zero-exit branch with `return True` left + the suite green, because every route out of here logged "Removed the … + package" regardless: npm missing, npm refusing, an OSError starting it and an + `rmtree` that raised were all reported as a completed removal — and the + caller then reinstalled on top of the wedge, saying it had cleared it. + """ + import shutil + + agent = _agent(tmp_path / "home", _spec("com.example.a", "pkg-a", str(tmp_path / "s")), + mock_logger) + assert agent.ensure_installed() is True + npm = os.path.join(agent.resolved_bin_dir, "npm") + agent._exists = lambda path: path != npm and os.path.exists(path) + # The fallback's own failure mode: a directory rmtree cannot remove. + monkeypatch.setattr(shutil, "rmtree", + lambda *_a, **_k: (_ for _ in ()).throw(OSError("permission denied"))) + + assert agent.remove_package() is False + said = " ".join(str(c.args[0]) % c.args[1:] if len(c.args) > 1 else str(c.args[0]) + for c in mock_logger.error.call_args_list) + assert "Could NOT remove" in said + assert "Removed the pkg-a package" not in _infos(mock_logger) + + +def _infos(logger) -> str: + return " ".join(str(c.args[0]) % c.args[1:] if len(c.args) > 1 else str(c.args[0]) + for c in logger.info.call_args_list) + + +# --------------------------------------------------------------------------- +# run_state — "loaded" is not "running", and the difference is #104's fault 2 +# --------------------------------------------------------------------------- + +def test_run_state_distinguishes_the_four_things_launchd_can_say(tmp_path, mock_logger): + """⊗ `ensure_installed() is not None` was being printed as "the LaunchAgent + is running", and `is_running()` (which means "launchd knows this label") + passes for a job that is loaded and DEAD — the state this file already + recovers from at `_apply_plist`, and the one launchd will not respawn.""" + home = tmp_path / "home" + spec = _spec("com.example.a", "pkg-a", str(tmp_path / "s")) + + running = _agent(home, spec, mock_logger, runner=FakeRunner(pid=1234)) + assert running.run_state() == LaunchAgent.RUNNING + assert running.is_alive() is True + assert running.is_running() is True + + # Loaded, `launchctl print` succeeds, but there is no `pid =` line at all. + dead = _agent(home, spec, mock_logger, runner=FakeRunner(pid=None)) + assert dead.run_state() == LaunchAgent.LOADED_NOT_RUNNING + assert dead.is_alive() is False + assert dead.is_running() is True, "still 'loaded' — that is what is_running means" + + absent = _agent(home, spec, mock_logger, runner=FakeRunner(returncode=1)) + assert absent.run_state() == LaunchAgent.NOT_LOADED + assert absent.is_alive() is False + + +def test_an_unparseable_pid_line_is_UNKNOWN_and_counts_as_alive(tmp_path, mock_logger): + """We cannot prove it either way; reporting failure would call a healthy + server stopped, and killing it would be worse.""" + class _GarbledPid(FakeRunner): + def __call__(self, cmd, **kwargs): + result = super().__call__(cmd, **kwargs) + if len(cmd) >= 2 and cmd[0] == "launchctl" and cmd[1] == "print": + result.stdout = "\tstate = running\n\tpid = not-a-number\n" + return result + + agent = _agent(tmp_path / "home", _spec("com.example.a", "pkg-a", str(tmp_path / "s")), + mock_logger, runner=_GarbledPid()) + assert agent.run_state() == LaunchAgent.UNKNOWN + assert agent.is_alive() is True + + +def test_start_over_a_loaded_but_dead_job_does_not_report_success(tmp_path, mock_logger): + """`start()` is what fabric restore believes, and it used to answer + `is_running()` — "launchd knows this label" — after installing.""" + agent = _agent(tmp_path / "home", _spec("com.example.a", "pkg-a", str(tmp_path / "s")), + mock_logger, runner=FakeRunner(pid=None)) + assert not os.path.exists(agent.plist_path) # the ensure_installed branch + assert agent.start() is False + + # --------------------------------------------------------------------------- # Reaping is per-agent: an agent must never signal the other agent's process # --------------------------------------------------------------------------- diff --git a/tests/test_pairing_menu.py b/tests/test_pairing_menu.py index 7ce273d..5bd4873 100644 --- a/tests/test_pairing_menu.py +++ b/tests/test_pairing_menu.py @@ -24,7 +24,9 @@ from __future__ import annotations import importlib +import os import xml.etree.ElementTree as ET +from datetime import datetime, timedelta, timezone from pathlib import Path from unittest.mock import Mock @@ -115,6 +117,100 @@ def _fabric(index, vendor=0x1349, label=""): return bridge_protocol.FabricInfo(fabric_index=index, label=label, vendor_id=vendor) +def _iso(*, minutes: int) -> str: + """An RFC 3339 stamp `minutes` from now — never a literal date. + + The readout compares against the clock, so a hard-coded timestamp is a test + that starts failing on a date nobody wrote down. + """ + when = datetime.now(timezone.utc) + timedelta(minutes=minutes) + return when.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _removal(removed=True, remaining=1): + return bridge_protocol.FabricRemoval(removed=removed, remaining=remaining) + + +def _unpair_bridge(plug, *, fabrics, removal): + """A bridge wired for the unpair path: removal result, then the cache re-read. + + The menu makes two round trips — ``remove_fabric`` and the ``get_pairing`` + that refreshes the picker's cache — so the fake runtime has to answer both, + in order. + """ + client = _bridge_with(plug, fabrics=fabrics) + plug.runtime = _FakeRuntime([removal, _pairing(fabrics=fabrics[:-1])]) + return plug.export_bridge, client + + +def _first_run_agent(tmp_path, logger): + """A REAL :class:`BridgeProcess` on a machine that has never installed the package. + + Faithful where a ``Mock`` is not, which is the whole point: the first-run + dead end lives in the interaction between ``ensure_installed`` (writes the + plist, but only once preflight passes) and ``restart`` (which needs a plist + to exist on disk). A ``Mock(restart=…True)`` has a plist by virtue of being a + Mock, so the old test asserted the bug away. + + Only ``install`` is replaced, and it does what npm would: plant the package + entry. launchctl is a FakeRunner; the filesystem is really written to. + """ + import bridge_agent as bridge_agent_mod + from test_server_process import FakeRunner + + home = tmp_path / "home" + bindir = home / "bin" + bindir.mkdir(parents=True, exist_ok=True) + (bindir / "npx").write_text("#!/bin/sh\n") + (bindir / "node").write_text("#!/bin/sh\n") + + class _FirstRunBridge(bridge_agent_mod.BridgeProcess): + def install(self, install_spec=bridge_agent_mod.DEFAULT_INSTALL_SPEC): # noqa: ARG002 + entry = Path(self.project_dir) / "node_modules" / self.spec.package / "dist" / "main.js" + entry.parent.mkdir(parents=True, exist_ok=True) + entry.write_text("// installed by the fake npm\n") + return True + + return _FirstRunBridge({}, logger, home=str(home), npx_path=str(bindir / "npx"), + runner=FakeRunner(), sleep=lambda *_a: None) + + +def _running_agent(state="running"): + """An agent double whose `run_state()` answers `state`, with the real names.""" + from launch_agent import LaunchAgent + + return Mock(ensure_installed=Mock(return_value=False), ws_port="5581", + matter_port="5540", run_state=Mock(return_value=state), + RUNNING=LaunchAgent.RUNNING, UNKNOWN=LaunchAgent.UNKNOWN, + NOT_LOADED=LaunchAgent.NOT_LOADED, + LOADED_NOT_RUNNING=LaunchAgent.LOADED_NOT_RUNNING, + preflight=Mock(return_value=None), + tail_error_log=Mock(return_value=None), + log_dir="/tmp/logs", project_dir="/tmp/proj") + + +def _healthy_agent(**overrides): + """A bridge agent double whose preflight PASSES (so the log tail is reached). + + `Mock()` alone will not do since the diagnosis learned to ask preflight + first: a bare Mock returns a truthy Mock, which reads as "it cannot start". + """ + agent = Mock(project_dir="/Users/x/indigo-matter", + log_dir="/Users/x/Library/Logs/indigo-matter", + matter_port="5540", ws_port="5581", **overrides) + agent.preflight.return_value = None + return agent + + +def indigo_dict(plugin_mod): + return plugin_mod.indigo.Dict() + + +def plugin_module_no_selection_id() -> str: + import plugin as plugin_module + return plugin_module.NO_SELECTION_ID + + def _logged(logger) -> str: calls = (logger.info.call_args_list + logger.warning.call_args_list + logger.error.call_args_list) @@ -335,8 +431,27 @@ class TestFabricPicker: def test_it_lists_each_fabric_by_vendor_and_index(self, plug): _bridge_with(plug, fabrics=[_fabric(1), _fabric(2, vendor=0x1217)]) rows = dict(plug.getBridgeFabrics()) - assert rows["1"] == "Apple (index 1)" - assert rows["2"] == "Amazon (index 2)" + assert rows["1"] == "Apple Home (index 1)" + assert rows["2"] == "Amazon Alexa (index 2)" + + def test_the_first_row_is_a_no_selection_row(self, plug): + """⊗ Indigo pre-selects row one. Without this the destructive dialog + opened with a real ecosystem already chosen and its Execute button one + click away — and this was the ONE picker in the plugin without it.""" + _bridge_with(plug, fabrics=[_fabric(1), _fabric(2, vendor=0x1217)]) + rows = plug.getBridgeFabrics() + assert rows[0][0] == plugin_module_no_selection_id() + assert "select an ecosystem" in rows[0][1] + + def test_the_unpair_dialog_is_seeded_with_the_no_selection_row(self, plug): + """A picker row is only half of it: an unseeded field still lands on the + first row, and a seeded value with no matching row renders blank.""" + values = plug.get_menu_action_config_ui_values("unpairEcosystem") + assert values["fabric"] == plugin_module_no_selection_id() + + def test_seeding_the_unpair_dialog_does_not_seed_other_menus(self, plug): + """This callback fires for EVERY menu with a ConfigUI.""" + assert dict(plug.get_menu_action_config_ui_values("resetBridgePairings")) == {} def test_never_connected_and_not_paired_are_DIFFERENT_answers(self, plug): """Both are unpickable, but only one of them means "you are not paired".""" @@ -393,18 +508,21 @@ def test_no_selection_removes_nothing(self, plug): client.remove_fabric.assert_not_called() def test_both_ticks_call_remove_fabric_with_the_picked_index(self, plug): - client = _bridge_with(plug, fabrics=[_fabric(1), _fabric(2, vendor=0x100B)]) + bridge, client = _unpair_bridge(plug, fabrics=[_fabric(1), _fabric(2, vendor=0x6006)], + removal=_removal(True, 1)) ok, _values = plug.menuUnpairEcosystem( {"fabric": "2", "confirm": True, "confirmAgain": True}) assert ok is True client.remove_fabric.assert_called_once_with(2) assert "has been unpaired" in _logged(plug.logger) + # The picker's cache must not keep offering what was just removed. + bridge.note_fabrics.assert_called_once() def test_removing_the_LAST_fabric_says_the_bridge_has_reset_itself(self, plug): """⊗ E5's hardened case. matter.js factory-resets when the fabric set empties, so the user has just reset the whole bridge without using the reset menu — reporting a routine removal would be a lie.""" - client = _bridge_with(plug, fabrics=[_fabric(1)]) + _bridge, client = _unpair_bridge(plug, fabrics=[_fabric(1)], removal=_removal(True, 0)) ok, _values = plug.menuUnpairEcosystem( {"fabric": "1", "confirm": True, "confirmAgain": True}) assert ok is True @@ -412,6 +530,62 @@ def test_removing_the_LAST_fabric_says_the_bridge_has_reset_itself(self, plug): said = _logged(plug.logger) assert "LAST one paired" in said and "reset itself" in said + def test_the_NODES_count_beats_the_stale_cache_for_last_fabric(self, plug): + """⊗ The cache said one fabric was left; the node says none are. + + `_is_last_fabric` reads a list that can be a session old, and it selects + between "routine removal" and "your bridge has just factory-reset + itself". The node's own post-removal count is the answer when it has one. + """ + _bridge, _client = _unpair_bridge(plug, fabrics=[_fabric(1), _fabric(2)], + removal=_removal(True, 0)) + ok, _values = plug.menuUnpairEcosystem( + {"fabric": "2", "confirm": True, "confirmAgain": True}) + assert ok is True + assert "LAST one paired" in _logged(plug.logger) + + def test_an_already_gone_fabric_is_NOT_reported_as_an_unpairing(self, plug): + """⊗ The node-side no-op the menu reported as a completed removal. + + `remove_fabric` early-returns when the index holds nothing, and used to + answer `{}` — the same frame a real removal produced — so the menu logged + "has been unpaired. Every accessory Indigo exports has been removed from + it" over an operation that did nothing at all. The picker is built from a + CACHED fabric list, so this is the designed way to get here, not a typo. + """ + _bridge, client = _unpair_bridge(plug, fabrics=[_fabric(1), _fabric(2)], + removal=_removal(False, 2)) + ok, _values = plug.menuUnpairEcosystem( + {"fabric": "2", "confirm": True, "confirmAgain": True}) + assert ok is True + client.remove_fabric.assert_called_once_with(2) + said = _logged(plug.logger) + assert "ALREADY gone" in said + assert "has been unpaired" not in said + assert "LAST one paired" not in said + + def test_a_node_that_cannot_count_falls_back_to_the_cache(self, plug): + """`remaining` is null when the node could not read its own fabric set — + legitimate mid-reset. A fabricated 0 there would claim a factory reset.""" + _bridge, _client = _unpair_bridge(plug, fabrics=[_fabric(1), _fabric(2)], + removal=_removal(True, None)) + ok, _values = plug.menuUnpairEcosystem( + {"fabric": "2", "confirm": True, "confirmAgain": True}) + assert ok is True + said = _logged(plug.logger) + assert "has been unpaired" in said and "LAST one paired" not in said + + def test_a_failed_cache_refresh_does_not_fail_the_unpairing(self, plug): + """The removal already happened; failing to re-read the list afterwards + is not a reason to tell the user it did not.""" + bridge, _client = _unpair_bridge(plug, fabrics=[_fabric(1), _fabric(2)], + removal=_removal(True, 1)) + bridge.note_fabrics.side_effect = RuntimeError("gone") + ok, _values = plug.menuUnpairEcosystem( + {"fabric": "2", "confirm": True, "confirmAgain": True}) + assert ok is True + assert "has been unpaired" in _logged(plug.logger) + def test_a_failed_removal_says_pairings_are_unchanged(self, plug): _bridge_with(plug, fabrics=[_fabric(1), _fabric(2)]) plug.runtime = _FakeRuntime(error=RuntimeError("node refused")) @@ -426,6 +600,47 @@ def test_it_refuses_without_a_bridge_connection(self, plug): {"fabric": "1", "confirm": True, "confirmAgain": True}) assert ok is False and "confirmAgain" in errors + def test_a_DISCONNECTED_client_is_never_handed_remove_fabric(self, plug): + """⊗ The fixture has carried `connected=False` since E6 and no test used + it. `_pairing_client` checks it; nothing proved so, and this is the + destructive one.""" + client = _bridge_with(plug, connected=False, fabrics=[_fabric(1)]) + ok, _values, errors = plug.menuUnpairEcosystem( + {"fabric": "1", "confirm": True, "confirmAgain": True}) + assert ok is False and "confirmAgain" in errors + client.remove_fabric.assert_not_called() + + +class TestPairingClientGate: + """⊗ `_pairing_client` refuses a client that exists but is not connected. + + Every pairing action goes through it, and a disconnected client accepts the + call and then blocks the Indigo UI thread until the deadline. The fixture has + carried `connected=False` since E6 without a single test using it. + """ + + def test_the_pair_menu_refuses_a_disconnected_client(self, plug): + client = _bridge_with(plug, connected=False) + ok, _values, errors = plug.menuPairMatterBridge({"duration": "900"}) + assert ok is False and "duration" in errors + client.get_pairing.assert_not_called() + client.open_commissioning_window.assert_not_called() + + def test_it_names_the_real_precondition_when_nothing_is_exported(self, plug, + plugin_mod): + """XAC2's ordering: the client exists only while something is exported, + so "pair the bridge" is genuinely unreachable until then.""" + _bridge_with(plug, connected=False) + plug.exports = ExportStore(lambda: {}, plug.logger) + plug._pairing_client(indigo_dict(plugin_mod), "duration") + assert "Export at least one device" in _logged(plug.logger) + + def test_it_says_the_node_is_not_answering_when_something_IS_exported(self, plug, + plugin_mod): + _bridge_with(plug, connected=False) + plug._pairing_client(indigo_dict(plugin_mod), "duration") + assert "not answering" in _logged(plug.logger) + # --------------------------------------------------------------------------- # The QR page @@ -460,7 +675,7 @@ def test_it_says_so_when_no_window_is_open(self, plug): fabrics=[_fabric(1)])]) body = plug.http_pairing(self._action())["content"] assert "No pairing window is open" in body - assert "Apple (index 1)" in body, "still say who is paired" + assert "Apple Home (index 1)" in body, "still say who is paired" def test_it_renders_a_page_even_with_no_bridge_at_all(self, plug): """A blank page over a bridge that is merely not running is @@ -469,6 +684,37 @@ def test_it_renders_a_page_even_with_no_bridge_at_all(self, plug): body = plug.http_pairing(self._action())["content"] assert "not connected to the Matter bridge node" in body + def test_a_DISCONNECTED_client_is_never_asked_for_a_page(self, plug): + """⊗ The fixture has carried `connected=False` since E6 and no test used + it. A disconnected client handed `get_pairing` blocks the IWS handler on + a socket that is not there until the 15s deadline expires.""" + client = _bridge_with(plug, connected=False) + body = plug.http_pairing(self._action())["content"] + client.get_pairing.assert_not_called() + assert "not connected to the Matter bridge node" in body + + def test_the_page_says_the_code_is_a_LIVE_credential(self, plug): + """The page is served by IWS, which authenticates only if the user has + switched authentication on — so while a window is open, anyone on the LAN + who can reach this URL can commission the bridge onto their own fabric.""" + _bridge_with(plug) + plug.runtime = _FakeRuntime([_pairing()]) + body = plug.http_pairing(self._action())["content"] + assert "live commissioning passcode" in body + assert "authentication" in body + + def test_the_log_line_carries_the_same_warning(self, plug): + _bridge_with(plug) + plug.runtime = _FakeRuntime([_pairing(window_open=False, manual=None, qr=None), + bridge_protocol.CommissioningWindow( + manual_pairing_code="1234-567-8901", + qr_pairing_code="MT:X", + window_expires_at=_iso(minutes=+15))]) + ok, _values = plug.menuPairMatterBridge({"duration": "900"}) + assert ok is True + said = _logged(plug.logger) + assert "SECURITY" in said and "THEIR Apple Home" in said + def test_a_failed_get_pairing_becomes_a_page_not_a_500(self, plug): _bridge_with(plug) plug.runtime = _FakeRuntime(error=RuntimeError("socket gone")) @@ -531,51 +777,146 @@ def test_the_diagnosis_before_any_agent_names_the_real_precondition(self, plug): assert "export at least one device" in plug._bridge_agent_diagnosis().lower() def test_the_diagnosis_reads_the_agents_own_error_log(self, plug): - plug.bridge_process = Mock() + plug.bridge_process = _healthy_agent() plug.bridge_process.tail_error_log.return_value = "listen EADDRINUSE :::5540" assert "EADDRINUSE" in plug._bridge_agent_diagnosis() + def test_the_diagnosis_asks_PREFLIGHT_before_guessing_at_the_log(self, plug): + """⊗ It said "the package may not be installed (checked {project_dir})" + having checked only the error log — and preflight() holds that exact + fact. An uninstalled package is also the case where the log is empty for + the right reason: launchd never got far enough to write one.""" + plug.bridge_process = _healthy_agent() + plug.bridge_process.preflight.return_value = ( + "the indigo-matter-bridge package is not installed (…/dist/main.js is missing)") + plug.bridge_process.tail_error_log.return_value = "some ancient unrelated crash" + said = plug._bridge_agent_diagnosis() + assert "not installed" in said + assert "ancient" not in said, "the real cause must not be buried under a stale log tail" + def test_the_diagnosis_never_restarts_anything(self, plug): """launchd owns respawn; a diagnostic that bounced the agent on every failure streak would turn a crash-loop into an unreadable one.""" - plug.bridge_process = Mock() + plug.bridge_process = _healthy_agent() plug.bridge_process.tail_error_log.return_value = "boom" plug._bridge_agent_diagnosis() plug.bridge_process.restart.assert_not_called() plug.bridge_process.ensure_installed.assert_not_called() plug.bridge_process.stop.assert_not_called() - def test_an_empty_error_log_points_at_the_bridge_install_menu(self, plug): - plug.bridge_process = Mock() + def test_the_log_tail_is_not_called_RECENT(self, plug): + """The file is appended to and never truncated, so the last 20 lines can + be a crash-loop from days ago that has since been fixed.""" + plug.bridge_process = _healthy_agent() + plug.bridge_process.tail_error_log.return_value = "boom" + said = plug._bridge_agent_diagnosis() + assert "Recent" not in said + assert "may be old" in said + assert "bridge-node.err.log" in said, "name the file so timestamps can be checked" + + def test_an_empty_error_log_with_the_package_present_says_so(self, plug): + """Preflight passed, so "may not be installed" is no longer a guess that + is even available — it is installed. Point at the other cause.""" + plug.bridge_process = _healthy_agent() plug.bridge_process.tail_error_log.return_value = None - plug.bridge_process.project_dir = "/Users/x/indigo-matter" said = plug._bridge_agent_diagnosis() - assert "indigo-matter-bridge" in said and "export bridge" in said - - def test_stop_is_a_stop_not_an_uninstall(self, plug): - """The plist is cheap to keep and re-exporting should not re-derive it; - the storage dir is never touched by either.""" - plug.bridge_process = Mock() - plug.bridge_process.stop.return_value = True + assert "is installed" in said and "port" in said + + def test_stop_removes_the_plist_so_a_REBOOT_cannot_restart_it(self, plug, tmp_path): + """⊗ XG5/XAC1 across a reboot. + + `stop()` kept the plist, and the plist carries `RunAtLoad: True` — so at + the next login launchd started an unpaired bridge node with an EMPTY + allow-list, advertising on the Matter port, that this plugin never + started and (XAC1's latch) would never stop. The guarantee is "a fresh or + emptied install runs no bridge process", and it has to survive a restart. + """ + plist = tmp_path / "com.simons-plugins.indigo-matter.bridge.plist" + plist.write_text("") + agent = Mock(plist_path=str(plist)) + agent.is_running.side_effect = [True, False] + agent.uninstall.side_effect = lambda: plist.unlink() + plug.bridge_process = agent + plug._stop_bridge_agent() + agent.uninstall.assert_called_once() + assert not plist.exists(), "the plist must be gone, not merely booted out" + assert "cannot bring it back" in _logged(plug.logger) + + def test_a_stop_that_did_not_work_is_LOUD(self, plug, tmp_path): + """⊗ The silent branch. `stop()` returning False said nothing at all and + `_agent_started` had already been cleared, so nothing retried: the node + kept serving every paired ecosystem with the log asserting the opposite + by omission.""" + plist = tmp_path / "bridge.plist" + plist.write_text("") + agent = Mock(plist_path=str(plist)) + agent.is_running.return_value = True # still loaded afterwards + plug.bridge_process = agent plug._stop_bridge_agent() - plug.bridge_process.stop.assert_called_once() - plug.bridge_process.uninstall.assert_not_called() + said = _logged(plug.logger) + assert "could not be stopped" in said + assert "NOTHING retries" in said + assert "launchctl bootout" in said + + def test_stopping_when_nothing_was_loaded_is_not_reported_as_a_failure(self, plug, + tmp_path): + """The other False `stop()` conflated: "there was no such job".""" + agent = Mock(plist_path=str(tmp_path / "absent.plist")) + agent.is_running.return_value = False + plug.bridge_process = agent + plug._stop_bridge_agent() + assert "could not be" not in _logged(plug.logger) + + def test_the_stop_menu_exists_and_clears_the_XAC1_latch(self, plug, plugin_mod, + monkeypatch, tmp_path): + """A user who disables the plugin otherwise leaves the node running with + no UI at all — the allow-list lever needs the plugin to be running.""" + agent = Mock(plist_path=str(tmp_path / "absent.plist")) + agent.is_running.return_value = False + monkeypatch.setattr(plugin_mod.bridge_agent, "BridgeProcess", + lambda *_a, **_k: agent) + bridge = Mock() + plug.export_bridge = bridge + ok, _values = plug.menuStopBridgeNode({"confirm": True}) + assert ok is True + agent.uninstall.assert_called_once() + bridge.note_agent_stopped.assert_called_once() + + def test_the_stop_menu_needs_the_tick(self, plug, plugin_mod, monkeypatch): + built = [] + monkeypatch.setattr(plugin_mod.bridge_agent, "BridgeProcess", + lambda *_a, **_k: built.append(1)) + ok, _values, errors = plug.menuStopBridgeNode({"confirm": False}) + assert ok is False and "confirm" in errors + assert built == [] - def test_start_rebuilds_the_agent_from_CURRENT_prefs(self, plug, plugin_mod, - monkeypatch): + def test_start_rebuilds_the_agent_from_CURRENT_prefs_EVERY_TIME(self, plug, plugin_mod, + monkeypatch): """A snapshotted agent writes yesterday's ports while reporting success — - the fault menuRestartMatterServer learned the hard way.""" + the fault menuRestartMatterServer learned the hard way. + + Called TWICE with changed prefs in between: called once from a + `bridge_process is None` fixture, this test could not tell "rebuilds on + every call" from "builds once and caches", which is the thing it exists + to pin. + """ built = [] def _factory(prefs, _logger): built.append(dict(prefs)) - return Mock(ensure_installed=Mock(return_value=True), ws_port="5581", - matter_port="5540") + return _running_agent() monkeypatch.setattr(plugin_mod.bridge_agent, "BridgeProcess", _factory) plug.pluginPrefs["bridgeMatterPort"] = "5541" plug._start_bridge_agent() assert built[-1]["bridgeMatterPort"] == "5541" + # The agent now exists and is cached on the plugin — a build-once + # implementation passes everything above and fails from here. + assert plug.bridge_process is not None + plug.pluginPrefs["bridgeMatterPort"] = "5542" + plug._start_bridge_agent() + assert len(built) == 2, "a cached agent would serve yesterday's ports" + assert built[-1]["bridgeMatterPort"] == "5542" def test_start_says_nothing_reassuring_when_preflight_failed(self, plug, plugin_mod, monkeypatch): @@ -586,6 +927,50 @@ def test_start_says_nothing_reassuring_when_preflight_failed(self, plug, plugin_ plug._start_bridge_agent() assert "LaunchAgent is running" not in _logged(plug.logger) + def test_start_does_not_claim_RUNNING_when_launchd_never_loaded_it(self, plug, + plugin_mod, + monkeypatch): + """⊗ The wrong signal. + + `ensure_installed()` returns False for "already loaded and healthy" AND + for "bootout worked, bootstrap and load both failed" — and this printed + "bridge node LaunchAgent is running" for both, over a job launchd has + never heard of. + """ + agent = _running_agent(state="not_loaded") + monkeypatch.setattr(plugin_mod.bridge_agent, "BridgeProcess", lambda *_a, **_k: agent) + plug._start_bridge_agent() + said = _logged(plug.logger) + assert "LaunchAgent is running" not in said + assert "could not be loaded" in said + + def test_start_does_not_claim_RUNNING_over_a_loaded_but_DEAD_job(self, plug, plugin_mod, + monkeypatch): + """The #104 fault-2 state: loaded, no pid, and launchd will not respawn it.""" + agent = _running_agent(state="loaded_not_running") + monkeypatch.setattr(plugin_mod.bridge_agent, "BridgeProcess", lambda *_a, **_k: agent) + plug._start_bridge_agent() + said = _logged(plug.logger) + assert "LaunchAgent is running" not in said + assert "did not start" in said + + def test_start_reports_an_UNPARSEABLE_pid_as_neither_success_nor_failure( + self, plug, plugin_mod, monkeypatch): + """A pid line we could not read is not evidence of death; claiming the + job failed would report a healthy bridge as stopped.""" + agent = _running_agent(state="unknown") + monkeypatch.setattr(plugin_mod.bridge_agent, "BridgeProcess", lambda *_a, **_k: agent) + plug._start_bridge_agent() + said = _logged(plug.logger) + assert "is loaded" in said and "readable pid" in said + assert not plug.logger.error.call_args_list + + def test_start_announces_a_genuinely_running_agent(self, plug, plugin_mod, monkeypatch): + agent = _running_agent() + monkeypatch.setattr(plugin_mod.bridge_agent, "BridgeProcess", lambda *_a, **_k: agent) + plug._start_bridge_agent() + assert "LaunchAgent is running" in _logged(plug.logger) + def test_the_install_action_refuses_a_concurrent_npm_run(self, plug): """One npm root; two concurrent installs into it corrupt each other.""" plug._install_thread = Mock(is_alive=Mock(return_value=True)) @@ -608,13 +993,86 @@ def test_installing_with_nothing_exported_does_NOT_start_the_bridge(self, plug, def test_installing_with_an_export_restarts_onto_the_new_version(self, plug, plugin_mod, monkeypatch): - """A running LaunchAgent does not pick up new files on disk.""" - agent = Mock(install=Mock(return_value=True), restart=Mock(return_value=True)) + """A running LaunchAgent does not pick up new files on disk. + + ``ensure_installed`` returning False is "the current definition was + already loaded and left alone" — i.e. still running the OLD code — which + is exactly when a restart is needed. + """ + agent = Mock(install=Mock(return_value=True), restart=Mock(return_value=True), + ensure_installed=Mock(return_value=False)) monkeypatch.setattr(plugin_mod.bridge_agent, "BridgeProcess", lambda *_a, **_k: agent) plug._install_bridge_node() agent.restart.assert_called_once() assert "restarted onto the new version" in _logged(plug.logger) + def test_installing_writes_the_plist_BEFORE_restarting(self, plug, plugin_mod, + monkeypatch, tmp_path): + """⊗ The first-run dead end, against an agent that behaves like the real one. + + The bridge's plist is written by exactly ONE place — `_start_bridge_agent` + — and on a machine where the package has never been installed that place + cannot get past its own preflight, so it writes nothing and tears any + stale plist down. The user's route out is this menu; it went install() → + restart(), restart found no plist, and logged "nothing to restart. Fix + the problem reported above" (the install had just SUCCEEDED) followed by + "the restart FAILED — the old version may still be running" (nothing was + running). The old test could not catch it: it asserted against a + `Mock(restart=…True)`, which has a plist by virtue of being a Mock. + """ + agent = _first_run_agent(tmp_path, plug.logger) + monkeypatch.setattr(plugin_mod.bridge_agent, "BridgeProcess", lambda *_a, **_k: agent) + assert not os.path.exists(agent.plist_path), "the premise: a machine with no plist" + plug._install_bridge_node() + said = _logged(plug.logger) + assert os.path.exists(agent.plist_path), \ + "nothing ever wrote the plist, so nothing can ever run" + assert "nothing to restart" not in said + assert "FAILED" not in said + + def test_installing_does_not_bounce_a_job_ensure_installed_just_bootstrapped( + self, plug, plugin_mod, monkeypatch): + """`ensure_installed` returning True means launchd has already loaded the + NEW files. Restarting again is a second outage for nothing.""" + agent = Mock(install=Mock(return_value=True), restart=Mock(return_value=True), + ensure_installed=Mock(return_value=True)) + monkeypatch.setattr(plugin_mod.bridge_agent, "BridgeProcess", lambda *_a, **_k: agent) + plug._install_bridge_node() + agent.restart.assert_not_called() + + def test_installing_says_so_when_the_plist_could_not_be_written(self, plug, plugin_mod, + monkeypatch): + agent = Mock(install=Mock(return_value=True), restart=Mock(return_value=True), + ensure_installed=Mock(return_value=None)) + monkeypatch.setattr(plugin_mod.bridge_agent, "BridgeProcess", lambda *_a, **_k: agent) + plug._install_bridge_node() + agent.restart.assert_not_called() + assert "LaunchAgent could not be written" in _logged(plug.logger) + + def test_the_clean_reinstall_menu_removes_the_package_first(self, plug, plugin_mod, + monkeypatch): + """The bridge shipped without the exit the controller has had since + 2026.7, so a wedged install had a menu that reinstalled OVER the wedge.""" + order = [] + agent = Mock( + remove_package=Mock(side_effect=lambda: (order.append("remove"), True)[1]), + install=Mock(side_effect=lambda: (order.append("install"), True)[1]), + ensure_installed=Mock(return_value=True)) + monkeypatch.setattr(plugin_mod.bridge_agent, "BridgeProcess", lambda *_a, **_k: agent) + plug._install_bridge_node(clean=True) + assert order == ["remove", "install"] + + def test_a_clean_reinstall_that_cannot_remove_does_NOT_install_over_it( + self, plug, plugin_mod, monkeypatch): + """⊗ `remove_package` used to log "Removed the … package" whatever + happened, so a failed removal became a plain reinstall on top of the + wedge the user came here to clear — reported as a clean one.""" + agent = Mock(remove_package=Mock(return_value=False), install=Mock(return_value=True)) + monkeypatch.setattr(plugin_mod.bridge_agent, "BridgeProcess", lambda *_a, **_k: agent) + plug._install_bridge_node(clean=True) + agent.install.assert_not_called() + assert "ABANDONED" in _logged(plug.logger) + def test_a_failed_install_changes_nothing(self, plug, plugin_mod, monkeypatch): agent = Mock(install=Mock(return_value=False)) monkeypatch.setattr(plugin_mod.bridge_agent, "BridgeProcess", lambda *_a, **_k: agent) @@ -644,11 +1102,46 @@ def test_it_names_the_paired_ecosystems_not_the_slot_count(self, plug): interesting number — WHICH ecosystems hold one is.""" _bridge_with(plug, fabrics=[_fabric(1), _fabric(2, vendor=0x1217)]) readout = plug._export_readout() - assert "Apple (index 1)" in readout and "Amazon (index 2)" in readout + assert "Apple Home (index 1)" in readout and "Amazon Alexa (index 2)" in readout + # The list is a cache the §5 events maintain, never a WS round trip on a + # dialog open — so it must not be presented as this instant's truth. + assert "last reported" in readout def test_it_reports_an_open_window(self, plug): - _bridge_with(plug, fabrics=[_fabric(1)], window="2026-08-05T12:00:00Z") - assert "window is open until 2026-08-05T12:00:00Z" in plug._export_readout() + soon = _iso(minutes=+10) + _bridge_with(plug, fabrics=[_fabric(1)], window=soon) + assert f"window is open until {soon}" in plug._export_readout() + + def test_a_window_whose_time_has_PASSED_is_not_reported_as_open(self, plug): + """⊗ `window_expires_at` is cleared only by the §5 `window_closed` event, + which the node does NOT send on shutdown — so a node (or plugin) restart + left the timestamp standing and the readout claimed an open window + indefinitely, including for a time hours in the past.""" + gone = _iso(minutes=-30) + _bridge_with(plug, fabrics=[_fabric(1)], window=gone) + readout = plug._export_readout() + assert "is open until" not in readout + assert f"expired at {gone}" in readout + + def test_an_unparseable_expiry_is_reported_rather_than_dropped(self, plug): + _bridge_with(plug, fabrics=[_fabric(1)], window="whenever") + assert "expiring whenever" in plug._export_readout() + + def test_a_CRASH_LOOPING_node_is_not_reported_as_paired(self, plug): + """⊗ `active` means "a client OBJECT exists" and is set for the life of + the engine, reconnect attempts included. Read as "the node is running" it + told a user whose bridge had been down for a day "3 device(s) exported. + Paired with: Apple Home" — the readout PRD §5.5 exists to prevent.""" + _bridge_with(plug, fabrics=[_fabric(1)], connected=False) + readout = plug._export_readout() + assert "NOT connected" in readout + assert "Paired with" not in readout + + def test_connected_but_not_serving_says_so(self, plug): + """§1.1: a node that is refusing answers the socket and serves nothing.""" + client = _bridge_with(plug, fabrics=[_fabric(1)]) + client.attached = False + assert "not serving accessories" in plug._export_readout() def test_it_reports_the_switch_being_off(self, plug): _bridge_with(plug, fabrics=[_fabric(1)], enabled=False) @@ -670,3 +1163,132 @@ def test_it_does_no_io(self, plug): def test_it_survives_the_plugin_not_having_started(self, plug): plug.export_bridge = None assert "still starting" in plug._export_readout() + + +class TestSavingConfig: + """⊗ ``closedPrefsConfigUi``'s ``exports_changed()`` re-run. + + Three lines with no local symptom, and the ONLY path that makes the "Enable + Matter export" switch act without a plugin reload — which PluginConfig.xml + promises the user in prose. Deleting them left the whole suite green. + """ + + def test_saving_config_applies_the_export_switch_immediately(self, plug): + bridge = Mock() + plug.export_bridge = bridge + plug.closedPrefsConfigUi({"verboseLogging": False}, False) + bridge.exports_changed.assert_called_once() + + def test_cancelling_changes_nothing(self, plug): + bridge = Mock() + plug.export_bridge = bridge + plug.closedPrefsConfigUi({"verboseLogging": False}, True) + bridge.exports_changed.assert_not_called() + + def test_a_failure_to_apply_it_gets_a_SENTENCE_not_a_bare_traceback(self, plug): + """The user's mental model is "I clicked Save"; a stack trace under that + reads as a crash in saving, which is the one thing that did work.""" + bridge = Mock() + bridge.exports_changed.side_effect = RuntimeError("launchd said no") + plug.export_bridge = bridge + plug.closedPrefsConfigUi({"verboseLogging": False}, False) + said = " ".join(str(c.args[0]) % c.args[1:] if len(c.args) > 1 else str(c.args[0]) + for c in plug.logger.error.call_args_list) + assert "settings were saved" in said and "Reload the plugin" in said + + def test_the_no_engine_path_says_something(self, plug): + """Switched off with no client: silence here reads as "applied".""" + plug.export_bridge = None + plug.closedPrefsConfigUi({"verboseLogging": False}, False) + assert plug.logger.debug.called + + +class TestPairingPageUrl: + def test_it_uses_the_INDIGO_web_server_url_not_localhost(self, plug, plugin_mod): + """⊗ Deleting the `getWebServerURL()` call survived, even though the + fixture stubs it to jarvis.local — and a localhost URL is useless on the + phone the user is holding, which is the only device this page is for.""" + url = plug._pairing_page_url() + plugin_mod.indigo.server.getWebServerURL.assert_called() + assert url.startswith("http://jarvis.local:8176/") + assert "localhost" not in url + + def test_a_failure_falls_back_to_loopback_rather_than_no_url(self, plug, plugin_mod): + """A wrong-host URL a user can edit beats no URL at all.""" + plugin_mod.indigo.server.getWebServerURL.side_effect = RuntimeError("no reflector") + try: + assert plug._pairing_page_url().startswith("http://localhost:8176/") + finally: + plugin_mod.indigo.server.getWebServerURL.side_effect = None + + def test_it_points_at_this_plugins_pairing_handler(self, plug): + assert plug._pairing_page_url().endswith(f"/message/{OURS}/pairing/") + + +class TestEscaping: + def test_none_becomes_empty_not_the_word_None(self, plugin_mod): + """⊗ The reason `_escape` is hand-rolled rather than `html.escape`, named + in its own docstring and asserted by nothing: every value on the page + comes from the node or an exception, and an absent one must not render + the word "None" into a field the user is about to type into a phone.""" + assert plugin_mod._escape(None) == "" + assert "None" not in plugin_mod._pairing_html(None, "") + + def test_it_escapes_the_five_characters_that_matter(self, plugin_mod): + assert plugin_mod._escape('&') == \ + "<a href="x">&</a>" + + +class TestUiThreadDeadlines: + """⊗ Every `.result()` on the Indigo UI thread carries a timeout. + + Removing one is invisible: a node that accepts the socket and then stops + answering hangs the dialog — and the Indigo client — with no way out but + force-quitting it. Nothing about a missing `timeout=` is visible at the call + site, so it is pinned here by value. + """ + + class _TimeoutSpy: + def __init__(self, results): + self.results = list(results) + self.timeouts: list = [] + + def submit(self, coro): + coro.close() + return self + + def result(self, timeout=None): + self.timeouts.append(timeout) + return self.results.pop(0) if self.results else None + + def test_get_pairing_and_open_window_are_bounded(self, plug, plugin_mod): + _bridge_with(plug) + spy = self._TimeoutSpy([_pairing(window_open=False, manual=None, qr=None), + bridge_protocol.CommissioningWindow( + manual_pairing_code="1", qr_pairing_code="MT:1", + window_expires_at=_iso(minutes=+15))]) + plug.runtime = spy + plug.menuPairMatterBridge({"duration": "900"}) + assert spy.timeouts == [plugin_mod.PAIRING_READ_TIMEOUT, + plugin_mod.WINDOW_OPEN_TIMEOUT] + + def test_unpair_and_its_cache_refresh_are_bounded(self, plug, plugin_mod): + _bridge_with(plug, fabrics=[_fabric(1), _fabric(2)]) + spy = self._TimeoutSpy([_removal(True, 1), _pairing()]) + plug.runtime = spy + plug.menuUnpairEcosystem({"fabric": "2", "confirm": True, "confirmAgain": True}) + assert spy.timeouts == [plugin_mod.UNPAIR_TIMEOUT, plugin_mod.PAIRING_READ_TIMEOUT] + + def test_the_pairing_page_read_is_bounded(self, plug, plugin_mod): + _bridge_with(plug) + spy = self._TimeoutSpy([_pairing()]) + plug.runtime = spy + plug.http_pairing(Mock(props={"incoming_request_method": "GET", "file_path": [], + "url_query_args": {}})) + assert spy.timeouts == [plugin_mod.PAIRING_READ_TIMEOUT] + + def test_every_deadline_is_a_real_positive_number(self, plugin_mod): + for name in ("PAIRING_READ_TIMEOUT", "WINDOW_OPEN_TIMEOUT", "UNPAIR_TIMEOUT", + "FACTORY_RESET_TIMEOUT"): + value = getattr(plugin_mod, name) + assert isinstance(value, (int, float)) and value > 0, name diff --git a/tests/test_plugin_behaviour.py b/tests/test_plugin_behaviour.py index 0790225..7403488 100644 --- a/tests/test_plugin_behaviour.py +++ b/tests/test_plugin_behaviour.py @@ -1008,7 +1008,7 @@ def test_menu_install_refuses_when_already_running(plug): def test_install_handler_clean_removes_package_before_reinstall(plug, plugin_mod, monkeypatch): order = [] plug.server_process = SimpleNamespace( - remove_package=lambda: order.append("remove"), + remove_package=lambda: (order.append("remove"), True)[1], install=lambda: (order.append("install"), True)[1], resolved_bin_dir="/opt/homebrew/bin") plug.pluginPrefs = {"serverLocation": "local"} @@ -1021,6 +1021,29 @@ def test_install_handler_clean_removes_package_before_reinstall(plug, plugin_mod reinstalled.restart.assert_called_once() +def test_a_clean_reinstall_that_could_not_remove_does_not_install_over_it(plug, plugin_mod, + monkeypatch): + """⊗ `remove_package` used to log "Removed the … package" whatever happened. + + npm missing, npm refusing, an OSError starting it and an rmtree that raised + were all reported as a completed removal — so a clean reinstall quietly + became a plain reinstall on top of the wedge the user came here to clear, + and said it had cleared it. + """ + installed = [] + plug.server_process = SimpleNamespace( + remove_package=lambda: False, + install=lambda: installed.append(True) or True, + resolved_bin_dir="/x") + plug.pluginPrefs = {"serverLocation": "local"} + plug._stopping = False + plug._restart_expected_until = 0.0 + plug._install_matter_server(clean=True) + assert installed == [] + said = " ".join(str(c.args[0]) for c in plug.logger.error.call_args_list) + assert "ABANDONED" in said + + def test_install_handler_default_does_not_remove_package(plug, plugin_mod, monkeypatch): removed = [] plug.server_process = SimpleNamespace( diff --git a/tests/test_xac10_no_matter_js.py b/tests/test_xac10_no_matter_js.py index 6709f5a..37b50f6 100644 --- a/tests/test_xac10_no_matter_js.py +++ b/tests/test_xac10_no_matter_js.py @@ -96,6 +96,53 @@ def test_our_own_matter_prefixed_modules_are_not_flagged(): assert not any(needle in module for needle in DENIED_SUBSTRINGS), module +#: The bundle the release workflow zips and a user double-click-installs. +PLUGIN_BUNDLE = Path(__file__).parent.parent / "indigo-matter.indigoPlugin" + +#: What a JavaScript/TypeScript source or bundle looks like on disk. Node's own +#: extensions plus the source-map and declaration files that would come with a +#: vendored build. +JS_SUFFIXES = frozenset({".js", ".mjs", ".cjs", ".ts", ".mts", ".cts", ".jsx", ".tsx", + ".map"}) + + +def test_no_javascript_ships_inside_the_plugin_bundle(): + """XAC10's other half, and the one E7 actually claims. + + The import check above pins that no Python module *reaches* matter.js. E7's + claim is stronger and about the artefact: "the plugin bundle ships **no + JavaScript**" (``bridge_agent``'s module docstring, PRD §8) — the node lives + in ``bridge-node/`` and is distributed as an npm package installed into + ``~/indigo-matter``. Nothing was checking that. Vendoring ``dist/`` into + ``Resources/`` to "make install easier" would satisfy every existing test, + silently ship a second copy of matter.js on the user's disk, and put a + version of the bridge inside the plugin that its own pinned install spec + disagrees with. + """ + assert PLUGIN_BUNDLE.is_dir(), f"{PLUGIN_BUNDLE} is missing" + offenders = sorted( + str(path.relative_to(PLUGIN_BUNDLE)) + for path in PLUGIN_BUNDLE.rglob("*") + if path.is_file() and path.suffix.lower() in JS_SUFFIXES + ) + assert offenders == [], ( + "these JavaScript/TypeScript files ship inside indigo-matter.indigoPlugin: " + f"{offenders}. The bridge node is an npm package (bridge_agent.DEFAULT_INSTALL_SPEC), " + "not bundle content — see ADR-0006 and PRD-indigo-matter-export §8 (XAC10/XG6)." + ) + + +def test_the_bundle_scan_would_actually_catch_a_planted_file(tmp_path): + """Pin the check itself: a sweep that found nothing because it looked in the + wrong place would pass forever.""" + planted = tmp_path / "Contents" / "Resources" / "main.js" + planted.parent.mkdir(parents=True) + planted.write_text("// matter.js bundle\n") + found = [p for p in tmp_path.rglob("*") + if p.is_file() and p.suffix.lower() in JS_SUFFIXES] + assert found == [planted] + + def test_the_denylist_would_actually_catch_a_matter_js_import(tmp_path): """Pin the check itself: a planted violation must fail.""" planted = tmp_path / "planted.py"