src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/cli.ts
diff options
context:
space:
mode:
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;
+ });