src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/fabrics.ts
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-07-26 05:38:33 -0500
committerLuke Hoersten <[email protected]>2026-07-26 11:44:49 -0500
commit0af1cf3b1e2b471f2bc06abb16520035efff252e (patch)
tree4f1f5a2db4a7798ceb0e09cc30e8aa2b18b5d824 /src/fabrics.ts
Implement mattertimesync: one-shot Matter time synchronization CLIv0.1.0
A standalone CLI Matter controller that joins Matter devices' existing setups as a secondary administrator (multi-admin) and sets their clocks via the standard Time Synchronization cluster. Built on matter.js 0.17.6 using the CommissioningController API. One-shot runs from a systemd timer, no daemon: being a secondary controller is fabric membership, not a running process, so each invocation loads the persisted keys, discovers the device via operational DNS-SD, opens a fresh CASE session, does its work, and exits. - TypeScript scaffold: strict tsc, oxlint, prettier, vitest (51 tests: Matter epoch conversion, config validation, IANA timezone offsets and exact DST transition search, atomic state writes, pairing-code parsing, clock-delta reporting) - Validated JSON configuration with bigint-safe values; unknown fields rejected loudly - Persistent controller fabric with the same identity across restarts. The fabric doubles as the device registry: every node commissioned onto it is kept in sync, and membership changes only through commission and decommission, so no separate device list can drift - On-network multi-admin commissioning from a manual or QR pairing code; any number of devices, one per-device pairing code each. A device already on the fabric rejects re-commissioning via fabric conflict - Commands: nodes, inspect (human/JSON), status, fabrics (reads the Operational Credentials fabric table, marks our own entry by fabric ID plus root public key, flags likely-stale orphans, and removes them with --remove), and decommission (device drops our fabric while staying paired to its primary ecosystem). --node selects a device where relevant; optional while only one is commissioned - sync delta reporting: reads the device utcTime before writing and reports device time before, time written, and the delta with direction, e.g. "device clock was 1m 23s behind". Handles unset clocks after power loss and wrong-epoch garbage in exact bigint microseconds. The Time Synchronization write commands themselves still await real-device capability inspection - systemd oneshot service (dedicated non-root user, hardening) plus hourly timer with randomized delay - Deployment as a single esbuild bundle (mattertimesync.mjs, ~4.5 MB): all runtime deps are pure JavaScript, so the target needs only Node 20+, with no npm, registry access, or build toolchain. The Bun sqlite driver stays external behind its runtime guard so the bundle runs under plain Node. npm pack remains an alternative install path
Diffstat (limited to 'src/fabrics.ts')
-rw-r--r--src/fabrics.ts153
1 files changed, 153 insertions, 0 deletions
diff --git a/src/fabrics.ts b/src/fabrics.ts
new file mode 100644
index 0000000..2c4c90e
--- /dev/null
+++ b/src/fabrics.ts
@@ -0,0 +1,153 @@
+import { FabricIndex } from "@matter/main";
+import { OperationalCredentials } from "@matter/main/clusters/operational-credentials";
+import type { CommissioningController } from "@project-chip/matter.js";
+import type { PairedNode } from "@project-chip/matter.js/device";
+import { Log } from "./logging.js";
+
+const log = new Log("fabrics");
+
+/**
+ * Fabric-table management on the device's Operational Credentials cluster.
+ *
+ * Every commissioning writes a fabric entry into the device's limited fabric
+ * table. Entries never expire; identities whose local storage was deleted
+ * leave orphans behind. These helpers make the table visible and allow an
+ * admin (us) to remove stale entries without a factory reset.
+ */
+
+export interface FabricEntry {
+ fabricIndex: number;
+ fabricId: bigint;
+ nodeId: bigint;
+ vendorId: number;
+ label: string;
+ /** True when the entry belongs to this controller's current identity. */
+ isOurs: boolean;
+}
+
+export interface FabricTable {
+ supportedFabrics: number;
+ commissionedFabrics: number;
+ entries: FabricEntry[];
+}
+
+const KNOWN_VENDORS: Record<number, string> = {
+ 0x1349: "Apple",
+ 0x6006: "Google",
+ 0x10e1: "SmartThings",
+ 0x117c: "IKEA",
+};
+
+function vendorName(vendorId: number): string {
+ const known = KNOWN_VENDORS[vendorId];
+ if (known !== undefined) return known;
+ if (vendorId >= 0xfff1 && vendorId <= 0xfff4) return "test vendor";
+ return "unknown vendor";
+}
+
+export async function readFabricTable(
+ controller: CommissioningController,
+ node: PairedNode,
+): Promise<FabricTable> {
+ const client = node.getRootClusterClient(OperationalCredentials.Complete);
+ if (client === undefined) {
+ throw new Error("Operational Credentials cluster not found on the device's root endpoint");
+ }
+
+ // Read from the device with fabric filtering off, so entries from all
+ // fabrics are returned, not just our own.
+ const fabrics = await client.attributes.fabrics.get(true, false);
+ const supportedFabrics = (await client.attributes.supportedFabrics.get(true)) ?? 0;
+ const commissionedFabrics = (await client.attributes.commissionedFabrics.get(true)) ?? 0;
+ if (fabrics === undefined) {
+ throw new Error("Device returned no fabric list");
+ }
+
+ const ourFabric = controller.fabric;
+ const entries = fabrics.map(fabric => ({
+ fabricIndex: Number(fabric.fabricIndex),
+ fabricId: BigInt(fabric.fabricId),
+ nodeId: BigInt(fabric.nodeId),
+ vendorId: Number(fabric.vendorId),
+ label: fabric.label,
+ isOurs: ourFabric.matchesFabricIdAndRootPublicKey(fabric.fabricId, fabric.rootPublicKey),
+ }));
+
+ return { supportedFabrics, commissionedFabrics, entries };
+}
+
+export function formatFabricTable(nodeId: bigint, table: FabricTable): string {
+ const lines: string[] = [];
+ lines.push(
+ `Fabric table on node ${nodeId} (${table.commissionedFabrics} of ${table.supportedFabrics} slots used):`,
+ );
+ for (const entry of table.entries) {
+ const vendor = `0x${entry.vendorId.toString(16).padStart(4, "0")} (${vendorName(entry.vendorId)})`;
+ const marker = entry.isOurs ? " [this controller]" : "";
+ lines.push(
+ ` index ${entry.fabricIndex}: label ${JSON.stringify(entry.label)} vendor ${vendor} ` +
+ `fabricId ${entry.fabricId} nodeId ${entry.nodeId}${marker}`,
+ );
+ }
+ const strays = findLikelyStrays(table);
+ if (strays.length > 0) {
+ lines.push("");
+ lines.push(
+ `Likely stale entries (our label, but not our current identity): ` +
+ `${strays.map(entry => `index ${entry.fabricIndex}`).join(", ")}`,
+ );
+ lines.push(`Remove one with: mattertimesync fabrics --remove <index>`);
+ }
+ return lines.join("\n");
+}
+
+/**
+ * Entries carrying our fabric label but not our current identity: orphans
+ * from a commissioning whose local storage was deleted or replaced.
+ */
+export function findLikelyStrays(table: FabricTable): FabricEntry[] {
+ const ourLabel = table.entries.find(entry => entry.isOurs)?.label;
+ return table.entries.filter(entry => !entry.isOurs && ourLabel !== undefined && entry.label === ourLabel);
+}
+
+/**
+ * Removes another fabric's entry from the device. Refuses to remove our own
+ * entry; `decommission` is the correct path for that, because it also cleans
+ * up local controller state.
+ */
+export async function removeFabricByIndex(
+ controller: CommissioningController,
+ node: PairedNode,
+ fabricIndex: number,
+): Promise<void> {
+ const table = await readFabricTable(controller, node);
+ const entry = table.entries.find(candidate => candidate.fabricIndex === fabricIndex);
+ if (entry === undefined) {
+ throw new Error(
+ `No fabric with index ${fabricIndex} on the device ` +
+ `(present: ${table.entries.map(candidate => candidate.fabricIndex).join(", ")})`,
+ );
+ }
+ if (entry.isOurs) {
+ throw new Error(
+ `Fabric index ${fabricIndex} is this controller's own entry. ` +
+ `Use "decommission" instead, so local state is cleaned up too.`,
+ );
+ }
+
+ const client = node.getRootClusterClient(OperationalCredentials.Complete);
+ if (client === undefined) {
+ throw new Error("Operational Credentials cluster not found on the device's root endpoint");
+ }
+
+ log.info(`Removing fabric index ${fabricIndex} (label ${JSON.stringify(entry.label)}) from the device`);
+ const response = await client.commands.removeFabric({ fabricIndex: FabricIndex(fabricIndex) });
+ if (response.statusCode !== OperationalCredentials.NodeOperationalCertStatus.Ok) {
+ throw new Error(
+ `Device rejected RemoveFabric for index ${fabricIndex}: ` +
+ `status ${OperationalCredentials.NodeOperationalCertStatus[response.statusCode] ?? response.statusCode}` +
+ `${response.debugText ? ` (${response.debugText})` : ""}`,
+ );
+ }
+ log.info(`Fabric index ${fabricIndex} removed`);
+}