src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/main.ts
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-07-26 05:38:33 -0500
committerLuke Hoersten <[email protected]>2026-07-26 21:13:51 -0500
commit0e536daebe4cceb27eaccdbc4fc8ca066dff71b9 (patch)
treec96198815a3928a69ef06f5f0e4d1882317a6276 /src/main.ts
Implement mattertimesync: one-shot Matter time synchronization CLIHEADmain
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 (66 tests: Matter epoch conversion, config validation, IANA timezone offsets and exact DST transition search, atomic state writes, pairing-code parsing, clock-delta reporting, capability planning against the captured device fixture) - 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 - sync, validated against real hardware (IKEA ALPSTUGA air quality monitor over Thread, commissioned alongside Apple Home): refuses to run unless the host is NTP-synchronized, then per node reads utcTime, reports the correction with direction ("device clock was 1m 23s behind", handling unset clocks in exact bigint microseconds), writes SetUTCTime, SetTimeZone (standard offset plus IANA name), and SetDSTOffset (transition list bounded by the device's capacity), and verifies by reading the clock back. Capability-driven throughout: UTC-only devices degrade gracefully, cluster-less devices are skipped with a warning. Devices whose firmware encodes Unix-epoch time on the wire are detected by the exact 946684800s shift and reported corrected. Note matter.js's TlvEpochUs API convention: values are Unix-epoch microseconds at the API boundary; the library converts to Matter epoch on the wire - Commands: nodes, inspect (human/JSON; also reads the fabric table fabric-unfiltered, matching our own entry by fabric ID plus root public key and flagging likely-stale orphans; strictly read-only, since this tool never uses its admin rights against other fabrics' entries), status, and decommission (each device drops our fabric while staying paired to its primary ecosystem; targets all devices unless --node narrows it) - Logs (service and matter.js library, plain format) go to stderr; stdout carries only command output, so --json (available on nodes, inspect, sync, status, fabrics) is always clean parseable JSON with 64-bit values as decimal strings. Node's node:sqlite ExperimentalWarning is filtered before matter.js loads - 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 and node:sqlite is aliased to a lazy shim so the bundle runs under plain Node. npm pack remains an alternative install path
Diffstat (limited to 'src/main.ts')
-rw-r--r--src/main.ts381
1 files changed, 381 insertions, 0 deletions
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..4e28261
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,381 @@
+import { existsSync } from "node:fs";
+import { parseArgs } from "node:util";
+import { ConfigError, DEFAULT_CONFIG_PATH, loadConfig, parseNodeId, type Config } from "./config.js";
+import { commissionedNodeIds, resolveSingleNode, resolveTargetNodes, startController } from "./controller.js";
+import { commissionDevice } from "./commissioning.js";
+import { connectDevice } from "./device.js";
+import { findLikelyStrays, formatFabricTable, readFabricTable } from "./fabrics.js";
+import { formatInspection, inspectNode } from "./inspection.js";
+import { toJsonString } from "./json.js";
+import { Log, describeError, setLogLevel } from "./logging.js";
+import { currentMatterUtcMicroseconds, isHostClockSynchronized } from "./sync.js";
+import { formatSyncResult, syncNode, type SyncNodeResult } from "./timesync.js";
+import { nodeState, readServiceState, removeNodeState, serviceStatePath, updateNodeState } from "./state.js";
+import { formatUtcOffset, nextOffsetTransition, utcOffsetSeconds } from "./timezone.js";
+
+const log = new Log("cli");
+
+const USAGE = `Usage: mattertimesync [--config <path>] <command> [options]
+
+Read-only commands (never modify a device):
+ status [--json] Show controller and per-device state (contacts no device)
+ nodes [--json] List commissioned devices from local cached state (contacts no device)
+ inspect [--node <id>] [--json] Connect to a device and dump its live endpoints, clusters,
+ time-sync capabilities, and fabric table
+
+Commands with side effects:
+ commission <pairing-code> Join a device as an additional Matter admin
+ (writes the device's fabric table and local storage)
+ sync [--node <id>] [--json] Set each device's clock: UTC time, time zone, DST offsets
+ decommission [--node <id>] Drop this controller's fabric from each device, or just
+ one with --node (primary ecosystems are untouched)
+
+The controller fabric is the device registry: every commissioned node is kept
+in sync. --node may be omitted while only one device is commissioned.
+Run "sync" periodically (e.g. from a systemd timer) to keep clocks correct.
+
+Logs go to stderr; command output (including --json) goes to stdout.
+
+Options:
+ --config <path> Configuration file (default: ${DEFAULT_CONFIG_PATH})
+ --json Machine-readable output (64-bit values as decimal strings)
+ --help Show this help
+`;
+
+async function main(): Promise<number> {
+ const { values, positionals } = parseArgs({
+ options: {
+ config: { type: "string" },
+ json: { type: "boolean", default: false },
+ node: { type: "string" },
+ help: { type: "boolean", default: false },
+ },
+ allowPositionals: true,
+ });
+
+ if (values.help || positionals.length === 0) {
+ process.stdout.write(USAGE);
+ return values.help ? 0 : 2;
+ }
+
+ const command = positionals[0]!;
+ const configPath = values.config ?? DEFAULT_CONFIG_PATH;
+
+ let config: Config;
+ let requestedNode: bigint | undefined;
+ try {
+ config = loadConfig(configPath);
+ requestedNode = values.node === undefined ? undefined : parseNodeId(values.node);
+ } catch (cause) {
+ if (cause instanceof ConfigError) {
+ process.stderr.write(`Configuration error: ${cause.message}\n`);
+ return 1;
+ }
+ throw cause;
+ }
+ setLogLevel(config.logLevel);
+
+ switch (command) {
+ case "commission":
+ return await runCommission(config, positionals[1]);
+ case "nodes":
+ return await runNodes(config, values.json ?? false);
+ case "inspect":
+ return await runInspect(config, requestedNode, values.json ?? false);
+ case "sync":
+ return await runSync(config, requestedNode, values.json ?? false);
+ case "status":
+ return await runStatus(config, values.json ?? false);
+ case "decommission":
+ return await runDecommission(config, requestedNode);
+ default:
+ process.stderr.write(`Unknown command "${command}"\n\n${USAGE}`);
+ return 2;
+ }
+}
+
+async function runCommission(config: Config, codeArgument: string | undefined): Promise<number> {
+ const pairingCode = codeArgument?.trim();
+ if (pairingCode === undefined || pairingCode.length === 0) {
+ process.stderr.write(
+ "Usage: commission <pairing-code> (the code from the primary ecosystem's pairing mode).\n" +
+ "The code is used once and never logged or stored.\n",
+ );
+ return 2;
+ }
+ const controller = await startController(config);
+ try {
+ const result = await commissionDevice(controller, config, pairingCode);
+
+ const node = await connectDevice(controller, result.nodeId);
+ updateNodeState(serviceStatePath(config.storagePath), result.nodeId, {
+ lastSuccessfulConnection: new Date().toISOString(),
+ });
+ const report = await inspectNode(node);
+ process.stdout.write(formatInspection(report) + "\n\n");
+
+ process.stdout.write(
+ [
+ "Commissioning summary",
+ ` Node ID: ${result.nodeId}`,
+ ` Fabric ID: ${controller.fabric.fabricId}`,
+ ` Total devices: ${commissionedNodeIds(controller).length}`,
+ ` Time Sync: ${
+ report.timeSynchronization
+ ? `endpoint ${report.timeSynchronization.endpointId}`
+ : "cluster not found"
+ }`,
+ "",
+ "The device remains paired with its primary ecosystem; this controller",
+ 'was added as an additional Matter administrator. Run "sync" to',
+ "synchronize its clock now.",
+ "",
+ ].join("\n"),
+ );
+ return 0;
+ } finally {
+ await controller.close();
+ }
+}
+
+async function runNodes(config: Config, json: boolean): Promise<number> {
+ const controller = await startController(config);
+ try {
+ const details = controller.getCommissionedNodesDetails();
+ const state = readServiceState(serviceStatePath(config.storagePath));
+
+ if (json) {
+ const entries = details.map(detail => {
+ const nodeId = BigInt(detail.nodeId);
+ const info = detail.deviceData.basicInformation ?? {};
+ return {
+ nodeId,
+ vendorName: info["vendorName"] ?? null,
+ productName: info["productName"] ?? null,
+ ...nodeState(state, nodeId),
+ };
+ });
+ process.stdout.write(toJsonString(entries) + "\n");
+ return 0;
+ }
+
+ if (details.length === 0) {
+ process.stdout.write('No devices commissioned. Run "commission" to add one.\n');
+ return 0;
+ }
+
+ process.stdout.write(`${details.length} commissioned device${details.length === 1 ? "" : "s"}:\n`);
+ for (const detail of details) {
+ const nodeId = BigInt(detail.nodeId);
+ const info = detail.deviceData.basicInformation ?? {};
+ const name = [info["vendorName"], info["productName"]].filter(Boolean).join(" ");
+ const perNode = nodeState(state, nodeId);
+ process.stdout.write(
+ [
+ ` node ${nodeId}: ${name || "(no cached device info)"}`,
+ ` last connection: ${perNode.lastSuccessfulConnection ?? "never"}`,
+ ` last sync: ${perNode.lastSuccessfulSync ?? "never"}`,
+ ...(perNode.lastError !== null ? [` last error: ${perNode.lastError}`] : []),
+ ].join("\n") + "\n",
+ );
+ }
+ return 0;
+ } finally {
+ await controller.close();
+ }
+}
+
+async function runInspect(config: Config, requestedNode: bigint | undefined, json: boolean): Promise<number> {
+ const controller = await startController(config);
+ try {
+ const nodeId = resolveSingleNode(controller, requestedNode);
+ const node = await connectDevice(controller, nodeId);
+ updateNodeState(serviceStatePath(config.storagePath), nodeId, {
+ lastSuccessfulConnection: new Date().toISOString(),
+ });
+ const report = await inspectNode(node);
+ const fabricTable = await readFabricTable(controller, node);
+ if (json) {
+ process.stdout.write(
+ toJsonString({
+ ...report,
+ fabricTable: {
+ ...fabricTable,
+ likelyStaleIndexes: findLikelyStrays(fabricTable).map(entry => entry.fabricIndex),
+ },
+ }) + "\n",
+ );
+ } else {
+ process.stdout.write(formatInspection(report) + "\n\n" + formatFabricTable(nodeId, fabricTable) + "\n");
+ }
+ return 0;
+ } finally {
+ await controller.close();
+ }
+}
+
+async function runSync(config: Config, requestedNode: bigint | undefined, json: boolean): Promise<number> {
+ // Fail safe: never push time from a clock that is not NTP-disciplined.
+ const ntpSynchronized = await isHostClockSynchronized();
+ if (!ntpSynchronized) {
+ log.warn("Host clock is not NTP-synchronized; refusing to set device time. Will retry next run.");
+ if (json) process.stdout.write(toJsonString({ hostNtpSynchronized: false, nodes: [] }) + "\n");
+ return 1;
+ }
+
+ const controller = await startController(config);
+ const statePath = serviceStatePath(config.storagePath);
+ const results: SyncNodeResult[] = [];
+ try {
+ for (const nodeId of resolveTargetNodes(controller, requestedNode)) {
+ updateNodeState(statePath, nodeId, { lastAttemptedSync: new Date().toISOString() });
+ let result: SyncNodeResult;
+ try {
+ const node = await connectDevice(controller, nodeId);
+ updateNodeState(statePath, nodeId, { lastSuccessfulConnection: new Date().toISOString() });
+ result = await syncNode(node, config.timezone);
+ } catch (cause) {
+ result = {
+ nodeId: nodeId.toString(),
+ success: false,
+ skipped: false,
+ error: describeError(cause),
+ clockBefore: null,
+ utcTimeWritten: null,
+ utcTimeWrittenUnixMicroseconds: null,
+ timeZoneWritten: null,
+ dstOffsetsWritten: null,
+ verification: null,
+ };
+ }
+ results.push(result);
+ if (result.success) {
+ updateNodeState(statePath, nodeId, {
+ lastSuccessfulSync: new Date().toISOString(),
+ lastError: null,
+ });
+ } else {
+ updateNodeState(statePath, nodeId, { lastError: result.error });
+ log.error(`Node ${nodeId}: sync failed`, result.error);
+ }
+ }
+ } finally {
+ await controller.close();
+ }
+
+ if (json) {
+ process.stdout.write(toJsonString({ hostNtpSynchronized: true, nodes: results }) + "\n");
+ } else {
+ process.stdout.write(results.map(result => formatSyncResult(result)).join("\n") + "\n");
+ }
+ // Devices without the cluster are skipped with a warning, not treated as
+ // failures; a permanently incompatible device must not fail every timer run.
+ return results.some(result => !result.success && !result.skipped) ? 1 : 0;
+}
+
+async function runStatus(config: Config, json: boolean): Promise<number> {
+ const statePath = serviceStatePath(config.storagePath);
+ const state = readServiceState(statePath);
+ const storageExists = existsSync(config.storagePath);
+ const ntpSynchronized = await isHostClockSynchronized();
+ const offsetSeconds = utcOffsetSeconds(config.timezone);
+ const transition = nextOffsetTransition(config.timezone);
+
+ if (json) {
+ process.stdout.write(
+ toJsonString({
+ timezone: config.timezone,
+ storagePath: config.storagePath,
+ storageInitialized: storageExists,
+ hostNtpSynchronized: ntpSynchronized,
+ currentUtcOffsetSeconds: offsetSeconds,
+ currentUtcOffset: formatUtcOffset(offsetSeconds),
+ nextDstTransition:
+ transition === null
+ ? null
+ : {
+ at: transition.at.toISOString(),
+ offsetBeforeSeconds: transition.offsetBeforeSeconds,
+ offsetAfterSeconds: transition.offsetAfterSeconds,
+ },
+ matterTimeNowMicroseconds: currentMatterUtcMicroseconds(),
+ nodes: state.nodes,
+ }) + "\n",
+ );
+ return 0;
+ }
+
+ const nodeIds = Object.keys(state.nodes);
+ const lines = [
+ `Configuration: loaded (${config.timezone})`,
+ `Controller storage: ${storageExists ? `present at ${config.storagePath}` : "NOT INITIALIZED"}`,
+ `Commissioned nodes: ${nodeIds.length > 0 ? nodeIds.join(", ") : "none recorded"}`,
+ `Host NTP synced: ${ntpSynchronized ? "yes" : "no (or not determinable on this host)"}`,
+ `Current UTC offset: ${formatUtcOffset(offsetSeconds)}`,
+ `Next DST transition: ${
+ transition
+ ? `${transition.at.toISOString()} (${formatUtcOffset(transition.offsetBeforeSeconds)} -> ${formatUtcOffset(
+ transition.offsetAfterSeconds,
+ )})`
+ : "none within 400 days"
+ }`,
+ `Matter time now: ${currentMatterUtcMicroseconds()} us since 2000-01-01T00:00:00Z`,
+ ];
+ for (const [nodeId, perNode] of Object.entries(state.nodes)) {
+ lines.push(`Node ${nodeId}:`);
+ lines.push(` Last connection: ${perNode.lastSuccessfulConnection ?? "never"}`);
+ lines.push(` Last successful sync: ${perNode.lastSuccessfulSync ?? "never"}`);
+ lines.push(` Last attempted sync: ${perNode.lastAttemptedSync ?? "never"}`);
+ lines.push(` Most recent error: ${perNode.lastError ?? "none"}`);
+ }
+ process.stdout.write(lines.join("\n") + "\n");
+ return 0;
+}
+
+async function runDecommission(config: Config, requestedNode: bigint | undefined): Promise<number> {
+ const controller = await startController(config);
+ const statePath = serviceStatePath(config.storagePath);
+ const failed: bigint[] = [];
+ try {
+ // Like sync, no --node means every commissioned device.
+ for (const nodeId of resolveTargetNodes(controller, requestedNode)) {
+ try {
+ const node = await connectDevice(controller, nodeId);
+ log.info(`Decommissioning: removing this controller's fabric from node ${nodeId}`);
+ await node.decommission();
+ removeNodeState(statePath, nodeId);
+ process.stdout.write(
+ `Device ${nodeId} removed this controller's fabric; its primary ecosystem is untouched.\n`,
+ );
+ } catch (cause) {
+ failed.push(nodeId);
+ log.error(`Node ${nodeId}: decommission failed`, cause);
+ process.stdout.write(`Device ${nodeId} could NOT be decommissioned: ${describeError(cause)}\n`);
+ }
+ }
+
+ const remaining = commissionedNodeIds(controller);
+ process.stdout.write(
+ (remaining.length > 0
+ ? `${remaining.length} device${remaining.length === 1 ? "" : "s"} remain commissioned: ${remaining.join(", ")}.`
+ : `No devices remain commissioned. Local controller identity remains in ` +
+ `${config.storagePath}; deleting that directory is now safe, or keep it to reuse ` +
+ `the same identity for future commissioning.`) + "\n",
+ );
+ return failed.length > 0 ? 1 : 0;
+ } finally {
+ await controller.close();
+ }
+}
+
+main()
+ .then(code => {
+ process.exitCode = code;
+ })
+ .catch(cause => {
+ log.error("Fatal error", cause);
+ if (!(cause instanceof Error)) {
+ process.stderr.write(describeError(cause) + "\n");
+ }
+ process.exitCode = 1;
+ });