src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/cli.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/cli.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/cli.ts')
-rw-r--r--src/cli.ts306
1 files changed, 306 insertions, 0 deletions
diff --git a/src/cli.ts b/src/cli.ts
new file mode 100644
index 0000000..382c40b
--- /dev/null
+++ b/src/cli.ts
@@ -0,0 +1,306 @@
+#!/usr/bin/env node
+import { existsSync } from "node:fs";
+import { createInterface } from "node:readline";
+import { parseArgs } from "node:util";
+import { ConfigError, DEFAULT_CONFIG_PATH, loadConfig, parseNodeId, type Config } from "./config.js";
+import { commissionedNodeIds, resolveSingleNode, startController } from "./controller.js";
+import { commissionDevice } from "./commissioning.js";
+import { connectDevice } from "./device.js";
+import { formatFabricTable, readFabricTable, removeFabricByIndex } from "./fabrics.js";
+import { formatInspection, inspectionToJson, inspectNode } from "./inspection.js";
+import { Log, describeError, setLogLevel } from "./logging.js";
+import { currentMatterUtcMicroseconds, isHostClockSynchronized } from "./sync.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]
+
+Commands:
+ commission [--code <pairing-code>] Commission a device as an additional Matter admin
+ nodes List commissioned devices and their sync state
+ inspect [--node <id>] [--json] Dump a device's endpoints, clusters, time capabilities
+ sync [--node <id>] Synchronize device clocks (not yet implemented)
+ status Show controller and per-device state
+ fabrics [--node <id>] [--remove <n>] List a device's fabric table; remove a stale entry
+ decommission --node <id> Remove this controller from a device
+
+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.
+
+Options:
+ --config <path> Configuration file (default: ${DEFAULT_CONFIG_PATH})
+ --help Show this help
+`;
+
+async function main(): Promise<number> {
+ const { values, positionals } = parseArgs({
+ options: {
+ config: { type: "string" },
+ code: { type: "string" },
+ json: { type: "boolean", default: false },
+ node: { type: "string" },
+ remove: { 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, values.code);
+ case "nodes":
+ return await runNodes(config);
+ case "inspect":
+ return await runInspect(config, requestedNode, values.json ?? false);
+ case "sync":
+ return runNotImplemented("sync");
+ case "status":
+ return await runStatus(config);
+ case "fabrics":
+ return await runFabrics(config, requestedNode, values.remove);
+ 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 ?? (await promptForPairingCode());
+ 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. Time synchronization",
+ "commands will be implemented from this inspection data (plan Phase 5).",
+ "",
+ ].join("\n"),
+ );
+ return 0;
+ } finally {
+ await controller.close();
+ }
+}
+
+async function runNodes(config: Config): Promise<number> {
+ const controller = await startController(config);
+ try {
+ const details = controller.getCommissionedNodesDetails();
+ if (details.length === 0) {
+ process.stdout.write('No devices commissioned. Run "commission" to add one.\n');
+ return 0;
+ }
+
+ const state = readServiceState(serviceStatePath(config.storagePath));
+ 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);
+ process.stdout.write((json ? inspectionToJson(report) : formatInspection(report)) + "\n");
+ return 0;
+ } finally {
+ await controller.close();
+ }
+}
+
+function runNotImplemented(command: string): number {
+ process.stderr.write(
+ `"${command}" is not implemented yet. Per the project plan, the Time Synchronization\n` +
+ `write commands are implemented only after inspecting the real device.\n` +
+ `Run "commission" and then "inspect --json" against the hardware first.\n`,
+ );
+ return 3;
+}
+
+async function runStatus(config: Config): 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);
+
+ 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 runFabrics(
+ config: Config,
+ requestedNode: bigint | undefined,
+ removeArgument: string | undefined,
+): Promise<number> {
+ let removeIndex: number | undefined;
+ if (removeArgument !== undefined) {
+ removeIndex = Number(removeArgument);
+ if (!Number.isInteger(removeIndex) || removeIndex < 1) {
+ process.stderr.write(`--remove requires a positive fabric index (got "${removeArgument}")\n`);
+ return 2;
+ }
+ }
+
+ const controller = await startController(config);
+ try {
+ const nodeId = resolveSingleNode(controller, requestedNode);
+ const node = await connectDevice(controller, nodeId);
+
+ if (removeIndex !== undefined) {
+ await removeFabricByIndex(controller, node, removeIndex);
+ }
+
+ const table = await readFabricTable(controller, node);
+ process.stdout.write(formatFabricTable(nodeId, table) + "\n");
+ return 0;
+ } finally {
+ await controller.close();
+ }
+}
+
+async function runDecommission(config: Config, requestedNode: bigint | undefined): Promise<number> {
+ const controller = await startController(config);
+ try {
+ const nodeId = resolveSingleNode(controller, requestedNode);
+ const node = await connectDevice(controller, nodeId);
+
+ log.info(`Decommissioning: removing this controller's fabric from node ${nodeId}`);
+ await node.decommission();
+
+ removeNodeState(serviceStatePath(config.storagePath), nodeId);
+ const remaining = commissionedNodeIds(controller);
+ process.stdout.write(
+ [
+ `Device ${nodeId} removed this controller's fabric; its primary ecosystem is untouched.`,
+ 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.`,
+ "",
+ ].join("\n"),
+ );
+ return 0;
+ } finally {
+ await controller.close();
+ }
+}
+
+async function promptForPairingCode(): Promise<string> {
+ if (!process.stdin.isTTY) {
+ throw new Error("No pairing code given. Pass --code <pairing-code> when not running interactively.");
+ }
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
+ try {
+ const answer = await new Promise<string>(resolve =>
+ readline.question("Matter pairing code (from the primary ecosystem's pairing mode): ", resolve),
+ );
+ return answer.trim();
+ } finally {
+ readline.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;
+ });