#!/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 ] [options] Commands: commission [--code ] Commission a device as an additional Matter admin nodes List commissioned devices and their sync state inspect [--node ] [--json] Dump a device's endpoints, clusters, time capabilities sync [--node ] Synchronize device clocks (not yet implemented) status Show controller and per-device state fabrics [--node ] [--remove ] List a device's fabric table; remove a stale entry decommission --node 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 Configuration file (default: ${DEFAULT_CONFIG_PATH}) --help Show this help `; async function main(): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { if (!process.stdin.isTTY) { throw new Error("No pairing code given. Pass --code when not running interactively."); } const readline = createInterface({ input: process.stdin, output: process.stdout }); try { const answer = await new Promise(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; });