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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bridge-node/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "indigo-matter-bridge",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"description": "Matter bridge node for the indigo-matter Indigo plugin — exports selected Indigo devices as Matter accessories.",
"license": "MIT",
Expand Down
657 changes: 657 additions & 0 deletions bridge-node/src/endpoints.ts

Large diffs are not rendered by default.

147 changes: 100 additions & 47 deletions bridge-node/src/node.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
/**
* The Matter side of the bridge: a ServerNode with an Aggregator at endpoint 1
* and (in E0) a single hard-coded bridged child endpoint.
* and one bridged child endpoint per exported Indigo device.
*
* 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.
* The child set is entirely protocol-driven (§3.1-§3.3) and lives in
* {@link EndpointRegistry}; a node that has never been attached to serves an
* empty aggregator. Endpoint *numbers* are persisted by matter.js against
* `Endpoint.id`, so a device that comes back with the same id comes back with
* the same number — presence in the running set is not what preserves identity.
*
* All matter.js coupling lives here, in `endpoints.ts`/`registry.ts` 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 { BasicInformationServer } from "@matter/main/behaviors/basic-information";
import { AggregatorEndpoint } from "@matter/main/endpoints/aggregator";
import { Crypto } from "@matter/main";
import { DeviceCommissioner, PaseClient, PaseServer, SessionManager } from "@matter/main/protocol";
Expand All @@ -18,17 +24,21 @@ import { CommissioningFlowType, ManualPairingCodeCodec, QrPairingCodeCodec } fro
import type { BridgeConfig } from "./config.js";
import {
type BridgeFacade,
type CommandEventData,
type CommissioningWindowResult,
describeError,
describeErrorWithStack,
type EndpointSpec,
ErrorCode,
type FabricInfo,
type PairingReport,
ProtocolError,
Role,
type RemoveResult,
type StatusReport,
type UpsertResult,
type WindowClosedReason,
} from "./protocol.js";
import { EndpointRegistry } from "./registry.js";
import { type BridgeIdentity, nodeUniqueIdFor, serialNumberFor } from "./storage.js";
import { CommissioningWindow } from "./window.js";

Expand All @@ -38,32 +48,19 @@ 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 = Role.onOffPlugInUnit;
/** PRD §5.3: no hard cap, but past this many exports the log says so. */
export const ENDPOINT_COUNT_WARNING = 100;

/** 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;
#registry?: EndpointRegistry;
#command?: (data: CommandEventData) => void;
readonly #window: CommissioningWindow;

constructor(
Expand All @@ -85,8 +82,10 @@ export class BridgeNode implements BridgeFacade {
}

/**
* Build the node and bring it online. Aggregator is added first so it takes
* endpoint 1; the bridged child then lands at 2.
* Build the node and bring it online. The aggregator is added first so it
* takes endpoint 1; bridged children land at 2 and up, in the order the
* first `attach` creates them (and thereafter at whatever number matter.js
* has persisted against their id).
*/
async start(): Promise<void> {
const environment = Environment.default;
Expand Down Expand Up @@ -134,19 +133,13 @@ export class BridgeNode implements BridgeFacade {
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,
},
this.#registry = new EndpointRegistry({
aggregator,
productName: PRODUCT_NAME,
log: message => this.log(message),
emit: data => this.#command?.(data),
onConfigurationChange: () => this.bumpConfigurationVersion(),
});
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
Expand Down Expand Up @@ -196,28 +189,87 @@ export class BridgeNode implements BridgeFacade {
}));
}

private get registry(): EndpointRegistry {
if (this.#registry === undefined) {
throw new ProtocolError(ErrorCode.internal, "Matter node not started");
}
return this.#registry;
}

getStatus(): StatusReport {
const child = this.#child;
const endpoints =
child === undefined
? []
: [{ indigoDeviceId: E0_DEVICE_ID, endpointNumber: Number(child.number), role: E0_ROLE }];
const endpoints = this.#registry?.summaries() ?? [];
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.
// E5 introduces the persisted endpoint-number map and its drift
// detector; until then there is no baseline to drift from, which is
// what `driftChecked: false` says out loud (§4.3). An empty `drift`
// here means "not looked", never "looked and found nothing".
drift: [],
driftChecked: false,
};
}

/** §3.1 — reconcile the live endpoint set, then answer with the new status. */
async reconcile(endpoints: readonly EndpointSpec[], replaceAll: boolean): Promise<StatusReport> {
await this.registry.reconcile(endpoints, replaceAll);
if (this.registry.size > ENDPOINT_COUNT_WARNING) {
this.log(
`${this.registry.size} exported endpoints exceeds the ${ENDPOINT_COUNT_WARNING} advisory limit; ` +
"ecosystem per-home accessory caps will bite before memory does",
);
}
return this.getStatus();
}

/** §3.2 */
async upsertEndpoint(spec: EndpointSpec): Promise<UpsertResult> {
return this.registry.upsert(spec);
}

/** §3.3 */
async removeEndpoint(indigoDeviceId: number): Promise<RemoveResult> {
return this.registry.remove(indigoDeviceId);
}

/** §3.4 */
async setState(indigoDeviceId: number, states: Record<string, unknown>): Promise<void> {
await this.registry.setState(indigoDeviceId, states);
}

/** §3.5 */
async setReachable(indigoDeviceId: number, reachable: boolean): Promise<void> {
await this.registry.setReachable(indigoDeviceId, reachable);
}

/**
* PRD §5.3 / Matter 1.5: a changed bridged-node set is a configuration
* change of the bridge. Bumping the root's `ConfigurationVersion` also
* covers the children — matter.js's own
* `BridgedDeviceBasicInformationServer.increaseConfigurationVersion`
* increments the root as well, so doing it once per batch is both cheaper
* and truer to "one logical change, one increment".
*/
private async bumpConfigurationVersion(): Promise<void> {
const server = this.#server;
if (server === undefined) {
return;
}
await server.act(agent => agent.get(BasicInformationServer).increaseConfigurationVersion());
}

