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