diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/cli.ts | 306 | ||||
| -rw-r--r-- | src/commissioning.ts | 101 | ||||
| -rw-r--r-- | src/config.ts | 99 | ||||
| -rw-r--r-- | src/controller.ts | 96 | ||||
| -rw-r--r-- | src/device.ts | 72 | ||||
| -rw-r--r-- | src/fabrics.ts | 153 | ||||
| -rw-r--r-- | src/inspection.ts | 272 | ||||
| -rw-r--r-- | src/logging.ts | 71 | ||||
| -rw-r--r-- | src/state.ts | 90 | ||||
| -rw-r--r-- | src/sync.ts | 120 | ||||
| -rw-r--r-- | src/timezone.ts | 106 |
11 files changed, 1486 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; + }); diff --git a/src/commissioning.ts b/src/commissioning.ts new file mode 100644 index 0000000..b4edf9b --- /dev/null +++ b/src/commissioning.ts @@ -0,0 +1,101 @@ +import { ManualPairingCodeCodec, QrPairingCodeCodec } from "@matter/main/types"; +import { GeneralCommissioning } from "@matter/main/clusters/general-commissioning"; +import type { CommissioningController, NodeCommissioningOptions } from "@project-chip/matter.js"; +import type { Config } from "./config.js"; +import { Log } from "./logging.js"; +import { serviceStatePath, updateNodeState } from "./state.js"; + +const log = new Log("commissioning"); + +export interface CommissioningResult { + nodeId: bigint; +} + +interface ParsedPairingCode { + passcode: number; + shortDiscriminator?: number; + longDiscriminator?: number; +} + +/** + * Parses an Apple-generated Matter multi-admin pairing code. Accepts the + * 11/21-digit manual code (with or without hyphens) or an "MT:" QR payload. + * The code is used only in memory and never logged or stored. + */ +export function parsePairingCode(code: string): ParsedPairingCode { + const trimmed = code.trim(); + if (trimmed.length === 0) { + throw new Error("Pairing code is empty"); + } + + if (trimmed.toUpperCase().startsWith("MT:")) { + const [payload] = QrPairingCodeCodec.decode(trimmed); + if (payload === undefined) { + throw new Error("QR pairing code contains no payload"); + } + return { passcode: payload.passcode, longDiscriminator: payload.discriminator }; + } + + const digits = trimmed.replace(/[-\s]/g, ""); + if (!/^\d{11}$|^\d{21}$/.test(digits)) { + throw new Error("Manual pairing code must be 11 or 21 digits"); + } + const decoded = ManualPairingCodeCodec.decode(digits); + return { + passcode: decoded.passcode, + shortDiscriminator: decoded.shortDiscriminator, + longDiscriminator: decoded.discriminator, + }; +} + +/** + * Commissions a device as an additional Matter administrator using on-network + * (IP) discovery only. The device stays joined to its existing primary fabric + * (e.g. Apple Home); no Thread credentials are involved. + * + * Any number of devices may be commissioned, one pairing code each; a device + * that is already on this fabric rejects the attempt itself (fabric + * conflict), so duplicates cannot occur. + */ +export async function commissionDevice( + controller: CommissioningController, + config: Config, + pairingCode: string, +): Promise<CommissioningResult> { + const existing = controller.getCommissionedNodes(); + if (existing.length > 0) { + log.info( + `${existing.length} node${existing.length === 1 ? "" : "s"} already commissioned; ` + + `adding another device to the same fabric.`, + ); + } + + const parsed = parsePairingCode(pairingCode); + const identifierData = + parsed.longDiscriminator !== undefined + ? { longDiscriminator: parsed.longDiscriminator } + : parsed.shortDiscriminator !== undefined + ? { shortDiscriminator: parsed.shortDiscriminator } + : {}; + + log.info("Discovering commissionable device on the IP network (_matterc._udp)..."); + + const options: NodeCommissioningOptions = { + commissioning: { + regulatoryLocation: GeneralCommissioning.RegulatoryLocationType.IndoorOutdoor, + regulatoryCountryCode: "XX", + }, + discovery: { + identifierData, + discoveryCapabilities: { onIpNetwork: true }, + }, + passcode: parsed.passcode, + }; + + const nodeId = await controller.commissionNode(options, { connectNodeAfterCommissioning: false }); + const nodeIdBigint = BigInt(nodeId); + + updateNodeState(serviceStatePath(config.storagePath), nodeIdBigint, {}); + log.info(`Commissioning complete: node ${nodeIdBigint}`); + return { nodeId: nodeIdBigint }; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..14dbd4f --- /dev/null +++ b/src/config.ts @@ -0,0 +1,99 @@ +import { readFileSync } from "node:fs"; +import { isLogLevel, type LogLevel } from "./logging.js"; +import { isValidTimeZone } from "./timezone.js"; + +export const DEFAULT_CONFIG_PATH = "/etc/mattertimesync/config.json"; + +export interface Config { + /** Directory holding persistent Matter fabric state and service state. Contains secrets. */ + storagePath: string; + /** IANA time-zone name, e.g. "America/Chicago". Never a fixed UTC offset. */ + timezone: string; + logLevel: LogLevel; +} + +export class ConfigError extends Error {} + +const DEFAULTS = { + timezone: "America/Chicago", + logLevel: "info" as LogLevel, +}; + +export function loadConfig(path: string): Config { + let raw: string; + try { + raw = readFileSync(path, "utf8"); + } catch (cause) { + throw new ConfigError( + `Cannot read configuration file ${path}: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (cause) { + throw new ConfigError( + `Configuration file ${path} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + + return validateConfig(parsed, path); +} + +export function validateConfig(parsed: unknown, source = "configuration"): Config { + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new ConfigError(`${source}: top-level value must be a JSON object`); + } + const record = parsed as Record<string, unknown>; + + // Device membership lives in controller storage, not the configuration, so + // there is deliberately no nodeId field; commands take --node instead. + const known = new Set(["storagePath", "timezone", "logLevel"]); + for (const key of Object.keys(record)) { + if (!known.has(key)) { + throw new ConfigError(`${source}: unknown field "${key}"`); + } + } + + const storagePath = record["storagePath"]; + if (typeof storagePath !== "string" || storagePath.length === 0) { + throw new ConfigError(`${source}: "storagePath" is required and must be a non-empty string`); + } + + const timezone = record["timezone"] === undefined ? DEFAULTS.timezone : record["timezone"]; + if (typeof timezone !== "string" || !isValidTimeZone(timezone)) { + throw new ConfigError( + `${source}: "timezone" must be a valid IANA time-zone name (got ${JSON.stringify(timezone)})`, + ); + } + + const logLevel = record["logLevel"] === undefined ? DEFAULTS.logLevel : record["logLevel"]; + if (!isLogLevel(logLevel)) { + throw new ConfigError(`${source}: "logLevel" must be one of debug, info, warn, error`); + } + + return { storagePath, timezone, logLevel }; +} + +/** + * Parses a node ID given on the command line (--node). Node IDs are 64-bit + * values; they are carried as decimal strings so they never pass through a + * lossy JavaScript number. + */ +export function parseNodeId(value: unknown, source = "--node"): bigint { + if (typeof value === "string" && /^\d+$/.test(value)) { + return toNodeId(BigInt(value), source); + } + if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) { + return toNodeId(BigInt(value), source); + } + throw new ConfigError(`${source}: node ID must be a decimal string; got ${JSON.stringify(value)}`); +} + +function toNodeId(value: bigint, source: string): bigint { + if (value < 0n || value >= 1n << 64n) { + throw new ConfigError(`${source}: node ID must fit in an unsigned 64-bit integer`); + } + return value; +} diff --git a/src/controller.ts b/src/controller.ts new file mode 100644 index 0000000..b4d27f3 --- /dev/null +++ b/src/controller.ts @@ -0,0 +1,96 @@ +import { mkdirSync } from "node:fs"; +import { Environment, StorageService } from "@matter/main"; +import { CommissioningController } from "@project-chip/matter.js"; +import type { Config } from "./config.js"; +import { Log, type LogLevel } from "./logging.js"; + +const log = new Log("controller"); + +/** + * Fabric label shown to other Matter administrators (e.g. in Apple Home's + * connected-services list). Maximum 32 characters. + */ +const FABRIC_LABEL = "mattertimesync"; + +/** Storage namespace under the storage path; also the controller's unique environment id. */ +const CONTROLLER_ID = "mattertimesync"; + +/** + * matter.js library log level derived from the service log level. At service + * level "info" the library is kept at "warn" so protocol internals (discovery, + * session management) only appear when debugging. + */ +function matterLogLevel(serviceLevel: LogLevel): string { + return serviceLevel === "debug" ? "debug" : serviceLevel === "info" ? "warn" : serviceLevel; +} + +/** + * Creates and starts the Matter controller with persistent storage rooted at + * the configured storage path. The fabric identity (root certificate, keys, + * node allocation state) persists across restarts; a fabric is only generated + * on the very first start. + */ +export async function startController(config: Config): Promise<CommissioningController> { + mkdirSync(config.storagePath, { recursive: true, mode: 0o700 }); + + const environment = Environment.default; + environment.vars.set("storage.path", config.storagePath); + environment.vars.set("log.level", matterLogLevel(config.logLevel)); + + const storage = environment.get(StorageService); + log.debug(`Matter storage location: ${storage.location}`); + + const controller = new CommissioningController({ + environment: { environment, id: CONTROLLER_ID }, + autoConnect: false, + adminFabricLabel: FABRIC_LABEL, + }); + + await controller.start(); + const commissioned = controller.getCommissionedNodes(); + log.info( + `Controller started (fabric ${controller.fabric.fabricId}, ${commissioned.length} commissioned node${ + commissioned.length === 1 ? "" : "s" + })`, + ); + return controller; +} + +/** All commissioned node IDs in controller storage. */ +export function commissionedNodeIds(controller: CommissioningController): bigint[] { + return controller.getCommissionedNodes().map(id => BigInt(id)); +} + +/** + * Resolves the set of nodes a command operates on: the explicitly requested + * one, or every commissioned node. + */ +export function resolveTargetNodes(controller: CommissioningController, requested?: bigint): bigint[] { + const commissioned = commissionedNodeIds(controller); + if (commissioned.length === 0) { + throw new Error('No device is commissioned yet. Run "commission" first.'); + } + if (requested === undefined) return commissioned; + if (!commissioned.includes(requested)) { + throw new Error( + `Node ${requested} is not commissioned on this controller ` + + `(known nodes: ${commissioned.join(", ")}). Run "nodes" to list them.`, + ); + } + return [requested]; +} + +/** + * Resolves exactly one node for commands that cannot meaningfully operate on + * several (inspect, fabrics, decommission). --node is required once more than + * one device is commissioned. + */ +export function resolveSingleNode(controller: CommissioningController, requested?: bigint): bigint { + const targets = resolveTargetNodes(controller, requested); + if (targets.length > 1) { + throw new Error( + `Multiple nodes are commissioned (${targets.join(", ")}); pass --node <id> to choose one.`, + ); + } + return targets[0]!; +} diff --git a/src/device.ts b/src/device.ts new file mode 100644 index 0000000..1c0d0ea --- /dev/null +++ b/src/device.ts @@ -0,0 +1,72 @@ +import { NodeId } from "@matter/main"; +import type { CommissioningController } from "@project-chip/matter.js"; +import { NodeStates, type PairedNode } from "@project-chip/matter.js/device"; +import { Log } from "./logging.js"; + +const log = new Log("device"); + +export function nodeStateName(state: NodeStates): string { + switch (state) { + case NodeStates.Connected: + return "connected"; + case NodeStates.Disconnected: + return "disconnected"; + case NodeStates.Reconnecting: + return "reconnecting"; + case NodeStates.WaitingForDeviceDiscovery: + return "waiting for device discovery"; + } +} + +/** + * Connects to the commissioned node using operational discovery (DNS-SD by + * node identity, never a stored IP address) and waits until the node is fully + * initialized from the device. + * + * The returned PairedNode keeps reconnecting in the background; observe + * `events.stateChanged` for connectivity transitions. + */ +export async function connectDevice( + controller: CommissioningController, + nodeId: bigint, + timeoutMs = 60_000, +): Promise<PairedNode> { + const node = await controller.getNode(NodeId(nodeId)); + + node.events.stateChanged.on(state => { + log.debug(`Node ${nodeId} state: ${nodeStateName(state)}`); + }); + + if (!node.initialized) { + log.info(`Connecting to Matter node ${nodeId}...`); + node.connect(); + await waitForInitialization(node, nodeId, timeoutMs); + } else if (!node.isConnected) { + node.connect(); + await waitForInitialization(node, nodeId, timeoutMs); + } + + log.info(`Connected to Matter node ${nodeId}`); + return node; +} + +async function waitForInitialization(node: PairedNode, nodeId: bigint, timeoutMs: number): Promise<void> { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise<never>((_, reject) => { + timer = setTimeout( + () => + reject( + new Error( + `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for node ${nodeId}. ` + + `The device may be offline or unreachable over IPv6.`, + ), + ), + timeoutMs, + ); + }); + try { + await Promise.race([node.events.initializedFromRemote, timeout]); + } finally { + clearTimeout(timer); + } +} diff --git a/src/fabrics.ts b/src/fabrics.ts new file mode 100644 index 0000000..2c4c90e --- /dev/null +++ b/src/fabrics.ts @@ -0,0 +1,153 @@ +import { FabricIndex } from "@matter/main"; +import { OperationalCredentials } from "@matter/main/clusters/operational-credentials"; +import type { CommissioningController } from "@project-chip/matter.js"; +import type { PairedNode } from "@project-chip/matter.js/device"; +import { Log } from "./logging.js"; + +const log = new Log("fabrics"); + +/** + * Fabric-table management on the device's Operational Credentials cluster. + * + * Every commissioning writes a fabric entry into the device's limited fabric + * table. Entries never expire; identities whose local storage was deleted + * leave orphans behind. These helpers make the table visible and allow an + * admin (us) to remove stale entries without a factory reset. + */ + +export interface FabricEntry { + fabricIndex: number; + fabricId: bigint; + nodeId: bigint; + vendorId: number; + label: string; + /** True when the entry belongs to this controller's current identity. */ + isOurs: boolean; +} + +export interface FabricTable { + supportedFabrics: number; + commissionedFabrics: number; + entries: FabricEntry[]; +} + +const KNOWN_VENDORS: Record<number, string> = { + 0x1349: "Apple", + 0x6006: "Google", + 0x10e1: "SmartThings", + 0x117c: "IKEA", +}; + +function vendorName(vendorId: number): string { + const known = KNOWN_VENDORS[vendorId]; + if (known !== undefined) return known; + if (vendorId >= 0xfff1 && vendorId <= 0xfff4) return "test vendor"; + return "unknown vendor"; +} + +export async function readFabricTable( + controller: CommissioningController, + node: PairedNode, +): Promise<FabricTable> { + const client = node.getRootClusterClient(OperationalCredentials.Complete); + if (client === undefined) { + throw new Error("Operational Credentials cluster not found on the device's root endpoint"); + } + + // Read from the device with fabric filtering off, so entries from all + // fabrics are returned, not just our own. + const fabrics = await client.attributes.fabrics.get(true, false); + const supportedFabrics = (await client.attributes.supportedFabrics.get(true)) ?? 0; + const commissionedFabrics = (await client.attributes.commissionedFabrics.get(true)) ?? 0; + if (fabrics === undefined) { + throw new Error("Device returned no fabric list"); + } + + const ourFabric = controller.fabric; + const entries = fabrics.map(fabric => ({ + fabricIndex: Number(fabric.fabricIndex), + fabricId: BigInt(fabric.fabricId), + nodeId: BigInt(fabric.nodeId), + vendorId: Number(fabric.vendorId), + label: fabric.label, + isOurs: ourFabric.matchesFabricIdAndRootPublicKey(fabric.fabricId, fabric.rootPublicKey), + })); + + return { supportedFabrics, commissionedFabrics, entries }; +} + +export function formatFabricTable(nodeId: bigint, table: FabricTable): string { + const lines: string[] = []; + lines.push( + `Fabric table on node ${nodeId} (${table.commissionedFabrics} of ${table.supportedFabrics} slots used):`, + ); + for (const entry of table.entries) { + const vendor = `0x${entry.vendorId.toString(16).padStart(4, "0")} (${vendorName(entry.vendorId)})`; + const marker = entry.isOurs ? " [this controller]" : ""; + lines.push( + ` index ${entry.fabricIndex}: label ${JSON.stringify(entry.label)} vendor ${vendor} ` + + `fabricId ${entry.fabricId} nodeId ${entry.nodeId}${marker}`, + ); + } + const strays = findLikelyStrays(table); + if (strays.length > 0) { + lines.push(""); + lines.push( + `Likely stale entries (our label, but not our current identity): ` + + `${strays.map(entry => `index ${entry.fabricIndex}`).join(", ")}`, + ); + lines.push(`Remove one with: mattertimesync fabrics --remove <index>`); + } + return lines.join("\n"); +} + +/** + * Entries carrying our fabric label but not our current identity: orphans + * from a commissioning whose local storage was deleted or replaced. + */ +export function findLikelyStrays(table: FabricTable): FabricEntry[] { + const ourLabel = table.entries.find(entry => entry.isOurs)?.label; + return table.entries.filter(entry => !entry.isOurs && ourLabel !== undefined && entry.label === ourLabel); +} + +/** + * Removes another fabric's entry from the device. Refuses to remove our own + * entry; `decommission` is the correct path for that, because it also cleans + * up local controller state. + */ +export async function removeFabricByIndex( + controller: CommissioningController, + node: PairedNode, + fabricIndex: number, +): Promise<void> { + const table = await readFabricTable(controller, node); + const entry = table.entries.find(candidate => candidate.fabricIndex === fabricIndex); + if (entry === undefined) { + throw new Error( + `No fabric with index ${fabricIndex} on the device ` + + `(present: ${table.entries.map(candidate => candidate.fabricIndex).join(", ")})`, + ); + } + if (entry.isOurs) { + throw new Error( + `Fabric index ${fabricIndex} is this controller's own entry. ` + + `Use "decommission" instead, so local state is cleaned up too.`, + ); + } + + const client = node.getRootClusterClient(OperationalCredentials.Complete); + if (client === undefined) { + throw new Error("Operational Credentials cluster not found on the device's root endpoint"); + } + + log.info(`Removing fabric index ${fabricIndex} (label ${JSON.stringify(entry.label)}) from the device`); + const response = await client.commands.removeFabric({ fabricIndex: FabricIndex(fabricIndex) }); + if (response.statusCode !== OperationalCredentials.NodeOperationalCertStatus.Ok) { + throw new Error( + `Device rejected RemoveFabric for index ${fabricIndex}: ` + + `status ${OperationalCredentials.NodeOperationalCertStatus[response.statusCode] ?? response.statusCode}` + + `${response.debugText ? ` (${response.debugText})` : ""}`, + ); + } + log.info(`Fabric index ${fabricIndex} removed`); +} diff --git a/src/inspection.ts b/src/inspection.ts new file mode 100644 index 0000000..b022b60 --- /dev/null +++ b/src/inspection.ts @@ -0,0 +1,272 @@ +import { ClusterId } from "@matter/main"; +import { SupportedAttributeClient } from "@project-chip/matter.js/cluster"; +import type { Endpoint, PairedNode } from "@project-chip/matter.js/device"; +import { Log } from "./logging.js"; + +const log = new Log("inspection"); + +const TIME_SYNC_CLUSTER_ID = ClusterId(0x38); +/** Standard cluster IDs are <= 0x7fff; anything above is manufacturer-specific. */ +const LAST_STANDARD_CLUSTER_ID = 0x7fff; + +export interface ClusterSummary { + id: string; + name: string; + revision: number; + isUnknown: boolean; + vendorSpecific: boolean; + supportedFeatures: Record<string, boolean>; +} + +export interface EndpointSummary { + endpointId: number; + name: string; + deviceTypes: { name: string; code: string; revision: number }[]; + serverClusters: ClusterSummary[]; + /** Client-side cluster IDs from the endpoint's descriptor. */ + clientClusters: string[]; +} + +export interface TimeSyncAttribute { + id: string; + name: string; + value: unknown; +} + +export interface TimeSyncSummary { + endpointId: number; + clusterRevision: number; + supportedFeatures: Record<string, boolean>; + featureMap: unknown; + supportedCommands: Record<string, boolean>; + attributes: TimeSyncAttribute[]; +} + +export interface InspectionReport { + nodeId: string; + basicInformation: { + vendorName: string; + vendorId: number; + productName: string; + productId: number; + hardwareVersion: string; + softwareVersion: string; + serialNumber?: string; + uniqueId?: string; + } | null; + endpoints: EndpointSummary[]; + timeSynchronization: TimeSyncSummary | null; + vendorSpecificClusters: { endpointId: number; clusterId: string; name: string }[]; +} + +/** Commands whose availability drives the capability-based sync stages. */ +const TIME_SYNC_COMMANDS = [ + "setUtcTime", + "setTrustedTimeSource", + "setTimeZone", + "setDstOffset", + "setDefaultNtp", +] as const; + +export async function inspectNode(node: PairedNode): Promise<InspectionReport> { + const endpoints = collectEndpoints(node); + + const endpointSummaries = endpoints.map(endpoint => summarizeEndpoint(endpoint)); + + const info = node.basicInformation; + const basicInformation = info + ? { + vendorName: info.vendorName, + vendorId: Number(info.vendorId), + productName: info.productName, + productId: info.productId, + hardwareVersion: info.hardwareVersionString, + softwareVersion: info.softwareVersionString, + serialNumber: info.serialNumber, + uniqueId: info.uniqueId, + } + : null; + + const timeSynchronization = await summarizeTimeSync(endpoints); + if (timeSynchronization === null) { + log.warn("Time Synchronization cluster not found on any endpoint"); + } else { + log.info(`Time Synchronization cluster found on endpoint ${timeSynchronization.endpointId}`); + } + + const vendorSpecificClusters = endpointSummaries.flatMap(endpoint => + endpoint.serverClusters + .filter(cluster => cluster.vendorSpecific) + .map(cluster => ({ endpointId: endpoint.endpointId, clusterId: cluster.id, name: cluster.name })), + ); + + return { + nodeId: BigInt(node.nodeId).toString(), + basicInformation, + endpoints: endpointSummaries, + timeSynchronization, + vendorSpecificClusters, + }; +} + +function collectEndpoints(node: PairedNode): Endpoint[] { + const seen = new Map<number, Endpoint>(); + const root = node.getRootEndpoint(); + if (root?.number !== undefined) seen.set(root.number, root); + for (const [number, endpoint] of node.parts) { + if (!seen.has(number)) seen.set(number, endpoint); + } + return [...seen.entries()].sort(([a], [b]) => a - b).map(([, endpoint]) => endpoint); +} + +function summarizeEndpoint(endpoint: Endpoint): EndpointSummary { + const serverClusters = endpoint.getAllClusterClients().map(client => ({ + id: formatClusterId(client.id), + name: client.name, + revision: client.revision, + isUnknown: client.isUnknown, + vendorSpecific: client.id > LAST_STANDARD_CLUSTER_ID, + supportedFeatures: Object.fromEntries( + Object.entries(client.supportedFeatures ?? {}).filter(([, enabled]) => typeof enabled === "boolean"), + ) as Record<string, boolean>, + })); + + let clientClusters: string[] = []; + try { + clientClusters = (endpoint.state.descriptor.clientList ?? []).map(id => formatClusterId(id)); + } catch { + // Descriptor state unavailable; leave the client list empty. + } + + return { + endpointId: endpoint.number ?? -1, + name: endpoint.name, + deviceTypes: endpoint.getDeviceTypes().map(type => ({ + name: type.name, + code: `0x${type.code.toString(16).padStart(4, "0")}`, + revision: type.revision, + })), + serverClusters, + clientClusters, + }; +} + +async function summarizeTimeSync(endpoints: Endpoint[]): Promise<TimeSyncSummary | null> { + for (const endpoint of endpoints) { + const client = endpoint.getClusterClientById(TIME_SYNC_CLUSTER_ID); + if (client === undefined) continue; + + const attributes: TimeSyncAttribute[] = []; + for (const [name, attribute] of Object.entries(client.attributes)) { + if (!(attribute instanceof SupportedAttributeClient)) continue; + let value: unknown; + try { + value = attribute.getLocal(); + if (value === undefined) value = await attribute.get(); + } catch (cause) { + value = `<read failed: ${cause instanceof Error ? cause.message : String(cause)}>`; + } + attributes.push({ id: `0x${attribute.id.toString(16)}`, name, value }); + } + + const supportedCommands = Object.fromEntries( + TIME_SYNC_COMMANDS.map(command => [command, client.isCommandSupportedByName(command)]), + ); + + const featureMap = attributes.find(attribute => attribute.name === "featureMap")?.value ?? null; + + return { + endpointId: endpoint.number ?? -1, + clusterRevision: client.revision, + supportedFeatures: Object.fromEntries( + Object.entries(client.supportedFeatures ?? {}).filter(([, enabled]) => typeof enabled === "boolean"), + ) as Record<string, boolean>, + featureMap, + supportedCommands, + attributes, + }; + } + return null; +} + +function formatClusterId(id: number): string { + return `0x${Number(id).toString(16).padStart(4, "0")}`; +} + +/** JSON output with bigints rendered as decimal strings. */ +export function inspectionToJson(report: InspectionReport): string { + return JSON.stringify(report, (_key, value) => (typeof value === "bigint" ? value.toString() : value), 2); +} + +export function formatInspection(report: InspectionReport): string { + const lines: string[] = []; + lines.push(`Node ${report.nodeId}`); + + if (report.basicInformation) { + const info = report.basicInformation; + lines.push(` Vendor: ${info.vendorName} (0x${info.vendorId.toString(16).padStart(4, "0")})`); + lines.push(` Product: ${info.productName} (0x${info.productId.toString(16).padStart(4, "0")})`); + lines.push(` Hardware: ${info.hardwareVersion} Software: ${info.softwareVersion}`); + if (info.serialNumber) lines.push(` Serial: ${info.serialNumber}`); + } + + for (const endpoint of report.endpoints) { + lines.push(""); + const types = endpoint.deviceTypes.map(t => `${t.name} (${t.code}, rev ${t.revision})`).join(", "); + lines.push(`Endpoint ${endpoint.endpointId}: ${types}`); + if (endpoint.serverClusters.length > 0) { + lines.push(" Server clusters:"); + for (const cluster of endpoint.serverClusters) { + const features = Object.entries(cluster.supportedFeatures) + .filter(([, enabled]) => enabled) + .map(([name]) => name); + const notes = [ + `rev ${cluster.revision}`, + ...(features.length > 0 ? [`features: ${features.join(", ")}`] : []), + ...(cluster.vendorSpecific ? ["vendor-specific"] : []), + ...(cluster.isUnknown ? ["unknown to matter.js"] : []), + ]; + lines.push(` ${cluster.id} ${cluster.name} (${notes.join("; ")})`); + } + } + if (endpoint.clientClusters.length > 0) { + lines.push(` Client clusters: ${endpoint.clientClusters.join(", ")}`); + } + } + + lines.push(""); + if (report.timeSynchronization) { + const ts = report.timeSynchronization; + lines.push(`Time Synchronization cluster (endpoint ${ts.endpointId}, revision ${ts.clusterRevision})`); + const features = Object.entries(ts.supportedFeatures) + .filter(([, enabled]) => enabled) + .map(([name]) => name); + lines.push(` Features: ${features.length > 0 ? features.join(", ") : "(none)"}`); + lines.push(" Commands:"); + for (const [command, supported] of Object.entries(ts.supportedCommands)) { + lines.push(` ${command}: ${supported ? "supported" : "not supported"}`); + } + lines.push(" Attributes:"); + for (const attribute of ts.attributes) { + lines.push(` ${attribute.name} (${attribute.id}): ${renderValue(attribute.value)}`); + } + } else { + lines.push("Time Synchronization cluster: NOT FOUND"); + lines.push(" This device cannot be synchronized through the standard Matter mechanism."); + } + + if (report.vendorSpecificClusters.length > 0) { + lines.push(""); + lines.push("Vendor-specific clusters:"); + for (const cluster of report.vendorSpecificClusters) { + lines.push(` endpoint ${cluster.endpointId}: ${cluster.clusterId} ${cluster.name}`); + } + } + + return lines.join("\n"); +} + +function renderValue(value: unknown): string { + if (value === undefined) return "<not read>"; + return JSON.stringify(value, (_key, inner) => (typeof inner === "bigint" ? inner.toString() : inner)); +} diff --git a/src/logging.ts b/src/logging.ts new file mode 100644 index 0000000..c8c75d7 --- /dev/null +++ b/src/logging.ts @@ -0,0 +1,71 @@ +/** + * Structured, level-filtered logging for the service's own messages. + * + * matter.js library logging is configured separately (see controller.ts); this + * logger covers service-level events in the format: + * + * 2026-07-26T20:00:00.100Z INFO controller Connected to Matter node 1 + */ + +export const LOG_LEVELS = ["debug", "info", "warn", "error"] as const; +export type LogLevel = (typeof LOG_LEVELS)[number]; + +export function isLogLevel(value: unknown): value is LogLevel { + return typeof value === "string" && (LOG_LEVELS as readonly string[]).includes(value); +} + +const LEVEL_RANK: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 }; +const LEVEL_LABEL: Record<LogLevel, string> = { + debug: "DEBUG", + info: "INFO ", + warn: "WARN ", + error: "ERROR", +}; + +let globalLevel: LogLevel = "info"; + +export function setLogLevel(level: LogLevel): void { + globalLevel = level; +} + +export function getLogLevel(): LogLevel { + return globalLevel; +} + +function write(level: LogLevel, module: string, message: string): void { + if (LEVEL_RANK[level] < LEVEL_RANK[globalLevel]) return; + const line = `${new Date().toISOString()} ${LEVEL_LABEL[level]} ${module} ${message}`; + if (level === "error" || level === "warn") { + process.stderr.write(line + "\n"); + } else { + process.stdout.write(line + "\n"); + } +} + +/** A logger bound to a module name (e.g. "controller", "sync"). */ +export class Log { + constructor(private readonly module: string) {} + + debug(message: string): void { + write("debug", this.module, message); + } + + info(message: string): void { + write("info", this.module, message); + } + + warn(message: string): void { + write("warn", this.module, message); + } + + error(message: string, cause?: unknown): void { + write("error", this.module, cause === undefined ? message : `${message}: ${describeError(cause)}`); + } +} + +export function describeError(cause: unknown): string { + if (cause instanceof Error) { + return cause.message; + } + return String(cause); +} diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..c5621fe --- /dev/null +++ b/src/state.ts @@ -0,0 +1,90 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** + * Small service-state file kept next to the Matter fabric storage, with one + * entry per commissioned node (keyed by node ID as a decimal string). Written + * atomically (temp file + rename) so a power loss mid-write never corrupts it. + */ +export interface NodeState { + lastSuccessfulConnection: string | null; + lastSuccessfulSync: string | null; + lastAttemptedSync: string | null; + lastError: string | null; +} + +export interface ServiceState { + nodes: Record<string, NodeState>; +} + +export const EMPTY_NODE_STATE: NodeState = { + lastSuccessfulConnection: null, + lastSuccessfulSync: null, + lastAttemptedSync: null, + lastError: null, +}; + +export function serviceStatePath(storagePath: string): string { + return join(storagePath, "service-state.json"); +} + +export function readServiceState(path: string): ServiceState { + let raw: string; + try { + raw = readFileSync(path, "utf8"); + } catch { + return { nodes: {} }; + } + try { + const parsed = JSON.parse(raw) as Partial<ServiceState>; + const nodes: Record<string, NodeState> = {}; + if (typeof parsed.nodes === "object" && parsed.nodes !== null) { + for (const [nodeId, value] of Object.entries(parsed.nodes)) { + if (!/^\d+$/.test(nodeId) || typeof value !== "object" || value === null) continue; + const entry = value as Partial<NodeState>; + nodes[nodeId] = { + lastSuccessfulConnection: stringOrNull(entry.lastSuccessfulConnection), + lastSuccessfulSync: stringOrNull(entry.lastSuccessfulSync), + lastAttemptedSync: stringOrNull(entry.lastAttemptedSync), + lastError: stringOrNull(entry.lastError), + }; + } + } + return { nodes }; + } catch { + // A corrupt state file is not fatal; it only holds status metadata. + return { nodes: {} }; + } +} + +export function writeServiceState(path: string, state: ServiceState): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const temp = `${path}.tmp`; + writeFileSync(temp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 }); + renameSync(temp, path); +} + +export function nodeState(state: ServiceState, nodeId: bigint): NodeState { + return state.nodes[nodeId.toString()] ?? { ...EMPTY_NODE_STATE }; +} + +/** Merges a patch into one node's entry and persists the result. */ +export function updateNodeState(path: string, nodeId: bigint, patch: Partial<NodeState>): ServiceState { + const state = readServiceState(path); + const key = nodeId.toString(); + state.nodes[key] = { ...(state.nodes[key] ?? EMPTY_NODE_STATE), ...patch }; + writeServiceState(path, state); + return state; +} + +/** Drops one node's entry (after decommissioning) and persists the result. */ +export function removeNodeState(path: string, nodeId: bigint): ServiceState { + const state = readServiceState(path); + delete state.nodes[nodeId.toString()]; + writeServiceState(path, state); + return state; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" ? value : null; +} diff --git a/src/sync.ts b/src/sync.ts new file mode 100644 index 0000000..ec3577c --- /dev/null +++ b/src/sync.ts @@ -0,0 +1,120 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +/** + * Matter epoch conversion. + * + * Matter UTC time is microseconds since 2000-01-01T00:00:00Z (the "Matter + * epoch"), not the Unix epoch. All arithmetic stays in bigint; the full + * microsecond timestamp must never pass through a JavaScript number. + * + * The Time Synchronization write commands (SetUtcTime, SetTimeZone, + * SetDstOffset) are intentionally NOT implemented yet: per the project plan + * they are Phase 5/6 work that must be driven by the real device's inspected + * capabilities. Run `inspect` against the hardware first. + */ + +export const MATTER_EPOCH_UNIX_SECONDS = 946_684_800n; + +/** Converts a Unix-epoch timestamp in milliseconds to Matter-epoch microseconds. */ +export function unixMillisToMatterMicros(unixMilliseconds: bigint): bigint { + return unixMilliseconds * 1_000n - MATTER_EPOCH_UNIX_SECONDS * 1_000_000n; +} + +/** Converts Matter-epoch microseconds back to Unix-epoch milliseconds (truncating). */ +export function matterMicrosToUnixMillis(matterMicroseconds: bigint): bigint { + return (matterMicroseconds + MATTER_EPOCH_UNIX_SECONDS * 1_000_000n) / 1_000n; +} + +/** Current time as Matter-epoch microseconds. */ +export function currentMatterUtcMicroseconds(now: () => number = Date.now): bigint { + return unixMillisToMatterMicros(BigInt(now())); +} + +/** Renders Matter-epoch microseconds as an ISO-8601 UTC instant (millisecond precision). */ +export function formatMatterMicros(matterMicroseconds: bigint): string { + return new Date(Number(matterMicrosToUnixMillis(matterMicroseconds))).toISOString(); +} + +/** + * Comparison of the device's reported clock against the time we are about to + * set, for the sync report ("was X, set to Y, delta Z"). + */ +export interface ClockComparison { + /** Device time before sync as ISO-8601, or null if its clock was unset. */ + deviceTime: string | null; + /** The time being written, as ISO-8601. */ + hostTime: string; + /** deviceTime - hostTime in microseconds; null when the device clock was unset. */ + deltaMicroseconds: bigint | null; + /** Human-readable summary, e.g. "device clock was 3.2s behind". */ + description: string; +} + +/** + * Compares the device's utcTime attribute (null when the device lost its + * clock, e.g. after a power outage) against the host time being written. + */ +export function compareDeviceClock(deviceMicros: bigint | null, hostMicros: bigint): ClockComparison { + const hostTime = formatMatterMicros(hostMicros); + if (deviceMicros === null) { + return { + deviceTime: null, + hostTime, + deltaMicroseconds: null, + description: "device clock was unset", + }; + } + const delta = deviceMicros - hostMicros; + const magnitude = delta < 0n ? -delta : delta; + const description = + magnitude < 1_000_000n + ? `device clock was within 1s of host time (${formatDurationMicros(magnitude)} ${ + delta < 0n ? "behind" : "ahead" + })` + : `device clock was ${formatDurationMicros(magnitude)} ${delta < 0n ? "behind" : "ahead"}`; + return { deviceTime: formatMatterMicros(deviceMicros), hostTime, deltaMicroseconds: delta, description }; +} + +/** Formats a non-negative duration in microseconds as a compact human string. */ +export function formatDurationMicros(microseconds: bigint): string { + if (microseconds < 0n) throw new Error("duration must be non-negative"); + if (microseconds < 1_000n) return `${microseconds}us`; + if (microseconds < 1_000_000n) return `${microseconds / 1_000n}ms`; + + const totalSeconds = microseconds / 1_000_000n; + if (totalSeconds < 60n) { + const tenths = (microseconds % 1_000_000n) / 100_000n; + return tenths === 0n ? `${totalSeconds}s` : `${totalSeconds}.${tenths}s`; + } + const days = totalSeconds / 86_400n; + const hours = (totalSeconds % 86_400n) / 3_600n; + const minutes = (totalSeconds % 3_600n) / 60n; + const seconds = totalSeconds % 60n; + const parts: string[] = []; + if (days > 0n) parts.push(`${days}d`); + if (hours > 0n) parts.push(`${hours}h`); + if (minutes > 0n) parts.push(`${minutes}m`); + if (seconds > 0n && days === 0n) parts.push(`${seconds}s`); + return parts.join(" "); +} + +const execFileAsync = promisify(execFile); + +/** + * Whether the host clock is NTP-synchronized, via systemd-timesyncd. The + * device must never be set from an unsynchronized clock. + * + * Returns false on hosts without timedatectl (e.g. during development on + * macOS) so callers fail safe; pass `assumeSynchronized` explicitly in tests. + */ +export async function isHostClockSynchronized(): Promise<boolean> { + try { + const { stdout } = await execFileAsync("timedatectl", ["show", "-p", "NTPSynchronized", "--value"], { + timeout: 5000, + }); + return stdout.trim() === "yes"; + } catch { + return false; + } +} diff --git a/src/timezone.ts b/src/timezone.ts new file mode 100644 index 0000000..f4240f4 --- /dev/null +++ b/src/timezone.ts @@ -0,0 +1,106 @@ +/** + * IANA time-zone helpers. + * + * Phase 1-4 scope: validation and offset introspection used by config + * validation and status output. Matter TimeZone/DSTOffset structure + * generation (Phase 6) will build on these helpers once the real device's + * Time Synchronization capabilities have been inspected. + */ + +/** Returns true when the host's ICU data recognizes the IANA time-zone name. */ +export function isValidTimeZone(timezone: string): boolean { + if (typeof timezone !== "string" || timezone.length === 0) return false; + try { + new Intl.DateTimeFormat("en-US", { timeZone: timezone }); + return true; + } catch { + return false; + } +} + +/** + * Current UTC offset of `timezone` at `date`, in seconds. + * + * Derived from ICU rules, never from a hard-coded offset, so daylight-saving + * transitions are honored automatically. + */ +export function utcOffsetSeconds(timezone: string, date: Date = new Date()): number { + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + timeZoneName: "longOffset", + }); + const offsetPart = formatter.formatToParts(date).find(part => part.type === "timeZoneName")?.value; + if (offsetPart === undefined) { + throw new Error(`Unable to determine UTC offset for time zone ${timezone}`); + } + // Formats look like "GMT-05:00", "GMT+05:30", or plain "GMT" for UTC itself. + const match = /^GMT(?:([+-])(\d{2}):(\d{2})(?::(\d{2}))?)?$/.exec(offsetPart); + if (!match) { + throw new Error(`Unrecognized UTC offset format "${offsetPart}" for time zone ${timezone}`); + } + if (match[1] === undefined) return 0; + const sign = match[1] === "-" ? -1 : 1; + const hours = Number(match[2]); + const minutes = Number(match[3]); + const seconds = match[4] === undefined ? 0 : Number(match[4]); + return sign * (hours * 3600 + minutes * 60 + seconds); +} + +/** Formats an offset in seconds as "UTC-05:00" style text for logs and status output. */ +export function formatUtcOffset(offsetSeconds: number): string { + const sign = offsetSeconds < 0 ? "-" : "+"; + const absolute = Math.abs(offsetSeconds); + const hours = String(Math.floor(absolute / 3600)).padStart(2, "0"); + const minutes = String(Math.floor((absolute % 3600) / 60)).padStart(2, "0"); + return `UTC${sign}${hours}:${minutes}`; +} + +/** + * Finds the next instant at which the zone's UTC offset changes, scanning up + * to `horizonDays` ahead. Returns null when no transition occurs in the + * window (e.g. fixed-offset zones). + * + * Uses day-granularity scan plus binary search, so it is exact to the second + * without iterating minute-by-minute. + */ +export function nextOffsetTransition( + timezone: string, + from: Date = new Date(), + horizonDays = 400, +): { at: Date; offsetBeforeSeconds: number; offsetAfterSeconds: number } | null { + const startOffset = utcOffsetSeconds(timezone, from); + const dayMs = 24 * 60 * 60 * 1000; + // Probe on whole-second boundaries so the binary search converges on the + // exact transition instant (transitions occur at whole seconds). + const startMs = Math.ceil(from.getTime() / 1000) * 1000; + + let previous = startMs; + let changedAtOrBefore: number | null = null; + for (let day = 1; day <= horizonDays; day++) { + const probe = startMs + day * dayMs; + if (utcOffsetSeconds(timezone, new Date(probe)) !== startOffset) { + changedAtOrBefore = probe; + break; + } + previous = probe; + } + if (changedAtOrBefore === null) return null; + + // Binary search the exact transition instant between the last unchanged + // probe and the first changed probe. + let low = previous; + let high = changedAtOrBefore; + while (high - low > 1000) { + const middle = low + Math.floor((high - low) / 2 / 1000) * 1000; + if (utcOffsetSeconds(timezone, new Date(middle)) === startOffset) { + low = middle; + } else { + high = middle; + } + } + return { + at: new Date(high), + offsetBeforeSeconds: startOffset, + offsetAfterSeconds: utcOffsetSeconds(timezone, new Date(high)), + }; +} |
