src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/main.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/main.ts')
-rw-r--r--src/main.ts381
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;
+ });