diff --git a/.gitignore b/.gitignore index a879176..ae6fa6e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ __pycache__/ *.indigoPlugin.zip .coverage docs/branding/.build/ +bridge-node/node_modules/ +bridge-node/dist/ +bridge-node/.test-build/ diff --git a/bridge-node/package-lock.json b/bridge-node/package-lock.json new file mode 100644 index 0000000..ebdbbde --- /dev/null +++ b/bridge-node/package-lock.json @@ -0,0 +1,200 @@ +{ + "name": "indigo-matter-bridge", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "indigo-matter-bridge", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@matter/main": "0.17.8", + "@matter/nodejs": "0.17.8", + "ws": "8.21.2" + }, + "bin": { + "indigo-matter-bridge": "dist/main.js" + }, + "devDependencies": { + "@types/node": "^22.20.1", + "@types/ws": "^8.18.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/@matter/general": { + "version": "0.17.8", + "resolved": "https://registry.npmjs.org/@matter/general/-/general-0.17.8.tgz", + "integrity": "sha512-gSjnMG2fXtXoAqvUWe8HXdJLl1QzR1I6YWVflWWomEghNymUdz/y2aY46R0e87x8fFkuDRFchVX8t1C00eG8bQ==", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^2.2.0" + } + }, + "node_modules/@matter/main": { + "version": "0.17.8", + "resolved": "https://registry.npmjs.org/@matter/main/-/main-0.17.8.tgz", + "integrity": "sha512-+KHxt0OU1GnrhfFsfFOWRfXHtukgBt4g8fWZ7hPO4NwFfFsZgd8zyHqioUgRbru/hIRqZjqljZGnWkJlkwfxyg==", + "license": "Apache-2.0", + "dependencies": { + "@matter/general": "0.17.8", + "@matter/model": "0.17.8", + "@matter/node": "0.17.8", + "@matter/protocol": "0.17.8", + "@matter/types": "0.17.8" + }, + "optionalDependencies": { + "@matter/nodejs": "0.17.8" + } + }, + "node_modules/@matter/model": { + "version": "0.17.8", + "resolved": "https://registry.npmjs.org/@matter/model/-/model-0.17.8.tgz", + "integrity": "sha512-vAOv/YdFuexjOD3cHibvFU8uMtAxgbC8GJg8395OXJy4PRcLW7hV1TPtA5+X14I+yxCtZyqE3fylV2y1t1AVkw==", + "license": "Apache-2.0", + "dependencies": { + "@matter/general": "0.17.8" + } + }, + "node_modules/@matter/node": { + "version": "0.17.8", + "resolved": "https://registry.npmjs.org/@matter/node/-/node-0.17.8.tgz", + "integrity": "sha512-EexLTjwgtEwT4j3AtNs32o/DzvMuS/tkm6XVUPOZjAQijl4qKUjNYcy3UxSzLQQkxD0MpwIbTzd5bH4gD7SIjA==", + "license": "Apache-2.0", + "dependencies": { + "@matter/general": "0.17.8", + "@matter/model": "0.17.8", + "@matter/protocol": "0.17.8", + "@matter/types": "0.17.8" + } + }, + "node_modules/@matter/nodejs": { + "version": "0.17.8", + "resolved": "https://registry.npmjs.org/@matter/nodejs/-/nodejs-0.17.8.tgz", + "integrity": "sha512-UytC/qovZpltsc2o5wsNu65gp0SBq9d0FNiJZwVAyERdw++cdDQAsceEHMuU0/4pefG4d/c1lP3J7cTMMT3T1A==", + "license": "Apache-2.0", + "dependencies": { + "@matter/general": "0.17.8", + "@matter/node": "0.17.8", + "@matter/protocol": "0.17.8", + "@matter/types": "0.17.8" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.13.0" + } + }, + "node_modules/@matter/protocol": { + "version": "0.17.8", + "resolved": "https://registry.npmjs.org/@matter/protocol/-/protocol-0.17.8.tgz", + "integrity": "sha512-vXd83JuqnBQ1oNm04NSv5I/yXxl5yek4qQUW+mBfKKFBwx3HR0mEzqWzPXmCYJdxtRDnRDWISlyIYJZU/SlzZQ==", + "license": "Apache-2.0", + "dependencies": { + "@matter/general": "0.17.8", + "@matter/model": "0.17.8", + "@matter/types": "0.17.8" + } + }, + "node_modules/@matter/types": { + "version": "0.17.8", + "resolved": "https://registry.npmjs.org/@matter/types/-/types-0.17.8.tgz", + "integrity": "sha512-z63hjdOAPbPFHum42zqtuShBJ0ypB/hVVmAjxmYH3sVOu2wDmji8T5GOh0j8MnKQG2DZNEkq/a97IctoVOcyCQ==", + "license": "Apache-2.0", + "dependencies": { + "@matter/general": "0.17.8", + "@matter/model": "0.17.8" + } + }, + "node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.21.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz", + "integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/bridge-node/package.json b/bridge-node/package.json new file mode 100644 index 0000000..606b0b1 --- /dev/null +++ b/bridge-node/package.json @@ -0,0 +1,31 @@ +{ + "name": "indigo-matter-bridge", + "version": "0.1.0", + "private": true, + "description": "Matter bridge node for the indigo-matter Indigo plugin — exports selected Indigo devices as Matter accessories.", + "license": "MIT", + "type": "module", + "main": "dist/main.js", + "bin": { + "indigo-matter-bridge": "dist/main.js" + }, + "engines": { + "node": ">=22.13.0" + }, + "scripts": { + "build": "tsc", + "start": "node dist/main.js", + "test": "tsc -p tsconfig.test.json && rm -rf .test-build/test/fixtures && cp -R test/fixtures .test-build/test/fixtures && node --test \".test-build/test/*.test.js\"", + "clean": "rm -rf dist .test-build" + }, + "dependencies": { + "@matter/main": "0.17.8", + "@matter/nodejs": "0.17.8", + "ws": "8.21.2" + }, + "devDependencies": { + "@types/node": "^22.20.1", + "@types/ws": "^8.18.1", + "typescript": "^5.9.3" + } +} diff --git a/bridge-node/src/config.ts b/bridge-node/src/config.ts new file mode 100644 index 0000000..27ff01b --- /dev/null +++ b/bridge-node/src/config.ts @@ -0,0 +1,121 @@ +/** + * CLI argument parsing and defaults for the indigo-matter bridge node. + * + * Every knob the plugin needs to drive is a long-form `--flag value` argument; + * launchd plists are easier to read that way than with environment variables. + */ + +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** Default Matter operational/commissioning port (matter.js's own default). */ +export const DEFAULT_MATTER_PORT = 5540; + +/** Default loopback WebSocket port for the plugin protocol (controller uses 5580). */ +export const DEFAULT_WS_PORT = 5581; + +/** Storage root, sibling to the controller's directory. See PRD §4.3. */ +export const DEFAULT_STORAGE_PATH = join( + homedir(), + "Library", + "Application Support", + "com.simons-plugins.indigo-matter", + "bridge-node", +); + +export interface BridgeConfig { + /** Directory holding matter.js storage plus our own persisted install identity. */ + storagePath: string; + /** Matter network port. */ + matterPort: number; + /** Loopback port for the plugin protocol WebSocket server. */ + wsPort: number; + /** Optional mDNS interface pin (maps to matter.js's `mdns.networkInterface`). */ + mdnsInterface?: string; +} + +const USAGE = `indigo-matter-bridge + +Usage: indigo-matter-bridge [options] + +Options: + --storage-path Storage directory (default: ${DEFAULT_STORAGE_PATH}) + --matter-port Matter network port (default: ${DEFAULT_MATTER_PORT}) + --ws-port Loopback protocol WebSocket port (default: ${DEFAULT_WS_PORT}) + --mdns-interface Pin mDNS to a single network interface (default: all) + --help Show this message +`; + +function parsePort(name: string, raw: string): number { + const value = Number(raw); + if (!Number.isInteger(value) || value < 1 || value > 65535) { + throw new Error(`${name} must be an integer 1-65535, got ${JSON.stringify(raw)}`); + } + return value; +} + +/** Flags that take a following value. Checked before "requires a value", so an + * unrecognised flag is reported as unknown rather than as a missing value. */ +const VALUE_FLAGS = new Set(["--storage-path", "--matter-port", "--ws-port", "--mdns-interface"]); + +/** + * Parse `process.argv.slice(2)`. Throws on unknown or malformed arguments — a + * bad launchd plist should fail loudly at start rather than silently run with + * defaults on the wrong port. + */ +export function parseArgs(argv: readonly string[]): BridgeConfig | "help" { + const config: BridgeConfig = { + storagePath: DEFAULT_STORAGE_PATH, + matterPort: DEFAULT_MATTER_PORT, + wsPort: DEFAULT_WS_PORT, + }; + + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]!; + if (flag === "--help" || flag === "-h") { + return "help"; + } + if (!VALUE_FLAGS.has(flag)) { + throw new Error(`Unknown argument: ${flag}`); + } + const value = argv[i + 1]; + if (value === undefined || value.startsWith("--")) { + throw new Error(`${flag} requires a value`); + } + i++; + switch (flag) { + case "--storage-path": + config.storagePath = value; + break; + case "--matter-port": + config.matterPort = parsePort(flag, value); + break; + case "--ws-port": + config.wsPort = parsePort(flag, value); + break; + case "--mdns-interface": + config.mdnsInterface = value; + break; + default: + throw new Error(`Unknown argument: ${flag}`); + } + } + + return config; +} + +/** + * Fail before start if `--mdns-interface` names something this host does not + * have: matter.js takes the name on trust and would advertise on nothing, + * which presents as "the bridge never appears" with no error anywhere. + */ +export function assertMdnsInterface(name: string, available: readonly string[]): void { + if (!available.includes(name)) { + throw new Error( + `--mdns-interface ${name} is not a network interface on this host ` + + `(available: ${available.length > 0 ? available.join(", ") : "none"})`, + ); + } +} + +export { USAGE }; diff --git a/bridge-node/src/main.ts b/bridge-node/src/main.ts new file mode 100644 index 0000000..8524be3 --- /dev/null +++ b/bridge-node/src/main.ts @@ -0,0 +1,141 @@ +#!/usr/bin/env node +/** + * indigo-matter bridge node entry point. + * + * Wires the three pieces together — persisted identity, the Matter ServerNode, + * and the loopback protocol server — and shuts them down cleanly on SIGTERM so + * launchd restarts are not mistaken for crashes. + */ + +import { createRequire } from "node:module"; +import { networkInterfaces } from "node:os"; + +import { assertMdnsInterface, parseArgs, USAGE } from "./config.js"; +import { BridgeNode, matterJsVersion } from "./node.js"; +import { describeError, describeErrorWithStack } from "./protocol.js"; +import { loadOrCreateIdentity } from "./storage.js"; +import { BridgeWsServer } from "./ws-server.js"; + +const bridgeVersion: string = createRequire(import.meta.url)("../package.json").version; + +/** Give a stalled shutdown this long before we stop being polite about it. */ +const SHUTDOWN_ESCAPE_MS = 10_000; + +function log(message: string): void { + // stdout; launchd captures it into the plugin's log file. + console.log(`[bridge] ${new Date().toISOString()} ${message}`); +} + +/** + * Label each startup step so a launchd log shows *which* one failed. Without + * this, "Error: listen EADDRINUSE" could equally be the Matter port or the + * protocol port. + */ +async function phase(name: string, run: () => Promise | T): Promise { + try { + return await run(); + } catch (error) { + log(`Startup failed — ${name}: ${describeErrorWithStack(error)}`); + throw error; + } +} + +async function main(): Promise { + let parsed; + try { + parsed = parseArgs(process.argv.slice(2)); + } catch (error) { + log(`Invalid arguments: ${describeError(error)}`); + process.stderr.write(USAGE); + process.exit(1); + } + if (parsed === "help") { + process.stdout.write(USAGE); + return; + } + const config = parsed; + + log(`indigo-matter-bridge ${bridgeVersion} (matter.js ${matterJsVersion}, node ${process.version})`); + + if (config.mdnsInterface !== undefined) { + await phase("mDNS interface check", () => + assertMdnsInterface(config.mdnsInterface!, Object.keys(networkInterfaces())), + ); + } + + const identity = await phase("identity load failed", () => loadOrCreateIdentity(config.storagePath, log)); + + const bridge = new BridgeNode(config, identity, bridgeVersion, log); + await phase(`Matter node start failed (matter port ${config.matterPort}, storage ${config.storagePath})`, () => + bridge.start(), + ); + + const ws = new BridgeWsServer({ + port: config.wsPort, + bridge, + bridgeVersion, + matterJsVersion, + log, + }); + await phase(`protocol WS listen failed (port ${config.wsPort})`, () => ws.listen()); + + let shuttingDown = false; + const shutdown = (signal: string): void => { + if (shuttingDown) { + return; + } + shuttingDown = true; + log(`Received ${signal}, shutting down`); + + // Armed before the awaits, so a close that never resolves is still + // caught. Unref'd: it must not itself hold the loop open. A forced exit + // is not a clean shutdown, hence exit code 1 — launchd should see the + // difference. BridgeNode.start() sets `runtime.signals: false`, so + // matter.js's ProcessManager is not racing us for these signals. + let pending = "protocol WS close"; + const escapeHatch = setTimeout(() => { + log(`Shutdown stalled at: ${pending}; forcing exit`); + process.exit(1); + }, SHUTDOWN_ESCAPE_MS); + escapeHatch.unref(); + + void (async () => { + // Separate try blocks: a failing WS close must not skip the Matter + // close, which is what releases the storage lock. + try { + await ws.close(); + } catch (error) { + log(`Error closing protocol WS: ${describeErrorWithStack(error)}`); + } + pending = "Matter node close"; + try { + await bridge.close(); + } catch (error) { + log(`Error closing Matter node: ${describeErrorWithStack(error)}`); + } + pending = "event loop drain"; + log("Shutdown complete"); + })(); + }; + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); +} + +// A crash must reach the launchd-captured stdout log with its stack, then exit +// non-zero so launchd restarts us. Silent survival on a broken invariant is the +// worse outcome: the plugin would see a live socket and a dead bridge. +process.on("uncaughtException", (error: unknown) => { + log(`Uncaught exception: ${describeErrorWithStack(error)}`); + process.exit(1); +}); +process.on("unhandledRejection", (reason: unknown) => { + log(`Unhandled rejection: ${describeErrorWithStack(reason)}`); + process.exit(1); +}); + +main().catch((error: unknown) => { + log(`Fatal: ${describeErrorWithStack(error)}`); + // Secondary copy on stderr, for the case where stdout is the thing that broke. + console.error(`[bridge] fatal: ${describeErrorWithStack(error)}`); + process.exit(1); +}); diff --git a/bridge-node/src/node.ts b/bridge-node/src/node.ts new file mode 100644 index 0000000..76e4f41 --- /dev/null +++ b/bridge-node/src/node.ts @@ -0,0 +1,347 @@ +/** + * The Matter side of the bridge: a ServerNode with an Aggregator at endpoint 1 + * and (in E0) a single hard-coded bridged child endpoint. + * + * All matter.js coupling lives here and in main.ts — ADR-0006's binding + * constraint keeps it out of the Indigo plugin entirely, and this module keeps + * it out of the protocol layer so the protocol is testable on its own. + */ + +import { Endpoint, Environment, ServerNode, VendorId, version as matterJsVersion } from "@matter/main"; +import { BridgedDeviceBasicInformationServer } from "@matter/main/behaviors/bridged-device-basic-information"; +import { OnOffPlugInUnitDevice } from "@matter/main/devices/on-off-plug-in-unit"; +import { AggregatorEndpoint } from "@matter/main/endpoints/aggregator"; +import { Crypto } from "@matter/main"; +import { DeviceCommissioner, PaseClient, PaseServer, SessionManager } from "@matter/main/protocol"; +import { CommissioningFlowType, ManualPairingCodeCodec, QrPairingCodeCodec } from "@matter/main/types"; + +import type { BridgeConfig } from "./config.js"; +import { + type BridgeFacade, + type CommissioningWindowResult, + describeError, + describeErrorWithStack, + ErrorCode, + type FabricInfo, + type PairingReport, + ProtocolError, + type StatusReport, + type WindowClosedReason, +} from "./protocol.js"; +import { type BridgeIdentity, nodeUniqueIdFor, serialNumberFor } from "./storage.js"; +import { CommissioningWindow } from "./window.js"; + +/** Test vendor id. Uncertified by design — see ADR-0006 "Attestation cuts both ways". */ +export const VENDOR_ID = 0xfff1; +export const PRODUCT_ID = 0x8000; +export const VENDOR_NAME = "simons-plugins"; +export const PRODUCT_NAME = "Indigo Matter Bridge"; + +/** The one hard-coded export of E0. E1 replaces this with protocol-driven CRUD. */ +const E0_DEVICE_ID = 999001; +const E0_LABEL = "Indigo E0 Test"; +const E0_ROLE = "onOffPlugInUnit"; + +/** PBKDF iteration count for enhanced-window verifiers. Spec floor is 1000. */ +const PBKDF_ITERATIONS = 1000; +const PBKDF_SALT_BYTES = 32; + +export { matterJsVersion }; + +/** Bridged Device Basic Information `UniqueID`, stable across restarts. */ +export function uniqueIdFor(indigoDeviceId: number): string { + return `indigo-${indigoDeviceId}`; +} + +/** + * `Endpoint.id` derivation — the identity key of BRIDGE_PROTOCOL §4.1/§6.3. + * Deliberately the *same* value as {@link uniqueIdFor}: one derivation means the + * two can never drift apart, and §6.3's one-way identity flow reads directly. + */ +export const endpointIdFor = uniqueIdFor; + +export class BridgeNode implements BridgeFacade { + #server?: ServerNode; + #child?: Endpoint; + readonly #window: CommissioningWindow; + + constructor( + private readonly config: BridgeConfig, + private readonly identity: BridgeIdentity, + private readonly bridgeVersion: string, + private readonly log: (message: string) => void = console.log, + ) { + this.#window = new CommissioningWindow({ + log: message => this.log(message), + onExpire: () => this.endMatterCommissioning(), + }); + } + + /** Matter wants an integer software version; derive it from the npm semver. */ + private get softwareVersion(): number { + const [major = 0, minor = 0, patch = 0] = this.bridgeVersion.split(".").map(part => Number.parseInt(part, 10)); + return (major || 0) * 10000 + (minor || 0) * 100 + (patch || 0); + } + + /** + * Build the node and bring it online. Aggregator is added first so it takes + * endpoint 1; the bridged child then lands at 2. + */ + async start(): Promise { + const environment = Environment.default; + // matter.js resolves all of these lazily through its VariableService, + // so setting them before `ServerNode.create` is what keeps storage out + // of ~/.matter and pins mDNS when asked. + environment.vars.set("storage.path", this.config.storagePath); + if (this.config.mdnsInterface !== undefined) { + environment.vars.set("mdns.networkInterface", this.config.mdnsInterface); + } + // We own SIGTERM/SIGINT outright. matter.js's ProcessManager installs its + // own interrupt handlers when the runtime starts, and being registered + // first they run first — tearing the ServerNode down concurrently with + // main.ts's ordered shutdown. `runtime.signals` is the documented opt-out + // (`ProcessManager.hasSignalSupport` reads it). + environment.vars.set("runtime.signals", false); + + const server = await ServerNode.create({ + id: "indigo-matter-bridge", + environment, + network: { port: this.config.matterPort }, + commissioning: { + passcode: this.identity.passcode, + discriminator: this.identity.discriminator, + }, + productDescription: { + name: PRODUCT_NAME, + deviceType: AggregatorEndpoint.deviceType, + }, + basicInformation: { + vendorName: VENDOR_NAME, + vendorId: VendorId(VENDOR_ID), + productName: PRODUCT_NAME, + productId: PRODUCT_ID, + serialNumber: serialNumberFor(this.identity), + uniqueId: nodeUniqueIdFor(this.identity), + hardwareVersion: 1, + hardwareVersionString: "1", + softwareVersion: this.softwareVersion, + softwareVersionString: this.bridgeVersion, + }, + }); + this.#server = server; + + const aggregator = new Endpoint(AggregatorEndpoint, { id: "aggregator" }); + await server.add(aggregator); + + const child = new Endpoint(OnOffPlugInUnitDevice.with(BridgedDeviceBasicInformationServer), { + id: endpointIdFor(E0_DEVICE_ID), + bridgedDeviceBasicInformation: { + nodeLabel: E0_LABEL, + productName: PRODUCT_NAME, + productLabel: E0_LABEL, + serialNumber: String(E0_DEVICE_ID), + uniqueId: uniqueIdFor(E0_DEVICE_ID), + reachable: true, + }, + }); + await aggregator.add(child); + this.#child = child; + + server.events.commissioning.fabricsChanged.on((fabricIndex, action) => { + // A throw here propagates straight into matter.js's observable, and + // `fabrics()` reads server state that is legitimately gone once + // close() is under way. Log-only, never rethrow. + try { + this.log(`Fabric ${action}: index ${fabricIndex} (${this.fabrics().length} total)`); + } catch (error) { + try { + this.log(`Fabric ${action}: index ${fabricIndex} (count unavailable: ${describeError(error)})`); + } catch { + // The logger itself failed; there is nowhere left to report. + } + } + }); + + await server.start(); + + this.log(`Matter node online on port ${this.config.matterPort}, storage ${this.config.storagePath}`); + this.logPairing(); + } + + /** Print the pairing codes the way an operator (and the plugin log) needs them. */ + logPairing(): void { + const pairing = this.getPairing(); + if (pairing.commissioned && !pairing.windowOpen) { + this.log(`Commissioned into ${pairing.fabrics.length} fabric(s); no commissioning window open`); + return; + } + this.log(`Manual pairing code: ${pairing.manualPairingCode}`); + this.log(`QR pairing code: ${pairing.qrPairingCode}`); + } + + get server(): ServerNode { + if (this.#server === undefined) { + throw new ProtocolError(ErrorCode.internal, "Matter node not started"); + } + return this.#server; + } + + private fabrics(): FabricInfo[] { + const fabrics = this.server.state.commissioning.fabrics; + return Object.values(fabrics).map(fabric => ({ + fabricIndex: Number(fabric.fabricIndex), + label: fabric.label, + vendorId: Number(fabric.rootVendorId), + })); + } + + getStatus(): StatusReport { + const child = this.#child; + const endpoints = + child === undefined + ? [] + : [{ indigoDeviceId: E0_DEVICE_ID, endpointNumber: Number(child.number), role: E0_ROLE }]; + return { + commissioned: this.server.lifecycle.isCommissioned, + fabrics: this.fabrics(), + endpointCount: endpoints.length, + endpoints, + // E6 introduces the persisted endpoint-number allocator; until then + // there is no baseline to drift from. + drift: [], + }; + } + + /** §5: the sink for `window_closed`, wired up by the protocol server. */ + onWindowClosed(listener: (reason: WindowClosedReason) => void): void { + this.#window.onClosed(listener); + } + + getPairing(): PairingReport { + const commissioned = this.server.lifecycle.isCommissioned; + const window = this.#window.current; + + if (window !== undefined) { + return { + commissioned, + windowOpen: true, + windowExpiresAt: window.expiresAt.toISOString(), + manualPairingCode: window.manualPairingCode, + qrPairingCode: window.qrPairingCode, + fabrics: this.fabrics(), + }; + } + + if (!commissioned) { + // The never-commissioned initial state: the basic window is open and + // its codes are the persisted originals (§3.7). + const codes = this.server.state.commissioning.pairingCodes; + return { + commissioned: false, + windowOpen: true, + windowExpiresAt: null, + manualPairingCode: codes.manualPairingCode, + qrPairingCode: codes.qrPairingCode, + fabrics: this.fabrics(), + }; + } + + return { + commissioned: true, + windowOpen: false, + windowExpiresAt: null, + manualPairingCode: null, + qrPairingCode: null, + fabrics: this.fabrics(), + }; + } + + /** + * Open an enhanced commissioning window so a further ecosystem can pair. + * The passcode is ephemeral: matter.js holds only the PAKE verifier, so we + * derive and keep the display codes ourselves for the window's lifetime. + * + * This drives `DeviceCommissioner` rather than the `AdministratorCommissioning` + * cluster command, because in matter.js 0.17.8 that command asserts a remote + * authenticated session (it records the requesting fabric in + * `adminFabricIndex`) and therefore cannot be invoked from an offline agent. + * The consequence is that the cluster's `windowStatus`/`adminFabricIndex` + * attributes do not reflect a locally-opened window — a conformance gap for + * E7 to close, not something E0's pairing flow depends on. + */ + async openCommissioningWindow(durationSeconds: number): Promise { + // Before anything Matter-side: `allowEnhancedCommissioning` swaps the PASE + // commissioner and only then throws on a double-open, which would kill the + // code the user is already holding. See CommissioningWindow.assertClosed. + this.#window.assertClosed(); + + const server = this.server; + + let discriminator: number; + let passcode: number; + try { + const crypto = server.env.get(Crypto); + passcode = PaseClient.generateRandomPasscode(crypto); + discriminator = PaseClient.generateRandomDiscriminator(crypto); + const salt = crypto.randomBytes(PBKDF_SALT_BYTES); + const verifier = await PaseClient.generatePakePasscodeVerifier(crypto, passcode, { + iterations: PBKDF_ITERATIONS, + salt, + }); + const paseServer = PaseServer.fromVerificationValue(server.env.get(SessionManager), verifier, { + iterations: PBKDF_ITERATIONS, + salt, + }); + await server.env.get(DeviceCommissioner).allowEnhancedCommissioning(discriminator, paseServer, () => { + this.#window.noteEnded(); + }); + } catch (error) { + this.log(`Failed to open commissioning window: ${describeErrorWithStack(error)}`); + throw new ProtocolError(ErrorCode.commissioningWindowFailed, describeError(error)); + } + + const manualPairingCode = ManualPairingCodeCodec.encode({ discriminator, passcode }); + const qrPairingCode = QrPairingCodeCodec.encode([ + { + version: 0, + vendorId: VENDOR_ID, + productId: PRODUCT_ID, + flowType: CommissioningFlowType.Standard, + discriminator, + passcode, + discoveryCapabilities: 0b100, // on IP network + }, + ]); + + // DeviceCommissioner *does* build a STANDARD_COMMISSIONING_TIMEOUT timer + // in `#enterCommissioningMode`, but never calls `.start()` on it — a + // matter.js 0.17.8 bug — so nothing on the Matter side would ever close + // this window. Ours is the only timer. If upstream fixes that, the two + // race, which is why we cap durationSeconds at Matter's own 900s maximum: + // whichever fires first, the outcome is the same. + const expiresAt = this.#window.open(durationSeconds, manualPairingCode, qrPairingCode); + return { manualPairingCode, qrPairingCode, windowExpiresAt: expiresAt.toISOString() }; + } + + /** End the Matter-side window after ours expired. Never throws. */ + private endMatterCommissioning(): void { + const server = this.#server; + if (server === undefined) { + return; + } + void server.env + .get(DeviceCommissioner) + .endCommissioning() + .catch((error: unknown) => this.log(`Failed to end commissioning: ${describeError(error)}`)); + } + + async close(): Promise { + this.#window.clear(); + const server = this.#server; + this.#server = undefined; + this.#child = undefined; + if (server !== undefined) { + await server.close(); + } + } +} diff --git a/bridge-node/src/protocol.ts b/bridge-node/src/protocol.ts new file mode 100644 index 0000000..90a92f3 --- /dev/null +++ b/bridge-node/src/protocol.ts @@ -0,0 +1,165 @@ +/** + * Wire types for the plugin ⇄ bridge-node local protocol (BRIDGE_PROTOCOL.md). + * + * This module is deliberately free of matter.js imports: it is the contract, + * and both the WebSocket server and its tests depend on it without dragging in + * a Matter stack. + */ + +/** Protocol version this node speaks. Skew fails closed on both peers (§2). */ +export const PROTOCOL_VERSION = 1; + +/** Seconds a connection may sit past the handshake without attaching (§2). */ +export const UNATTACHED_TIMEOUT_MS = 10_000; + +/** + * Enhanced commissioning window bounds, in seconds (§3.8). + * + * Matter caps `CommissioningTimeout` at 900s and floors it at 180s. The cap is + * ours to enforce: matter.js 0.17.8's `DeviceCommissioner` builds a + * `STANDARD_COMMISSIONING_TIMEOUT` timer but never starts it, so nothing on the + * Matter side would close a longer window. + */ +export const WINDOW_DURATION_MIN_SECONDS = 180; +export const WINDOW_DURATION_MAX_SECONDS = 900; +export const WINDOW_DURATION_DEFAULT_SECONDS = 900; + +/** The complete `error_code` domain for protocol version 1 (§1.1). */ +export const ErrorCode = { + unknownCommand: "unknown_command", + malformedArgs: "malformed_args", + versionMismatch: "version_mismatch", + notAttached: "not_attached", + unknownDevice: "unknown_device", + unknownRole: "unknown_role", + roleChange: "role_change", + massRemovalRefused: "mass_removal_refused", + endpointMapInvalid: "endpoint_map_invalid", + commissioningWindowFailed: "commissioning_window_failed", + internal: "internal", +} as const; + +export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode]; + +/** The bare frame the node sends on every new connection, before `attach` (§2). */ +export interface HandshakeFrame { + protocolVersion: number; + bridgeVersion: string; + matterJsVersion: string; +} + +/** plugin → node (§1). */ +export interface RequestFrame { + message_id: string; + command: string; + args?: Record; +} + +/** node → plugin, success (§1). */ +export interface SuccessFrame { + message_id: string; + result: unknown; +} + +/** node → plugin, failure (§1). */ +export interface ErrorFrame { + message_id: string; + error_code: ErrorCodeValue; + details: string; +} + +/** node → plugin, unsolicited (§1). */ +export interface EventFrame { + event: string; + data: Record; +} + +/** §5 `window_closed` — why the enhanced commissioning window ended. */ +export type WindowClosedReason = "expired" | "commissioned"; + +/** §5 event names emitted in E0. The rest arrive with endpoint CRUD in E1. */ +export const EventName = { + windowClosed: "window_closed", +} as const; + +/** §4.3 */ +export interface FabricInfo { + fabricIndex: number; + label: string; + vendorId: number; +} + +/** §4.3 — the E0 subset; `drift` is always empty until E6 adds the allocator. */ +export interface StatusReport { + commissioned: boolean; + fabrics: FabricInfo[]; + endpointCount: number; + endpoints: EndpointSummary[]; + drift: DriftEntry[]; +} + +export interface EndpointSummary { + indigoDeviceId: number; + endpointNumber: number; + role: string; +} + +export interface DriftEntry { + uniqueId: string; + expected: number; + actual: number; +} + +/** §3.7 */ +export interface PairingReport { + commissioned: boolean; + windowOpen: boolean; + windowExpiresAt: string | null; + manualPairingCode: string | null; + qrPairingCode: string | null; + fabrics: FabricInfo[]; +} + +/** §3.8 */ +export interface CommissioningWindowResult { + manualPairingCode: string; + qrPairingCode: string; + windowExpiresAt: string; +} + +/** + * The slice of bridge behaviour the protocol server needs. Keeping it an + * interface is what lets the protocol tests run without a live Matter stack. + */ +export interface BridgeFacade { + getStatus(): StatusReport; + getPairing(): PairingReport; + openCommissioningWindow(durationSeconds: number): Promise; + /** + * Register the sink for `window_closed` (§3.8/§5). One listener, last + * registration wins — this seam is what lets the protocol server emit the + * event without importing the Matter stack, and lets tests fire it. + */ + onWindowClosed(listener: (reason: WindowClosedReason) => void): void; +} + +/** A protocol-level failure a command handler can throw to shape its response. */ +export class ProtocolError extends Error { + constructor( + readonly code: ErrorCodeValue, + details: string, + ) { + super(details); + this.name = "ProtocolError"; + } +} + +/** The human-readable message for an unknown throwable — what `details` carries. */ +export function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** The same, with a stack when there is one — for the log, never for the wire. */ +export function describeErrorWithStack(error: unknown): string { + return error instanceof Error ? (error.stack ?? error.message) : String(error); +} diff --git a/bridge-node/src/storage.ts b/bridge-node/src/storage.ts new file mode 100644 index 0000000..14a20a2 --- /dev/null +++ b/bridge-node/src/storage.ts @@ -0,0 +1,167 @@ +/** + * Persisted bridge identity. + * + * The install id, commissioning passcode and discriminator are generated once + * and kept in `identity.json` **outside** matter.js's own storage context, so a + * future factory reset (which wipes that context) cannot silently change the + * serial number a paired ecosystem remembers us by. + * + * Stdlib only — no matter.js import — so this is cheap to test. + */ + +import { randomInt, randomUUID } from "node:crypto"; +import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describeError } from "./protocol.js"; + +/** Inclusive passcode bounds. Matter core spec §5.1.1.6. */ +export const PASSCODE_MIN = 1; +export const PASSCODE_MAX = 99_999_998; + +/** + * Trivial passcodes that must never be used. Matter core spec §5.1.7.1. + * `0` is listed there too and is outside {@link PASSCODE_MIN}, but a persisted + * file can still carry it, so it is named here rather than left to the bounds. + */ +export const INVALID_PASSCODES: readonly number[] = [ + 0, 11111111, 22222222, 33333333, 44444444, 55555555, 66666666, 77777777, 88888888, 99999999, 12345678, 87654321, +]; + +/** The discriminator is 12 bits. */ +export const DISCRIMINATOR_MAX = 0xfff; + +const IDENTITY_FILE = "identity.json"; + +export interface BridgeIdentity { + /** Stable install id; seeds `serialNumber` and `uniqueId`. */ + installId: string; + /** Matter setup passcode for the initial (basic) commissioning window. */ + passcode: number; + /** 12-bit commissioning discriminator. */ + discriminator: number; +} + +/** True for a passcode inside the legal range and not on the trivial list. */ +export function isValidPasscode(passcode: number): boolean { + if (!Number.isInteger(passcode) || passcode < PASSCODE_MIN || passcode > PASSCODE_MAX) { + return false; + } + return !INVALID_PASSCODES.includes(passcode); +} + +/** A cryptographically random passcode that satisfies {@link isValidPasscode}. */ +export function generatePasscode(): number { + for (;;) { + const candidate = randomInt(PASSCODE_MIN, PASSCODE_MAX + 1); + if (isValidPasscode(candidate)) { + return candidate; + } + } +} + +/** A cryptographically random 12-bit discriminator. */ +export function generateDiscriminator(): number { + return randomInt(0, DISCRIMINATOR_MAX + 1); +} + +function isUsableIdentity(value: unknown): value is BridgeIdentity { + if (typeof value !== "object" || value === null) { + return false; + } + const candidate = value as Partial; + return ( + typeof candidate.installId === "string" && + candidate.installId.length > 0 && + typeof candidate.passcode === "number" && + isValidPasscode(candidate.passcode) && + typeof candidate.discriminator === "number" && + Number.isInteger(candidate.discriminator) && + candidate.discriminator >= 0 && + candidate.discriminator <= DISCRIMINATOR_MAX + ); +} + +function isNotFound(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === "ENOENT"; +} + +/** + * Write `identity.json` atomically: a temp file in the *same* directory (so the + * rename stays within one filesystem and is therefore atomic), then `rename`. + * A crash mid-write must never leave a half-written identity behind, because a + * half-written identity reads as corrupt and gets regenerated. + */ +function writeIdentity(file: string, identity: BridgeIdentity): void { + const temp = `${file}.${process.pid}.tmp`; + try { + writeFileSync(temp, `${JSON.stringify(identity, null, 2)}\n`, { mode: 0o600 }); + renameSync(temp, file); + } catch (error) { + try { + unlinkSync(temp); + } catch { + // Best effort: the temp file may never have been created. + } + throw error; + } +} + +/** + * Read `identity.json` from {@link storagePath}, creating it (and the directory) + * with freshly randomised values on first run. An unreadable or invalid file is + * replaced — a bridge that cannot advertise is worse than one that needs + * re-pairing, and E0 has nothing paired to protect yet. + * + * TODO(E1): a corrupt-but-present identity must refuse to start (PRD §4.3) — + * regeneration un-pairs every ecosystem. Only the missing-file branch may mint. + */ +export function loadOrCreateIdentity( + storagePath: string, + log: (message: string) => void = () => {}, +): BridgeIdentity { + mkdirSync(storagePath, { recursive: true }); + const file = join(storagePath, IDENTITY_FILE); + + // `undefined` means "no file at all"; anything else is why we are replacing + // a file that does exist — the distinction E1 turns into refuse-to-start. + let problem: string | undefined; + try { + const parsed: unknown = JSON.parse(readFileSync(file, "utf8")); + if (isUsableIdentity(parsed)) { + log(`Loaded bridge identity from ${file}`); + return parsed; + } + problem = "present but not a usable identity (missing or out-of-range fields)"; + } catch (error) { + problem = isNotFound(error) ? undefined : `unreadable (${describeError(error)})`; + } + + if (problem === undefined) { + log(`No identity at ${file}; minting new bridge identity`); + } else { + log(`Replacing bridge identity at ${file}: ${problem}`); + } + + const identity: BridgeIdentity = { + installId: randomUUID(), + passcode: generatePasscode(), + discriminator: generateDiscriminator(), + }; + writeIdentity(file, identity); + return identity; +} + +/** + * Matter `SerialNumber` is capped at 32 characters, so the UUID is used with its + * dashes stripped. Matter requires `UniqueID` and `SerialNumber` to differ, so + * {@link uniqueIdFor} takes the full value and this takes the first half. + */ +export function serialNumberFor(identity: BridgeIdentity): string { + return identity.installId.replace(/-/g, "").slice(0, 16); +} + +/** The root node's Matter `UniqueID` — distinct from {@link serialNumberFor}. */ +export function nodeUniqueIdFor(identity: BridgeIdentity): string { + return identity.installId.replace(/-/g, "").slice(0, 32); +} diff --git a/bridge-node/src/window.ts b/bridge-node/src/window.ts new file mode 100644 index 0000000..ab60b43 --- /dev/null +++ b/bridge-node/src/window.ts @@ -0,0 +1,141 @@ +/** + * The enhanced-commissioning-window state machine (§3.8). + * + * Extracted from {@link BridgeNode} so the bookkeeping — double-open refusal, + * expiry, and which reason a `window_closed` event carries — is unit-testable + * without a Matter stack. Timer and clock are injected for the same reason. + */ + +import { describeError, ErrorCode, ProtocolError, type WindowClosedReason } from "./protocol.js"; + +/** Cancels a scheduled expiry. */ +export type Cancel = () => void; + +/** Schedules `callback` `ms` from now and returns its canceller. */ +export type Scheduler = (callback: () => void, ms: number) => Cancel; + +/** Real timers, unref'd: an open window must never keep the process alive. */ +export const defaultScheduler: Scheduler = (callback, ms) => { + const timer = setTimeout(callback, ms); + timer.unref?.(); + return () => clearTimeout(timer); +}; + +export interface OpenWindow { + expiresAt: Date; + manualPairingCode: string; + qrPairingCode: string; +} + +export interface CommissioningWindowOptions { + schedule?: Scheduler; + now?: () => number; + log?: (message: string) => void; + /** + * Ends the Matter-side window when our expiry timer fires first. Anything + * it throws is caught and logged — a timer callback must never rethrow. + */ + onExpire?: () => void; +} + +export class CommissioningWindow { + #open?: OpenWindow; + #cancel?: Cancel; + #listener?: (reason: WindowClosedReason) => void; + readonly #schedule: Scheduler; + readonly #now: () => number; + readonly #log: (message: string) => void; + readonly #onExpire: (() => void) | undefined; + + constructor(options: CommissioningWindowOptions = {}) { + this.#schedule = options.schedule ?? defaultScheduler; + this.#now = options.now ?? Date.now; + this.#log = options.log ?? (() => {}); + this.#onExpire = options.onExpire; + } + + /** Register the `window_closed` sink (§5). Last registration wins. */ + onClosed(listener: (reason: WindowClosedReason) => void): void { + this.#listener = listener; + } + + get isOpen(): boolean { + return this.#open !== undefined; + } + + /** The live window, or `undefined` when none is open. */ + get current(): Readonly | undefined { + return this.#open; + } + + /** + * Refuse a second window *before* anything Matter-side is touched. + * + * matter.js 0.17.8's `DeviceCommissioner.allowEnhancedCommissioning` installs + * the new PASE commissioner and only then throws on the double-open, so a + * refusal that reaches the stack has already invalidated the code the user + * is holding. Guarding here keeps the open window intact. + */ + assertClosed(): void { + if (this.#open !== undefined) { + throw new ProtocolError(ErrorCode.commissioningWindowFailed, "A commissioning window is already open"); + } + } + + /** Record a freshly opened window and arm its expiry. */ + open(durationSeconds: number, manualPairingCode: string, qrPairingCode: string): Date { + this.assertClosed(); + const expiresAt = new Date(this.#now() + durationSeconds * 1000); + this.#open = { expiresAt, manualPairingCode, qrPairingCode }; + this.#cancel = this.#schedule(() => this.#expire(), durationSeconds * 1000); + return expiresAt; + } + + /** + * matter.js reports its window ended. Returns `false` when we had already + * closed ours — which is what keeps expiry from announcing twice, since the + * expiry path itself calls `endCommissioning()` and that fires this back. + */ + noteEnded(): boolean { + if (this.#open === undefined) { + return false; + } + this.#discard(); + this.#log("Commissioning window closed by commissioner"); + this.#emit("commissioned"); + return true; + } + + /** Shutdown: drop the window without announcing a close. */ + clear(): void { + this.#discard(); + } + + #expire(): void { + if (this.#open === undefined) { + return; + } + this.#discard(); + this.#log("Commissioning window expired"); + this.#emit("expired"); + try { + this.#onExpire?.(); + } catch (error) { + this.#log(`Failed to end Matter commissioning after expiry: ${describeError(error)}`); + } + } + + #discard(): void { + this.#cancel?.(); + this.#cancel = undefined; + this.#open = undefined; + } + + #emit(reason: WindowClosedReason): void { + try { + this.#listener?.(reason); + } catch (error) { + this.#log(`window_closed(${reason}) listener failed: ${describeError(error)}`); + } + } +} diff --git a/bridge-node/src/ws-server.ts b/bridge-node/src/ws-server.ts new file mode 100644 index 0000000..4e47232 --- /dev/null +++ b/bridge-node/src/ws-server.ts @@ -0,0 +1,308 @@ +/** + * The plugin ⇄ bridge-node WebSocket server (BRIDGE_PROTOCOL.md). + * + * Loopback only: there is no auth on this socket because there is no remote + * surface (§6.1). No matter.js import here — the server talks to a + * {@link BridgeFacade}, which is what makes the protocol testable without a + * live Matter stack. + */ + +import { WebSocketServer, type WebSocket } from "ws"; + +import { + type BridgeFacade, + describeError, + describeErrorWithStack, + ErrorCode, + type ErrorCodeValue, + EventName, + type EventFrame, + type HandshakeFrame, + PROTOCOL_VERSION, + ProtocolError, + UNATTACHED_TIMEOUT_MS, + WINDOW_DURATION_DEFAULT_SECONDS, + WINDOW_DURATION_MAX_SECONDS, + WINDOW_DURATION_MIN_SECONDS, +} from "./protocol.js"; + +export const LOOPBACK_HOST = "127.0.0.1"; + +export interface BridgeWsServerOptions { + port: number; + bridge: BridgeFacade; + bridgeVersion: string; + matterJsVersion: string; + log?: (message: string) => void; + /** Overridable so the §2 timeout is testable without a 10s wait. */ + unattachedTimeoutMs?: number; +} + +interface ClientState { + attached: boolean; + unattachedTimer?: NodeJS.Timeout; + /** + * Tail of this socket's handler chain. §1 promises frames are processed in + * receipt order, and handlers genuinely await (`open_commissioning_window` + * does crypto), so each frame is queued behind the previous one rather than + * dispatched concurrently. + */ + pending: Promise; +} + +/** Commands whose handlers exist in E0. Endpoint CRUD arrives in E1. */ +type CommandHandler = (args: Record, socket: WebSocket, state: ClientState) => Promise; + +export class BridgeWsServer { + #wss?: WebSocketServer; + #attached?: WebSocket; + readonly #clients = new Map(); + readonly #handlers = new Map(); + readonly #log: (message: string) => void; + + constructor(private readonly options: BridgeWsServerOptions) { + this.#log = options.log ?? console.log; + this.#handlers.set("attach", (args, socket, state) => this.handleAttach(args, socket, state)); + this.#handlers.set("get_status", async () => this.options.bridge.getStatus()); + this.#handlers.set("get_pairing", async () => this.options.bridge.getPairing()); + this.#handlers.set("open_commissioning_window", async args => this.handleOpenWindow(args)); + options.bridge.onWindowClosed(reason => this.sendEvent(EventName.windowClosed, { reason })); + } + + /** + * Push an unsolicited event (§1/§5) to the attached client. Dropped silently + * when nobody is attached: the plugin re-`attach`es on reconnect and gets a + * full reconcile, so a missed event has nothing to recover. + */ + sendEvent(event: string, data: Record): void { + const socket = this.#attached; + if (socket === undefined) { + return; + } + const frame: EventFrame = { event, data }; + this.send(socket, frame); + } + + /** Bind and start accepting connections. Resolves once listening. */ + async listen(): Promise { + await new Promise((resolve, reject) => { + const wss = new WebSocketServer({ host: LOOPBACK_HOST, port: this.options.port }); + wss.once("error", reject); + wss.once("listening", () => { + wss.off("error", reject); + wss.on("error", error => this.#log(`WebSocket server error: ${describeError(error)}`)); + resolve(); + }); + wss.on("connection", socket => this.onConnection(socket)); + this.#wss = wss; + }); + this.#log(`Protocol WebSocket listening on ws://${LOOPBACK_HOST}:${this.options.port}`); + } + + /** The bound port — useful when a test asks for port 0. */ + get port(): number { + const address = this.#wss?.address(); + if (address === undefined || address === null || typeof address === "string") { + return this.options.port; + } + return address.port; + } + + async close(): Promise { + const wss = this.#wss; + this.#wss = undefined; + for (const [socket, state] of this.#clients) { + clearTimeout(state.unattachedTimer); + // `terminate`, not `close`: a graceful close waits for the peer's + // close frame, and shutdown must not be held hostage by a plugin + // that is itself mid-crash. `wss.close()` below then resolves. + socket.terminate(); + } + this.#clients.clear(); + this.#attached = undefined; + if (wss !== undefined) { + await new Promise(resolve => wss.close(() => resolve())); + } + } + + private onConnection(socket: WebSocket): void { + const state: ClientState = { attached: false, pending: Promise.resolve() }; + this.#clients.set(socket, state); + + // Before the handshake send: `ws` throws an unhandled 'error' event if a + // socket fails with no listener attached, and a socket can fail on the + // very first write. + socket.on("error", error => this.#log(`Socket error: ${describeError(error)}`)); + + // §2 step 1: the bare handshake frame, before anything else. + const handshake: HandshakeFrame = { + protocolVersion: PROTOCOL_VERSION, + bridgeVersion: this.options.bridgeVersion, + matterJsVersion: this.options.matterJsVersion, + }; + socket.send(JSON.stringify(handshake)); + + // §2: a connection that handshakes but never attaches is closed. + const timeoutMs = this.options.unattachedTimeoutMs ?? UNATTACHED_TIMEOUT_MS; + state.unattachedTimer = setTimeout(() => { + if (!state.attached) { + this.#log(`Closing connection that did not attach within ${timeoutMs}ms`); + socket.close(); + } + }, timeoutMs); + state.unattachedTimer.unref?.(); + + socket.on("message", (data, isBinary) => { + if (isBinary) { + // The protocol is JSON text frames only (§1); nothing to answer. + this.#log("Dropping binary frame"); + return; + } + const raw = data.toString(); + // §1: strictly in receipt order — chain, never fan out. The tail + // catch also guarantees the chain is never left rejected, so one bad + // frame cannot stall every frame behind it. + state.pending = state.pending + .then(() => this.onMessage(socket, state, raw)) + .catch(error => this.#log(`Frame handling failed: ${describeErrorWithStack(error)}`)); + }); + socket.on("close", () => { + clearTimeout(state.unattachedTimer); + this.#clients.delete(socket); + if (this.#attached === socket) { + this.#attached = undefined; + this.#log("Attached client disconnected"); + } + }); + } + + private async onMessage(socket: WebSocket, state: ClientState, raw: string): Promise { + let frame: unknown; + try { + frame = JSON.parse(raw); + } catch { + // Unaddressable: no message_id to echo, so there is nobody to answer. + this.#log("Dropping unparseable frame"); + return; + } + + if (typeof frame !== "object" || frame === null) { + this.#log("Dropping non-object frame"); + return; + } + + const { message_id: messageId, command, args } = frame as Record; + if (typeof messageId !== "string") { + this.#log("Dropping frame without a message_id"); + return; + } + if (typeof command !== "string") { + this.send(socket, { message_id: messageId, error_code: ErrorCode.malformedArgs, details: "Missing command" }); + return; + } + + const commandArgs: Record = + typeof args === "object" && args !== null ? (args as Record) : {}; + + // Gating first: before `attach` the node has said nothing about which + // commands it knows, so `not_attached` is the honest answer even for a + // name it would otherwise reject (§1.1). + if (command !== "attach" && !state.attached) { + this.sendError(socket, messageId, ErrorCode.notAttached, `${command} requires a successful attach first`); + return; + } + + const handler = this.#handlers.get(command); + if (handler === undefined) { + this.sendError(socket, messageId, ErrorCode.unknownCommand, `Unknown command ${command}`); + return; + } + + try { + const result = await handler(commandArgs, socket, state); + this.send(socket, { message_id: messageId, result }); + } catch (error) { + if (error instanceof ProtocolError) { + this.sendError(socket, messageId, error.code, error.message); + // §2: version skew fails closed — answer, then hang up. + if (error.code === ErrorCode.versionMismatch) { + socket.close(); + } + return; + } + // Stack node-side, message on the wire: `details` is what the Indigo + // log shows a user, the stack is what we need to debug the node. + this.#log(`Command ${command} failed: ${describeErrorWithStack(error)}`); + this.sendError(socket, messageId, ErrorCode.internal, describeError(error)); + } + } + + private async handleAttach( + args: Record, + socket: WebSocket, + state: ClientState, + ): Promise { + const version = args.protocolVersion; + if (typeof version !== "number") { + throw new ProtocolError(ErrorCode.malformedArgs, "attach requires a numeric protocolVersion"); + } + if (version !== PROTOCOL_VERSION) { + throw new ProtocolError( + ErrorCode.versionMismatch, + `Node speaks protocol version ${PROTOCOL_VERSION}, client sent ${version}`, + ); + } + + // §2: exactly one attached client; a new attach supersedes the incumbent, + // which is how we recover from a half-open socket left by a plugin crash. + const incumbent = this.#attached; + if (incumbent !== undefined && incumbent !== socket) { + this.#log("Superseding previously attached client"); + incumbent.close(); + const incumbentState = this.#clients.get(incumbent); + if (incumbentState !== undefined) { + incumbentState.attached = false; + } + } + + state.attached = true; + clearTimeout(state.unattachedTimer); + state.unattachedTimer = undefined; + this.#attached = socket; + + const pluginVersion = typeof args.pluginVersion === "string" ? args.pluginVersion : "unknown"; + this.#log(`Client attached (plugin ${pluginVersion})`); + + // E1 reconciles `args.endpoints` here; E0 serves a fixed endpoint set. + // The §3.1 mass-removal guard (`mass_removal_refused` unless + // `intent: "replace_all"`) belongs with that reconcile — it is E1 scope, + // and until then there is no client-supplied set that could empty. + return this.options.bridge.getStatus(); + } + + private async handleOpenWindow(args: Record): Promise { + const duration = args.durationSeconds ?? WINDOW_DURATION_DEFAULT_SECONDS; + if ( + typeof duration !== "number" || + !Number.isInteger(duration) || + duration < WINDOW_DURATION_MIN_SECONDS || + duration > WINDOW_DURATION_MAX_SECONDS + ) { + throw new ProtocolError( + ErrorCode.malformedArgs, + `durationSeconds must be an integer ${WINDOW_DURATION_MIN_SECONDS}-${WINDOW_DURATION_MAX_SECONDS}`, + ); + } + return this.options.bridge.openCommissioningWindow(duration); + } + + private sendError(socket: WebSocket, messageId: string, code: ErrorCodeValue, details: string): void { + this.send(socket, { message_id: messageId, error_code: code, details }); + } + + private send(socket: WebSocket, frame: unknown): void { + if (socket.readyState === socket.OPEN) { + socket.send(JSON.stringify(frame)); + } + } +} diff --git a/bridge-node/test/client.ts b/bridge-node/test/client.ts new file mode 100644 index 0000000..e4d5e7f --- /dev/null +++ b/bridge-node/test/client.ts @@ -0,0 +1,98 @@ +/** + * Minimal test client: a WebSocket wrapped in a frame queue so tests can await + * one frame at a time in receipt order (§1 guarantees ordering). + */ + +import { WebSocket } from "ws"; + +export class TestClient { + readonly #socket: WebSocket; + readonly #frames: Record[] = []; + #waiter?: (frame: Record) => void; + closed = false; + + private constructor(socket: WebSocket) { + this.#socket = socket; + socket.on("message", data => { + const frame = JSON.parse(data.toString()) as Record; + const waiter = this.#waiter; + if (waiter !== undefined) { + this.#waiter = undefined; + waiter(frame); + } else { + this.#frames.push(frame); + } + }); + socket.on("close", () => { + this.closed = true; + }); + } + + static async connect(port: number): Promise { + const socket = new WebSocket(`ws://127.0.0.1:${port}`); + // Listeners must be attached before `open` resolves: the node sends the + // handshake the instant it accepts, and `ws` drops messages emitted + // before a listener exists. + const client = new TestClient(socket); + await new Promise((resolve, reject) => { + socket.once("open", resolve); + socket.once("error", reject); + }); + return client; + } + + /** Next frame, or a rejection after {@link timeoutMs}. */ + async next(timeoutMs = 2000): Promise> { + const buffered = this.#frames.shift(); + if (buffered !== undefined) { + return buffered; + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.#waiter = undefined; + reject(new Error("Timed out waiting for a frame")); + }, timeoutMs); + this.#waiter = frame => { + clearTimeout(timer); + resolve(frame); + }; + }); + } + + send(frame: unknown): void { + this.#socket.send(JSON.stringify(frame)); + } + + /** Bypass JSON.stringify — for the garbage-frame tests. */ + sendRaw(payload: string | Buffer): void { + this.#socket.send(payload); + } + + /** True once no more frames are buffered — used to assert "nothing came back". */ + get buffered(): number { + return this.#frames.length; + } + + /** Send a request and return its response frame. */ + async request(frame: unknown): Promise> { + this.send(frame); + return this.next(); + } + + async waitForClose(timeoutMs = 2000): Promise { + if (this.closed) { + return; + } + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Socket did not close")), timeoutMs); + this.#socket.once("close", () => { + clearTimeout(timer); + resolve(); + }); + }); + } + + close(): void { + this.#socket.close(); + } +} diff --git a/bridge-node/test/config.test.ts b/bridge-node/test/config.test.ts new file mode 100644 index 0000000..f7ca38d --- /dev/null +++ b/bridge-node/test/config.test.ts @@ -0,0 +1,87 @@ +/** + * CLI argument parsing — a bad launchd plist must fail loudly, not run on the + * wrong port with defaults. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + assertMdnsInterface, + DEFAULT_MATTER_PORT, + DEFAULT_STORAGE_PATH, + DEFAULT_WS_PORT, + parseArgs, +} from "../src/config.js"; + +describe("parseArgs", () => { + it("defaults to 5540 / 5581 and the Application Support storage path", () => { + const config = parseArgs([]); + assert.notEqual(config, "help"); + assert.deepEqual(config, { + storagePath: DEFAULT_STORAGE_PATH, + matterPort: DEFAULT_MATTER_PORT, + wsPort: DEFAULT_WS_PORT, + }); + assert.equal(DEFAULT_MATTER_PORT, 5540); + assert.equal(DEFAULT_WS_PORT, 5581); + assert.ok(DEFAULT_STORAGE_PATH.endsWith("com.simons-plugins.indigo-matter/bridge-node")); + assert.ok(!DEFAULT_STORAGE_PATH.includes(".matter")); + }); + + it("reads every supported flag", () => { + const config = parseArgs([ + "--storage-path", + "/tmp/bridge", + "--matter-port", + "5541", + "--ws-port", + "5582", + "--mdns-interface", + "en0", + ]); + assert.deepEqual(config, { + storagePath: "/tmp/bridge", + matterPort: 5541, + wsPort: 5582, + mdnsInterface: "en0", + }); + }); + + it("leaves mdnsInterface unset when not given", () => { + const config = parseArgs([]); + assert.equal((config as { mdnsInterface?: string }).mdnsInterface, undefined); + }); + + it("rejects unknown flags, missing values and bad ports", () => { + assert.throws(() => parseArgs(["--nope", "x"]), /Unknown argument: --nope/); + assert.throws(() => parseArgs(["--ws-port"]), /requires a value/); + assert.throws(() => parseArgs(["--ws-port", "0"]), /must be an integer/); + assert.throws(() => parseArgs(["--matter-port", "abc"]), /must be an integer/); + }); + + it("names an unknown flag as unknown, not as missing a value", () => { + // The known-flag check comes first, so `--bogus` with nothing after it + // is still reported as unknown — the message a bad plist needs. + assert.throws(() => parseArgs(["--bogus"]), /Unknown argument: --bogus/); + assert.throws(() => parseArgs(["--bogus", "--ws-port", "5581"]), /Unknown argument: --bogus/); + assert.throws(() => parseArgs(["--ws-port", "5581", "--bogus"]), /Unknown argument: --bogus/); + assert.throws(() => parseArgs(["stray"]), /Unknown argument: stray/); + }); + + it("returns help for --help", () => { + assert.equal(parseArgs(["--help"]), "help"); + }); +}); + +describe("assertMdnsInterface", () => { + it("accepts an interface the host actually has", () => { + assert.doesNotThrow(() => assertMdnsInterface("en0", ["lo0", "en0", "en1"])); + }); + + it("names the missing interface and lists what is available", () => { + assert.throws(() => assertMdnsInterface("en9", ["lo0", "en0"]), /--mdns-interface en9 is not a network interface/); + assert.throws(() => assertMdnsInterface("en9", ["lo0", "en0"]), /available: lo0, en0/); + assert.throws(() => assertMdnsInterface("en9", []), /available: none/); + }); +}); diff --git a/bridge-node/test/fixture-shapes.ts b/bridge-node/test/fixture-shapes.ts new file mode 100644 index 0000000..8e151b7 --- /dev/null +++ b/bridge-node/test/fixture-shapes.ts @@ -0,0 +1,101 @@ +/** + * Compile-time mirror of `fixtures/e0-frames.json` (§7 testing contract). + * + * `JSON.parse` erases types, so the golden file alone cannot fail `tsc` when a + * shape in `protocol.ts` drifts. Each payload is restated here bound with + * `satisfies` to its protocol type; `fixtures.test.ts` then deep-equals mirror + * against JSON. Between the two, a shape change has to be made in three places + * on purpose or something goes red: + * + * - rename a field in `protocol.ts` → this module fails to compile + * - edit the JSON without the mirror → `fixtures.test.ts` fails + * - edit the mirror without the JSON → `fixtures.test.ts` fails + */ + +import type { + CommissioningWindowResult, + ErrorFrame, + EventFrame, + HandshakeFrame, + PairingReport, + StatusReport, +} from "../src/protocol.js"; + +/** Versions are placeholders: the real ones track package.json / matter.js. */ +export const handshake = { + protocolVersion: 1, + bridgeVersion: "0.0.0-test", + matterJsVersion: "0.0.0-test", +} satisfies HandshakeFrame; + +export const status = { + commissioned: false, + fabrics: [], + endpointCount: 1, + endpoints: [{ indigoDeviceId: 999001, endpointNumber: 2, role: "onOffPlugInUnit" }], + drift: [], +} satisfies StatusReport; + +/** §3.7 state 1: never commissioned — the basic window with the persisted codes. */ +export const pairingUncommissioned = { + commissioned: false, + windowOpen: true, + windowExpiresAt: null, + manualPairingCode: "34970112332", + qrPairingCode: "MT:-24J0AFN00KA0648G00", + fabrics: [], +} satisfies PairingReport; + +/** §3.7 state 2: commissioned, no window — codes are gone and stay gone. */ +export const pairingCommissioned = { + commissioned: true, + windowOpen: false, + windowExpiresAt: null, + manualPairingCode: null, + qrPairingCode: null, + fabrics: [{ fabricIndex: 1, label: "Apple Home", vendorId: 4937 }], +} satisfies PairingReport; + +/** §3.7 state 3: commissioned *and* an enhanced window open — non-null expiry. */ +export const pairingCommissionedWindowOpen = { + commissioned: true, + windowOpen: true, + windowExpiresAt: "2026-08-04T12:15:00.000Z", + manualPairingCode: "34970112332", + qrPairingCode: "MT:-24J0AFN00KA0648G00", + fabrics: [{ fabricIndex: 1, label: "Apple Home", vendorId: 4937 }], +} satisfies PairingReport; + +export const commissioningWindow = { + manualPairingCode: "34970112332", + qrPairingCode: "MT:-24J0AFN00KA0648G00", + windowExpiresAt: "2026-08-04T12:15:00.000Z", +} satisfies CommissioningWindowResult; + +export const windowClosedExpired = { + event: "window_closed", + data: { reason: "expired" }, +} satisfies EventFrame; + +export const windowClosedCommissioned = { + event: "window_closed", + data: { reason: "commissioned" }, +} satisfies EventFrame; + +export const versionMismatch = { + message_id: "m2", + error_code: "version_mismatch", + details: "Node speaks protocol version 1, client sent 2", +} satisfies ErrorFrame; + +export const notAttached = { + message_id: "m3", + error_code: "not_attached", + details: "get_status requires a successful attach first", +} satisfies ErrorFrame; + +export const unknownCommand = { + message_id: "m4", + error_code: "unknown_command", + details: "Unknown command upsert_endpoint", +} satisfies ErrorFrame; diff --git a/bridge-node/test/fixtures.test.ts b/bridge-node/test/fixtures.test.ts new file mode 100644 index 0000000..c88f995 --- /dev/null +++ b/bridge-node/test/fixtures.test.ts @@ -0,0 +1,51 @@ +/** + * §7 testing contract: the golden JSON is shared with the Python suite, so it + * cannot itself be typed. `fixture-shapes.ts` is its compile-time mirror; this + * file is the runtime half that keeps the two honest. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import * as shapes from "./fixture-shapes.js"; +import { golden } from "./stub-bridge.js"; + +describe("golden fixtures match their typed mirror", () => { + const cases: [string, unknown, unknown][] = [ + ["handshake", golden.handshake, shapes.handshake], + ["attach result", golden.attach.response.result, shapes.status], + ["get_status result", golden.get_status.response.result, shapes.status], + ["get_pairing (uncommissioned)", golden.get_pairing_uncommissioned.response.result, shapes.pairingUncommissioned], + ["get_pairing (commissioned)", golden.get_pairing_commissioned.response.result, shapes.pairingCommissioned], + [ + "get_pairing (commissioned, window open)", + golden.get_pairing_commissioned_window_open.response.result, + shapes.pairingCommissionedWindowOpen, + ], + ["open_commissioning_window result", golden.open_commissioning_window.response.result, shapes.commissioningWindow], + ["window_closed (expired)", golden.window_closed_expired, shapes.windowClosedExpired], + ["window_closed (commissioned)", golden.window_closed_commissioned, shapes.windowClosedCommissioned], + ["attach version_mismatch", golden.attach_version_mismatch.response, shapes.versionMismatch], + ["not_attached", golden.not_attached.response, shapes.notAttached], + ["unknown_command", golden.unknown_command.response, shapes.unknownCommand], + ]; + + for (const [name, actual, expected] of cases) { + it(`${name} is shape-identical`, () => { + assert.deepEqual(actual, expected); + }); + } + + it("covers all four §3.7 pairing states", () => { + // uncommissioned+window, commissioned+no window, commissioned+window; + // the fourth (uncommissioned, no window) cannot exist — an + // uncommissioned node always advertises. + const states = [ + shapes.pairingUncommissioned, + shapes.pairingCommissioned, + shapes.pairingCommissionedWindowOpen, + ].map(state => `${state.commissioned}/${state.windowOpen}`); + assert.deepEqual(states.sort(), ["false/true", "true/false", "true/true"]); + assert.notEqual(shapes.pairingCommissionedWindowOpen.windowExpiresAt, null); + }); +}); diff --git a/bridge-node/test/fixtures/e0-frames.json b/bridge-node/test/fixtures/e0-frames.json new file mode 100644 index 0000000..3c17255 --- /dev/null +++ b/bridge-node/test/fixtures/e0-frames.json @@ -0,0 +1,207 @@ +{ + "_comment": [ + "Golden frames for the E0 subset of BRIDGE_PROTOCOL.md (§7 testing contract).", + "Shapes only — bridgeVersion/matterJsVersion are substituted at assert time,", + "since they track package.json and the pinned matter.js release.", + "E1 extends this file with the endpoint CRUD commands (§3.2-§3.5)." + ], + "handshake": { + "protocolVersion": 1, + "bridgeVersion": "0.0.0-test", + "matterJsVersion": "0.0.0-test" + }, + "attach": { + "request": { + "message_id": "m1", + "command": "attach", + "args": { + "protocolVersion": 1, + "pluginVersion": "2026.8.1", + "endpoints": [] + } + }, + "response": { + "message_id": "m1", + "result": { + "commissioned": false, + "fabrics": [], + "endpointCount": 1, + "endpoints": [ + { + "indigoDeviceId": 999001, + "endpointNumber": 2, + "role": "onOffPlugInUnit" + } + ], + "drift": [] + } + } + }, + "attach_version_mismatch": { + "request": { + "message_id": "m2", + "command": "attach", + "args": { + "protocolVersion": 2, + "pluginVersion": "2026.8.1", + "endpoints": [] + } + }, + "response": { + "message_id": "m2", + "error_code": "version_mismatch", + "details": "Node speaks protocol version 1, client sent 2" + } + }, + "not_attached": { + "request": { + "message_id": "m3", + "command": "get_status", + "args": {} + }, + "response": { + "message_id": "m3", + "error_code": "not_attached", + "details": "get_status requires a successful attach first" + } + }, + "unknown_command": { + "request": { + "message_id": "m4", + "command": "upsert_endpoint", + "args": { + "endpoint": { + "indigoDeviceId": 123456789, + "role": "onOffLight", + "label": "Kitchen Lamp", + "reachable": true, + "states": { "onOff": true }, + "options": {} + } + } + }, + "response": { + "message_id": "m4", + "error_code": "unknown_command", + "details": "Unknown command upsert_endpoint" + } + }, + "get_status": { + "request": { + "message_id": "m5", + "command": "get_status", + "args": {} + }, + "response": { + "message_id": "m5", + "result": { + "commissioned": false, + "fabrics": [], + "endpointCount": 1, + "endpoints": [ + { + "indigoDeviceId": 999001, + "endpointNumber": 2, + "role": "onOffPlugInUnit" + } + ], + "drift": [] + } + } + }, + "get_pairing_uncommissioned": { + "request": { + "message_id": "m6", + "command": "get_pairing", + "args": {} + }, + "response": { + "message_id": "m6", + "result": { + "commissioned": false, + "windowOpen": true, + "windowExpiresAt": null, + "manualPairingCode": "34970112332", + "qrPairingCode": "MT:-24J0AFN00KA0648G00", + "fabrics": [] + } + } + }, + "get_pairing_commissioned": { + "request": { + "message_id": "m7", + "command": "get_pairing", + "args": {} + }, + "response": { + "message_id": "m7", + "result": { + "commissioned": true, + "windowOpen": false, + "windowExpiresAt": null, + "manualPairingCode": null, + "qrPairingCode": null, + "fabrics": [ + { + "fabricIndex": 1, + "label": "Apple Home", + "vendorId": 4937 + } + ] + } + } + }, + "get_pairing_commissioned_window_open": { + "request": { + "message_id": "m9", + "command": "get_pairing", + "args": {} + }, + "response": { + "message_id": "m9", + "result": { + "commissioned": true, + "windowOpen": true, + "windowExpiresAt": "2026-08-04T12:15:00.000Z", + "manualPairingCode": "34970112332", + "qrPairingCode": "MT:-24J0AFN00KA0648G00", + "fabrics": [ + { + "fabricIndex": 1, + "label": "Apple Home", + "vendorId": 4937 + } + ] + } + } + }, + "open_commissioning_window": { + "request": { + "message_id": "m8", + "command": "open_commissioning_window", + "args": { + "durationSeconds": 900 + } + }, + "response": { + "message_id": "m8", + "result": { + "manualPairingCode": "34970112332", + "qrPairingCode": "MT:-24J0AFN00KA0648G00", + "windowExpiresAt": "2026-08-04T12:15:00.000Z" + } + } + }, + "window_closed_expired": { + "event": "window_closed", + "data": { + "reason": "expired" + } + }, + "window_closed_commissioned": { + "event": "window_closed", + "data": { + "reason": "commissioned" + } + } +} diff --git a/bridge-node/test/protocol.test.ts b/bridge-node/test/protocol.test.ts new file mode 100644 index 0000000..dfdd696 --- /dev/null +++ b/bridge-node/test/protocol.test.ts @@ -0,0 +1,381 @@ +/** + * BRIDGE_PROTOCOL.md conformance for the E0 command subset, run against the + * real ws-server with the Matter node stubbed out. + */ + +import assert from "node:assert/strict"; +import { after, before, describe, it } from "node:test"; + +import { ErrorCode, PROTOCOL_VERSION, ProtocolError } from "../src/protocol.js"; +import { BridgeWsServer } from "../src/ws-server.js"; +import { TestClient } from "./client.js"; +import { golden, StubBridge } from "./stub-bridge.js"; + +const BRIDGE_VERSION = "0.1.0-test"; +const MATTER_JS_VERSION = "0.17.8"; + +const bridge = new StubBridge(); +const server = new BridgeWsServer({ + port: 0, + bridge, + bridgeVersion: BRIDGE_VERSION, + matterJsVersion: MATTER_JS_VERSION, + log: () => {}, +}); + +async function connect(): Promise { + const client = await TestClient.connect(server.port); + await client.next(); // consume the handshake + return client; +} + +async function attach(client: TestClient): Promise> { + return client.request(golden.attach.request); +} + +before(async () => { + await server.listen(); +}); + +after(async () => { + await server.close(); +}); + +describe("handshake (§2)", () => { + it("sends the bare handshake frame first, with no message_id", async () => { + const client = await TestClient.connect(server.port); + const frame = await client.next(); + assert.deepEqual(frame, { + protocolVersion: PROTOCOL_VERSION, + bridgeVersion: BRIDGE_VERSION, + matterJsVersion: MATTER_JS_VERSION, + }); + assert.equal(Object.keys(golden.handshake).sort().join(","), Object.keys(frame).sort().join(",")); + client.close(); + }); +}); + +describe("attach (§3.1)", () => { + it("accepts a matching protocol version and returns a StatusReport", async () => { + const client = await connect(); + const response = await attach(client); + assert.deepEqual(response, golden.attach.response); + client.close(); + }); + + it("rejects a mismatched protocolVersion and closes the socket", async () => { + const client = await connect(); + const response = await client.request(golden.attach_version_mismatch.request); + assert.deepEqual(response, golden.attach_version_mismatch.response); + await client.waitForClose(); + }); + + it("rejects a non-numeric protocolVersion as malformed_args", async () => { + const client = await connect(); + const response = await client.request({ + message_id: "bad", + command: "attach", + args: { protocolVersion: "1" }, + }); + assert.equal(response.error_code, "malformed_args"); + assert.equal(response.message_id, "bad"); + client.close(); + }); + + it("supersedes the incumbent attached client", async () => { + const first = await connect(); + await attach(first); + + const second = await connect(); + await attach(second); + + await first.waitForClose(); + + // The superseding client is still usable. + const status = await second.request(golden.get_status.request); + assert.deepEqual(status, golden.get_status.response); + second.close(); + }); + + it("re-attaches the same socket without closing it", async () => { + // §2 supersession must not fire on the incumbent when it *is* the + // socket attaching: a plugin that re-attaches to refresh its endpoint + // set would otherwise hang up on itself. + const client = await connect(); + await attach(client); + const again = await attach(client); + assert.deepEqual(again, golden.attach.response); + assert.equal(client.closed, false); + + const status = await client.request(golden.get_status.request); + assert.deepEqual(status, golden.get_status.response); + assert.equal(client.closed, false); + client.close(); + }); +}); + +describe("gating (§1.1)", () => { + it("refuses non-attach commands before attach with not_attached", async () => { + const client = await connect(); + const response = await client.request(golden.not_attached.request); + assert.deepEqual(response, golden.not_attached.response); + client.close(); + }); + + it("returns unknown_command for E1 endpoint CRUD", async () => { + const client = await connect(); + await attach(client); + const response = await client.request(golden.unknown_command.request); + assert.deepEqual(response, golden.unknown_command.response); + client.close(); + }); + + it("prefers not_attached over unknown_command before attach", async () => { + const client = await connect(); + const response = await client.request(golden.unknown_command.request); + assert.equal(response.error_code, "not_attached"); + client.close(); + }); + + it("returns malformed_args when command is missing", async () => { + const client = await connect(); + const response = await client.request({ message_id: "nocmd" }); + assert.equal(response.error_code, "malformed_args"); + client.close(); + }); + + it("echoes message_id verbatim", async () => { + const client = await connect(); + await attach(client); + const response = await client.request({ + message_id: "an-opaque-🔑-id", + command: "get_status", + args: {}, + }); + assert.equal(response.message_id, "an-opaque-🔑-id"); + client.close(); + }); +}); + +describe("get_pairing (§3.7)", () => { + it("reports the initial window as open with the persisted codes", async () => { + bridge.commissioned = false; + const client = await connect(); + await attach(client); + const response = await client.request(golden.get_pairing_uncommissioned.request); + assert.deepEqual(response, golden.get_pairing_uncommissioned.response); + + const result = response.result as Record; + assert.equal(result.windowOpen, true); + assert.notEqual(result.manualPairingCode, null); + assert.notEqual(result.qrPairingCode, null); + client.close(); + }); + + it("nulls the codes once commissioned with no window open", async () => { + bridge.commissioned = true; + const client = await connect(); + await attach(client); + const response = await client.request(golden.get_pairing_commissioned.request); + assert.deepEqual(response, golden.get_pairing_commissioned.response); + + const result = response.result as Record; + assert.equal(result.windowOpen, false); + assert.equal(result.manualPairingCode, null); + assert.equal(result.windowExpiresAt, null); + bridge.commissioned = false; + client.close(); + }); + + it("reports codes and a non-null expiry while commissioned with a window open", async () => { + bridge.commissioned = true; + bridge.windowOpen = true; + const client = await connect(); + await attach(client); + const response = await client.request(golden.get_pairing_commissioned_window_open.request); + assert.deepEqual(response, golden.get_pairing_commissioned_window_open.response); + + const result = response.result as Record; + assert.equal(result.commissioned, true); + assert.equal(result.windowOpen, true); + assert.notEqual(result.windowExpiresAt, null); + assert.notEqual(result.manualPairingCode, null); + bridge.commissioned = false; + bridge.windowOpen = false; + client.close(); + }); +}); + +describe("open_commissioning_window (§3.8)", () => { + it("passes durationSeconds through and returns the window result", async () => { + const client = await connect(); + await attach(client); + const response = await client.request(golden.open_commissioning_window.request); + assert.deepEqual(response, golden.open_commissioning_window.response); + assert.equal(bridge.openWindowCalls.at(-1), 900); + client.close(); + }); + + it("bounds durationSeconds to Matter's 180-900s window", async () => { + const client = await connect(); + await attach(client); + // 0 and 60 are below Matter's MinCommissioningTimeout; 901 is above its + // maximum, and matter.js 0.17.8 would never time a longer window out. + for (const durationSeconds of [0, -1, 60, 179, 901, 3600, 900.5]) { + const response = await client.request({ + message_id: `dur-${durationSeconds}`, + command: "open_commissioning_window", + args: { durationSeconds }, + }); + assert.equal(response.error_code, "malformed_args", `durationSeconds ${durationSeconds} was accepted`); + } + for (const durationSeconds of [180, 900]) { + const response = await client.request({ + message_id: `ok-${durationSeconds}`, + command: "open_commissioning_window", + args: { durationSeconds }, + }); + assert.ok("result" in response, `durationSeconds ${durationSeconds} was rejected`); + assert.equal(bridge.openWindowCalls.at(-1), durationSeconds); + } + client.close(); + }); + + it("reports an unexpected facade failure as internal, with the message as details", async () => { + // The StubBridge hook exists precisely so this path is covered without + // a Matter stack that can be made to fail on demand. + bridge.openWindowError = new Error("mDNS advertiser is down"); + try { + const client = await connect(); + await attach(client); + const response = await client.request(golden.open_commissioning_window.request); + assert.equal(response.error_code, "internal"); + assert.equal(response.details, "mDNS advertiser is down"); + assert.equal(response.message_id, golden.open_commissioning_window.request.message_id); + client.close(); + } finally { + bridge.openWindowError = undefined; + } + }); + + it("passes a ProtocolError through with its own error_code", async () => { + bridge.openWindowError = new ProtocolError( + ErrorCode.commissioningWindowFailed, + "A commissioning window is already open", + ); + try { + const client = await connect(); + await attach(client); + const response = await client.request(golden.open_commissioning_window.request); + assert.equal(response.error_code, "commissioning_window_failed"); + assert.equal(response.details, "A commissioning window is already open"); + client.close(); + } finally { + bridge.openWindowError = undefined; + } + }); +}); + +describe("frame hygiene (§1)", () => { + it("drops every shape of garbage without answering or dying, and stays usable", async () => { + const client = await connect(); + await attach(client); + + const garbage: (string | Buffer)[] = [ + "{ not json", + "", + '"hi"', + "42", + "null", + "[1, 2, 3]", + JSON.stringify({ command: "get_status", args: {} }), // no message_id + JSON.stringify({ message_id: 7, command: "get_status" }), // non-string message_id + JSON.stringify({ event: "window_closed", data: { reason: "expired" } }), // events are node→plugin + Buffer.from([0x00, 0xff, 0x10, 0x80]), // binary frame + ]; + for (const payload of garbage) { + client.sendRaw(payload); + } + + // A well-formed follow-up is still answered, and it is the *only* frame + // that comes back — nothing above produced a response. + const response = await client.request(golden.get_status.request); + assert.deepEqual(response, golden.get_status.response); + assert.equal(client.buffered, 0); + assert.equal(client.closed, false); + client.close(); + }); +}); + +describe("ordering (§1)", () => { + it("answers pipelined frames in receipt order even when the first awaits", async () => { + // open_commissioning_window genuinely awaits (crypto); a get_pairing + // pipelined behind it resolves synchronously. Without a per-socket + // handler chain the second response overtakes the first. + bridge.delayOpenWindowMs = 50; + try { + const client = await connect(); + await attach(client); + + client.send({ + message_id: "first-slow", + command: "open_commissioning_window", + args: { durationSeconds: 900 }, + }); + client.send({ message_id: "second-fast", command: "get_pairing", args: {} }); + + const first = await client.next(); + const second = await client.next(); + assert.equal(first.message_id, "first-slow"); + assert.equal(second.message_id, "second-fast"); + client.close(); + } finally { + bridge.delayOpenWindowMs = 0; + } + }); +}); + +describe("window_closed event (§3.8/§5)", () => { + it("emits the golden frame to the attached client for each reason", async () => { + const client = await connect(); + await attach(client); + + bridge.emitWindowClosed("expired"); + assert.deepEqual(await client.next(), golden.window_closed_expired); + + bridge.emitWindowClosed("commissioned"); + assert.deepEqual(await client.next(), golden.window_closed_commissioned); + + // Events carry no message_id (§1) — the plugin must not try to match one. + assert.equal("message_id" in golden.window_closed_expired, false); + client.close(); + }); + + it("drops the event when nobody is attached", async () => { + // Its own server and stub: `#attached` is server-wide state, and the + // shared server has attached clients throughout this file. + const lonelyBridge = new StubBridge(); + const lonely = new BridgeWsServer({ + port: 0, + bridge: lonelyBridge, + bridgeVersion: BRIDGE_VERSION, + matterJsVersion: MATTER_JS_VERSION, + log: () => {}, + }); + await lonely.listen(); + try { + // Nothing connected at all. + lonelyBridge.emitWindowClosed("expired"); + + // Connected but never attached: still no addressee. + const client = await TestClient.connect(lonely.port); + await client.next(); // handshake + lonelyBridge.emitWindowClosed("expired"); + await assert.rejects(client.next(200)); + assert.equal(client.closed, false); + client.close(); + } finally { + await lonely.close(); + } + }); +}); diff --git a/bridge-node/test/storage.test.ts b/bridge-node/test/storage.test.ts new file mode 100644 index 0000000..f9227ec --- /dev/null +++ b/bridge-node/test/storage.test.ts @@ -0,0 +1,206 @@ +/** + * Identity persistence: randomised once, stable thereafter, never a trivial + * passcode. + */ + +import assert from "node:assert/strict"; +import { chmodSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; + +import { + DISCRIMINATOR_MAX, + generateDiscriminator, + generatePasscode, + INVALID_PASSCODES, + isValidPasscode, + loadOrCreateIdentity, + nodeUniqueIdFor, + PASSCODE_MAX, + PASSCODE_MIN, + serialNumberFor, +} from "../src/storage.js"; + +function scratch(): string { + return mkdtempSync(join(tmpdir(), "indigo-bridge-test-")); +} + +describe("passcode generation", () => { + it("never produces a trivial passcode", () => { + for (let i = 0; i < 2000; i++) { + const passcode = generatePasscode(); + assert.ok(!INVALID_PASSCODES.includes(passcode), `generated trivial passcode ${passcode}`); + assert.ok(passcode >= 1 && passcode <= PASSCODE_MAX); + } + }); + + it("rejects the spec's invalid list and out-of-range values", () => { + for (const invalid of INVALID_PASSCODES) { + assert.equal(isValidPasscode(invalid), false, `${invalid} should be invalid`); + } + assert.equal(isValidPasscode(0), false); + assert.equal(isValidPasscode(-1), false); + assert.equal(isValidPasscode(PASSCODE_MAX + 1), false); + assert.equal(isValidPasscode(1.5), false); + assert.equal(isValidPasscode(20202021), true); + // The inclusive bounds are legal values, not off-by-one rejects. + assert.equal(isValidPasscode(PASSCODE_MIN), true); + assert.equal(isValidPasscode(1), true); + assert.equal(isValidPasscode(PASSCODE_MAX), true); + }); + + it("names 0 on the invalid list, not just outside the bounds", () => { + // A persisted `"passcode": 0` must be caught by the spec's trivial list + // as well as by the range check. + assert.ok(INVALID_PASSCODES.includes(0)); + }); + + it("never uses the matter.js example defaults verbatim by chance-free construction", () => { + // 20202021/3840 are legal values; what matters is that we do not *default* + // to them. A fresh identity is drawn from the full range each time. + const seen = new Set(); + for (let i = 0; i < 200; i++) { + seen.add(generatePasscode()); + } + assert.ok(seen.size > 190, "passcodes are not being randomised"); + }); + + it("produces 12-bit discriminators", () => { + for (let i = 0; i < 2000; i++) { + const value = generateDiscriminator(); + assert.ok(Number.isInteger(value) && value >= 0 && value <= DISCRIMINATOR_MAX); + } + }); +}); + +describe("loadOrCreateIdentity", () => { + it("creates on first run and returns the same identity thereafter", () => { + const dir = scratch(); + try { + const first = loadOrCreateIdentity(dir); + assert.ok(isValidPasscode(first.passcode)); + assert.ok(first.discriminator <= DISCRIMINATOR_MAX); + assert.ok(first.installId.length > 0); + + const second = loadOrCreateIdentity(dir); + assert.deepEqual(second, first); + + const onDisk = JSON.parse(readFileSync(join(dir, "identity.json"), "utf8")); + assert.deepEqual(onDisk, first); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("replaces a corrupt or invalid identity file", () => { + const dir = scratch(); + try { + writeFileSync(join(dir, "identity.json"), "{ not json"); + const recovered = loadOrCreateIdentity(dir); + assert.ok(isValidPasscode(recovered.passcode)); + + writeFileSync(join(dir, "identity.json"), JSON.stringify({ installId: "x", passcode: 11111111, discriminator: 1 })); + const replaced = loadOrCreateIdentity(dir); + assert.ok(isValidPasscode(replaced.passcode)); + assert.notEqual(replaced.passcode, 11111111); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("regenerates an identity whose discriminator is out of 12-bit range, and says so", () => { + const dir = scratch(); + try { + writeFileSync( + join(dir, "identity.json"), + JSON.stringify({ installId: "x", passcode: 20202021, discriminator: DISCRIMINATOR_MAX + 1 }), + ); + const logs: string[] = []; + const replaced = loadOrCreateIdentity(dir, message => logs.push(message)); + assert.ok(replaced.discriminator <= DISCRIMINATOR_MAX); + assert.notEqual(replaced.installId, "x"); + assert.equal(logs.length, 1); + assert.match(logs[0]!, /Replacing bridge identity/); + assert.match(logs[0]!, /not a usable identity/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("distinguishes a missing file from an unreadable one in the log", () => { + const dir = scratch(); + try { + const minted: string[] = []; + loadOrCreateIdentity(dir, message => minted.push(message)); + assert.equal(minted.length, 1); + assert.match(minted[0]!, /minting new bridge identity/); + + const reloaded: string[] = []; + loadOrCreateIdentity(dir, message => reloaded.push(message)); + assert.match(reloaded[0]!, /Loaded bridge identity/); + + writeFileSync(join(dir, "identity.json"), "{ not json"); + const corrupt: string[] = []; + loadOrCreateIdentity(dir, message => corrupt.push(message)); + assert.match(corrupt[0]!, /Replacing bridge identity/); + // The caught parse error is quoted, so the log says *why*. + assert.match(corrupt[0]!, /unreadable \(.+\)/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("writes atomically and leaves no temp file behind", () => { + const dir = scratch(); + try { + loadOrCreateIdentity(dir); + assert.deepEqual(readdirSync(dir), ["identity.json"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("cleans up the temp file when the rename cannot happen", t => { + if (process.getuid?.() === 0) { + t.skip("root ignores directory permissions"); + return; + } + const dir = scratch(); + const nested = join(dir, "bridge"); + try { + // A read-only directory makes writeFileSync fail after mkdirSync + // succeeded, which is the path the temp-file cleanup guards. + loadOrCreateIdentity(nested); + chmodSync(nested, 0o500); + writeFileSync(join(nested, "identity.json"), "{ not json"); + assert.throws(() => loadOrCreateIdentity(nested)); + chmodSync(nested, 0o700); + assert.deepEqual(readdirSync(nested), ["identity.json"]); + } finally { + try { + chmodSync(nested, 0o700); + } catch { + // Directory may not exist if the first load failed. + } + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("derives a Matter-legal serial number and a distinct unique id", () => { + const dir = scratch(); + try { + const identity = loadOrCreateIdentity(dir); + const serial = serialNumberFor(identity); + const unique = nodeUniqueIdFor(identity); + for (const value of [serial, unique]) { + assert.ok(value.length > 0 && value.length <= 32); + assert.ok(!value.includes("-")); + } + // Matter requires UniqueID and SerialNumber to differ. + assert.notEqual(serial, unique); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/bridge-node/test/stub-bridge.ts b/bridge-node/test/stub-bridge.ts new file mode 100644 index 0000000..44992e3 --- /dev/null +++ b/bridge-node/test/stub-bridge.ts @@ -0,0 +1,93 @@ +/** + * A {@link BridgeFacade} that returns the golden fixture payloads verbatim. + * + * The protocol tests exercise the real ws-server against this, so they need no + * Matter stack, no network and no commissioned state. + */ + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { + BridgeFacade, + CommissioningWindowResult, + PairingReport, + StatusReport, + WindowClosedReason, +} from "../src/protocol.js"; + +const here = dirname(fileURLToPath(import.meta.url)); + +export interface GoldenExchange { + request: Record; + response: Record; +} + +export interface GoldenFrames { + handshake: Record; + attach: GoldenExchange; + attach_version_mismatch: GoldenExchange; + not_attached: GoldenExchange; + unknown_command: GoldenExchange; + get_status: GoldenExchange; + get_pairing_uncommissioned: GoldenExchange; + get_pairing_commissioned: GoldenExchange; + get_pairing_commissioned_window_open: GoldenExchange; + open_commissioning_window: GoldenExchange; + window_closed_expired: Record; + window_closed_commissioned: Record; +} + +export const golden: GoldenFrames = JSON.parse( + readFileSync(join(here, "fixtures", "e0-frames.json"), "utf8"), +) as GoldenFrames; + +export class StubBridge implements BridgeFacade { + commissioned = false; + /** Only meaningful while {@link commissioned}: selects the 3rd §3.7 state. */ + windowOpen = false; + openWindowError?: Error; + /** + * Makes `openCommissioningWindow` genuinely slow, for the ordering test. + * A single tick is not enough: `ws` delivers pipelined frames in separate + * read events, so a one-tick handler finishes before the next frame even + * arrives and the out-of-order bug never shows. + */ + delayOpenWindowMs = 0; + readonly openWindowCalls: number[] = []; + #windowClosed?: (reason: WindowClosedReason) => void; + + getStatus(): StatusReport { + return structuredClone(golden.get_status.response.result) as StatusReport; + } + + getPairing(): PairingReport { + const source = !this.commissioned + ? golden.get_pairing_uncommissioned + : this.windowOpen + ? golden.get_pairing_commissioned_window_open + : golden.get_pairing_commissioned; + return structuredClone(source.response.result) as PairingReport; + } + + async openCommissioningWindow(durationSeconds: number): Promise { + this.openWindowCalls.push(durationSeconds); + if (this.delayOpenWindowMs > 0) { + await new Promise(resolve => setTimeout(resolve, this.delayOpenWindowMs)); + } + if (this.openWindowError !== undefined) { + throw this.openWindowError; + } + return structuredClone(golden.open_commissioning_window.response.result) as CommissioningWindowResult; + } + + onWindowClosed(listener: (reason: WindowClosedReason) => void): void { + this.#windowClosed = listener; + } + + /** Stand in for the Matter stack closing the window. */ + emitWindowClosed(reason: WindowClosedReason): void { + this.#windowClosed?.(reason); + } +} diff --git a/bridge-node/test/timeout.test.ts b/bridge-node/test/timeout.test.ts new file mode 100644 index 0000000..e541073 --- /dev/null +++ b/bridge-node/test/timeout.test.ts @@ -0,0 +1,49 @@ +/** + * §2: a connection that handshakes but never attaches is closed by the node. + * Runs on its own server so the short timeout does not affect other tests. + */ + +import assert from "node:assert/strict"; +import { after, before, it } from "node:test"; + +import { BridgeWsServer } from "../src/ws-server.js"; +import { TestClient } from "./client.js"; +import { StubBridge } from "./stub-bridge.js"; + +const server = new BridgeWsServer({ + port: 0, + bridge: new StubBridge(), + bridgeVersion: "0.1.0-test", + matterJsVersion: "0.17.8", + log: () => {}, + unattachedTimeoutMs: 150, +}); + +before(async () => { + await server.listen(); +}); + +after(async () => { + await server.close(); +}); + +it("closes an unattached socket after the timeout", async () => { + const client = await TestClient.connect(server.port); + await client.next(); // handshake + await client.waitForClose(2000); + assert.equal(client.closed, true); +}); + +it("does not close a socket that attached in time", async () => { + const client = await TestClient.connect(server.port); + await client.next(); + const response = await client.request({ + message_id: "a", + command: "attach", + args: { protocolVersion: 1, pluginVersion: "test", endpoints: [] }, + }); + assert.ok("result" in response); + await new Promise(resolve => setTimeout(resolve, 400)); + assert.equal(client.closed, false); + client.close(); +}); diff --git a/bridge-node/test/window.test.ts b/bridge-node/test/window.test.ts new file mode 100644 index 0000000..a2d9057 --- /dev/null +++ b/bridge-node/test/window.test.ts @@ -0,0 +1,194 @@ +/** + * The commissioning-window state machine (§3.8). Timer and clock are injected, + * so every branch — including expiry — is exercised without waiting. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { ProtocolError, type WindowClosedReason } from "../src/protocol.js"; +import { CommissioningWindow } from "../src/window.js"; + +const MANUAL = "34970112332"; +const QR = "MT:-24J0AFN00KA0648G00"; + +interface Harness { + window: CommissioningWindow; + /** Fire the scheduled expiry, if one is armed. */ + fire(): void; + scheduled(): number | undefined; + cancels: number; + logs: string[]; + closed: WindowClosedReason[]; + expiries: number; +} + +function harness(options: { onExpire?: () => void } = {}): Harness { + let pending: { callback: () => void; ms: number } | undefined; + const state = { + cancels: 0, + logs: [] as string[], + closed: [] as WindowClosedReason[], + expiries: 0, + }; + const window = new CommissioningWindow({ + now: () => 1_000_000, + log: message => state.logs.push(message), + schedule: (callback, ms) => { + pending = { callback, ms }; + return () => { + pending = undefined; + state.cancels++; + }; + }, + onExpire: () => { + state.expiries++; + options.onExpire?.(); + }, + }); + window.onClosed(reason => state.closed.push(reason)); + return { + window, + fire: () => pending?.callback(), + scheduled: () => pending?.ms, + get cancels() { + return state.cancels; + }, + logs: state.logs, + closed: state.closed, + get expiries() { + return state.expiries; + }, + }; +} + +describe("CommissioningWindow", () => { + it("starts closed and reports no window", () => { + const h = harness(); + assert.equal(h.window.isOpen, false); + assert.equal(h.window.current, undefined); + h.window.assertClosed(); + }); + + it("records the codes and an ISO-representable expiry", () => { + const h = harness(); + const expiresAt = h.window.open(900, MANUAL, QR); + assert.equal(h.window.isOpen, true); + assert.equal(expiresAt.getTime(), 1_000_000 + 900_000); + assert.equal(expiresAt.toISOString(), new Date(1_900_000).toISOString()); + assert.deepEqual(h.window.current, { expiresAt, manualPairingCode: MANUAL, qrPairingCode: QR }); + assert.equal(h.scheduled(), 900_000); + }); + + it("refuses a second window without disturbing the first", () => { + const h = harness(); + const first = h.window.open(900, MANUAL, QR); + assert.throws( + () => h.window.open(900, "other", "other"), + (error: unknown) => { + assert.ok(error instanceof ProtocolError); + assert.equal(error.code, "commissioning_window_failed"); + assert.equal(error.message, "A commissioning window is already open"); + return true; + }, + ); + assert.throws(() => h.window.assertClosed(), ProtocolError); + // The live window is untouched — the whole point of guarding first. + assert.equal(h.window.current?.manualPairingCode, MANUAL); + assert.equal(h.window.current?.expiresAt, first); + assert.equal(h.closed.length, 0); + }); + + it("closes with reason \"expired\" when the timer fires, and ends Matter commissioning once", () => { + const h = harness(); + h.window.open(900, MANUAL, QR); + h.fire(); + assert.deepEqual(h.closed, ["expired"]); + assert.equal(h.expiries, 1); + assert.equal(h.window.isOpen, false); + assert.equal(h.logs.filter(line => line.includes("expired")).length, 1); + }); + + it("does not announce a second close when Matter reports the end we caused", () => { + // Our expiry calls endCommissioning(), which fires matter.js's own end + // callback straight back at us — synchronously, in this stand-in. + // Exactly one `window_closed`, exactly one log line. + let window!: CommissioningWindow; + const h = harness({ onExpire: () => window.noteEnded() }); + window = h.window; + + window.open(900, MANUAL, QR); + h.fire(); + + assert.deepEqual(h.closed, ["expired"]); + assert.equal(h.logs.filter(line => line.startsWith("Commissioning window")).length, 1); + // A later, unrelated end callback is still a no-op. + assert.equal(window.noteEnded(), false); + assert.deepEqual(h.closed, ["expired"]); + }); + + it("closes with reason \"commissioned\" and cancels the timer when a commissioner completes", () => { + const h = harness(); + h.window.open(900, MANUAL, QR); + assert.equal(h.window.noteEnded(), true); + assert.deepEqual(h.closed, ["commissioned"]); + assert.equal(h.cancels, 1); + assert.equal(h.window.isOpen, false); + // The cancelled timer cannot fire a late "expired" afterwards. + h.fire(); + assert.deepEqual(h.closed, ["commissioned"]); + assert.equal(h.expiries, 0); + }); + + it("noteEnded on a closed window is a no-op", () => { + const h = harness(); + assert.equal(h.window.noteEnded(), false); + assert.deepEqual(h.closed, []); + }); + + it("clear() drops the window silently, so shutdown emits nothing", () => { + const h = harness(); + h.window.open(900, MANUAL, QR); + h.window.clear(); + assert.equal(h.window.isOpen, false); + assert.equal(h.cancels, 1); + assert.deepEqual(h.closed, []); + h.window.clear(); // idempotent + assert.equal(h.cancels, 1); + h.window.assertClosed(); + }); + + it("reopens after a close", () => { + const h = harness(); + h.window.open(900, MANUAL, QR); + h.window.noteEnded(); + const second = h.window.open(180, "b", "c"); + assert.equal(h.scheduled(), 180_000); + assert.equal(second.getTime(), 1_000_000 + 180_000); + }); + + it("catches a throwing window_closed listener and stays consistent", () => { + const h = harness(); + h.window.onClosed(() => { + throw new Error("listener exploded"); + }); + h.window.open(900, MANUAL, QR); + assert.doesNotThrow(() => h.fire()); + assert.equal(h.window.isOpen, false); + assert.ok(h.logs.some(line => line.includes("listener exploded"))); + // The Matter-side teardown still ran despite the listener failure. + assert.equal(h.expiries, 1); + }); + + it("catches a throwing onExpire so the timer never rethrows", () => { + const h = harness({ + onExpire: () => { + throw new Error("env.get raced close()"); + }, + }); + h.window.open(900, MANUAL, QR); + assert.doesNotThrow(() => h.fire()); + assert.deepEqual(h.closed, ["expired"]); + assert.ok(h.logs.some(line => line.includes("env.get raced close()"))); + }); +}); diff --git a/bridge-node/tsconfig.json b/bridge-node/tsconfig.json new file mode 100644 index 0000000..10aba14 --- /dev/null +++ b/bridge-node/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"] +} diff --git a/bridge-node/tsconfig.test.json b/bridge-node/tsconfig.test.json new file mode 100644 index 0000000..e64f9ed --- /dev/null +++ b/bridge-node/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": ".test-build", + "declaration": false + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/indigo-matter.indigoPlugin/Contents/Info.plist b/indigo-matter.indigoPlugin/Contents/Info.plist index 07554bb..bd16276 100644 --- a/indigo-matter.indigoPlugin/Contents/Info.plist +++ b/indigo-matter.indigoPlugin/Contents/Info.plist @@ -20,7 +20,7 @@ IwsApiVersion 1.0.0 PluginVersion - 2026.7.22 + 2026.7.23 ServerApiVersion 3.6