/** §5: the sink for `window_closed`, wired up by the protocol server. */
onWindowClosed(listener: (reason: WindowClosedReason) => void): void {
this.#window.onClosed(listener);
}

/** §5: the sink for `command`. One listener, last registration wins. */
onCommand(listener: (data: CommandEventData) => void): void {
this.#command = listener;
}

getPairing(): PairingReport {
const commissioned = this.server.lifecycle.isCommissioned;
const window = this.#window.current;
Expand Down Expand Up @@ -268,7 +320,7 @@ export class BridgeNode implements BridgeFacade {
* `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.
* E7 to close, not something the pairing flow depends on.
*/
async openCommissioningWindow(durationSeconds: number): Promise<CommissioningWindowResult> {
// Before anything Matter-side: `allowEnhancedCommissioning` swaps the PASE
Expand Down Expand Up @@ -340,7 +392,8 @@ export class BridgeNode implements BridgeFacade {
this.#window.clear();
const server = this.#server;
this.#server = undefined;
this.#child = undefined;
this.#registry?.close();
this.#registry = undefined;
if (server !== undefined) {
await server.close();
}
Expand Down
77 changes: 72 additions & 5 deletions bridge-node/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,10 @@ export interface ErrorFrame {
export type WindowClosedReason = "expired" | "commissioned";

/**
* The complete §5 event name domain. Only `window_closed` is *emitted* in E0 —
* the rest arrive with endpoint CRUD — but the whole set is declared here so the
* fixture mirror can catch a misspelt name at compile time rather than shipping
* an event the plugin logs as unknown and drops.
* The complete §5 event name domain. `window_closed` and `command` are emitted
* today; the rest arrive with fabric/drift reporting. The whole set is declared
* here so the fixture mirror can catch a misspelt name at compile time rather
* than shipping an event the plugin logs as unknown and drops.
*/
export const EventName = {
command: "command",
Expand Down Expand Up @@ -119,20 +119,71 @@ export const Role = {

export type RoleValue = (typeof Role)[keyof typeof Role];

/** Runtime membership test for the §4.2 role enum — the `unknown_role` gate. */
export function isRole(value: unknown): value is RoleValue {
return typeof value === "string" && Object.prototype.hasOwnProperty.call(Role, value);
}

/**
* §3.1's opt-in for a reconcile that would empty the live endpoint set. The
* literal is named because both the guard and its refusal message quote it.
*/
export const INTENT_REPLACE_ALL = "replace_all";

/** §4.1 — the desired state of one exported device, as the plugin declares it. */
export interface EndpointSpec {
indigoDeviceId: number;
role: RoleValue;
label: string;
reachable: boolean;
/** Role-specific state keys (§4.2). Values are Indigo-natural units. */
states: Record<string, unknown>;
/** Role-specific extras (e.g. window-covering polarity). Unused by E3 roles. */
options: Record<string, unknown>;
}

/** §3.2 */
export interface UpsertResult {
endpointNumber: number;
}

/** §3.3 */
export interface RemoveResult {
removed: boolean;
}

/** §5 `command` — the `data` of an ecosystem-originated action. */
export interface CommandEventData extends Record<string, unknown> {
indigoDeviceId: number;
command: string;
args: Record<string, unknown>;
}

/** §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. */
/** §4.3. `drift` is always empty until E5 adds the persisted endpoint-number map. */
export interface StatusReport {
commissioned: boolean;
fabrics: FabricInfo[];
endpointCount: number;
endpoints: EndpointSummary[];
drift: DriftEntry[];
/**
* Whether `drift` is an answer or an absence.
*
* `drift: []` alone is ambiguous, and the two readings could not be further
* apart: "checked, nothing has moved" versus "there is no persisted map to
* check against yet". Until E5 builds that map this is always `false`, and
* saying so is what stops an empty list being read as an all-clear. The
* plugin ignores unknown fields (§1), so it can start honouring this in E3b
* without a protocol version bump.
*/
driftChecked: boolean;
}

export interface EndpointSummary {
Expand Down Expand Up @@ -172,12 +223,28 @@ export interface BridgeFacade {
getStatus(): StatusReport;
getPairing(): PairingReport;
openCommissioningWindow(durationSeconds: number): Promise<CommissioningWindowResult>;
/**
* §3.1's reconcile. `replaceAll` is the parsed `intent: "replace_all"`; the
* mass-removal guard lives behind this seam because only the implementation
* knows the live set.
*/
reconcile(endpoints: readonly EndpointSpec[], replaceAll: boolean): Promise<StatusReport>;
/** §3.2 — create-or-update. Rejects a role change with `role_change` (§4.1). */
upsertEndpoint(spec: EndpointSpec): Promise<UpsertResult>;
/** §3.3 — idempotent; `{removed: false}` for a device with no live endpoint. */
removeEndpoint(indigoDeviceId: number): Promise<RemoveResult>;
/** §3.4 — local (offline-context) writes, so they do not echo as `command`. */
setState(indigoDeviceId: number, states: Record<string, unknown>): Promise<void>;
/** §3.5 — Bridged Device Basic Information `Reachable`. */
setReachable(indigoDeviceId: number, reachable: boolean): Promise<void>;
/**
* 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;
/** The same seam for §5 `command`, emitted when an ecosystem acts. */
onCommand(listener: (data: CommandEventData) => void): void;
}

/** A protocol-level failure a command handler can throw to shape its response. */
Expand Down
Loading
Loading