diff options
Diffstat (limited to 'src/main.ts')
| -rw-r--r-- | src/main.ts | 381 |
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; + }); |
