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 ] [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 ] [--json] Connect to a device and dump its live endpoints, clusters, time-sync capabilities, and fabric table Commands with side effects: commission Join a device as an additional Matter admin (writes the device's fabric table and local storage) sync [--node ] [--json] Set each device's clock: UTC time, time zone, DST offsets decommission [--node ] 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 Configuration file (default: ${DEFAULT_CONFIG_PATH}) --json Machine-readable output (64-bit values as decimal strings) --help Show this help `; async function main(): Promise { 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 { const pairingCode = codeArgument?.trim(); if (pairingCode === undefined || pairingCode.length === 0) { process.stderr.write( "Usage: commission (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 { 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 { 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 { // 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 { 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 { 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; });