diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/cli.ts | 9 | ||||
| -rw-r--r-- | src/commissioning.ts | 101 | ||||
| -rw-r--r-- | src/config.ts | 99 | ||||
| -rw-r--r-- | src/controller.ts | 102 | ||||
| -rw-r--r-- | src/device.ts | 72 | ||||
| -rw-r--r-- | src/fabrics.ts | 111 | ||||
| -rw-r--r-- | src/inspection.ts | 267 | ||||
| -rw-r--r-- | src/json.ts | 8 | ||||
| -rw-r--r-- | src/logging.ts | 69 | ||||
| -rw-r--r-- | src/main.ts | 381 | ||||
| -rw-r--r-- | src/sqlite-lazy.ts | 17 | ||||
| -rw-r--r-- | src/state.ts | 90 | ||||
| -rw-r--r-- | src/sync.ts | 181 | ||||
| -rw-r--r-- | src/timesync.ts | 268 | ||||
| -rw-r--r-- | src/timezone.ts | 190 | ||||
| -rw-r--r-- | src/warnings.ts | 19 |
16 files changed, 1984 insertions, 0 deletions
diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..c3579d7 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,9 @@ +#!/usr/bin/env node +/** + * Entry point. The warning filter must be installed before matter.js loads + * (its import graph reaches node:sqlite, whose ExperimentalWarning fires at + * module load). Static imports are hoisted and evaluated before any code + * runs, so the program body is loaded dynamically after the filter. + */ +import "./warnings.js"; +await import("./main.js"); 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..ee65d82 --- /dev/null +++ b/src/controller.ts @@ -0,0 +1,102 @@ +import { mkdirSync } from "node:fs"; +import { Environment, LogFormat, Logger, StorageService } from "@matter/main"; +import { CommissioningController } from "@project-chip/matter.js"; +import type { Config } from "./config.js"; +import { Log, type LogLevel } from "./logging.js"; + +// matter.js library logs go to stderr, like the service's own logs, so +// stdout carries only command output (in particular clean --json). Plain +// format: the default ANSI escapes would land in the systemd journal. +Logger.format = LogFormat.PLAIN; +Logger.destinations["default"]!.write = text => process.stderr.write(text + "\n"); + +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..48687af --- /dev/null +++ b/src/fabrics.ts @@ -0,0 +1,111 @@ +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"; + +/** + * Read-only view of the device's Operational Credentials fabric table. + * + * 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 flag likely + * orphans. Removal is deliberately not offered: this controller does not use + * its admin rights against other fabrics' entries. Clean stale entries up + * from the device's primary ecosystem (e.g. Apple Home) or by factory reset; + * our own live entry is removed with "decommission". + */ + +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 them from the device's primary ecosystem (e.g. Apple Home: device settings,`); + lines.push(`Connected Services) or by factory-resetting and re-pairing the device.`); + } + 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); +} diff --git a/src/inspection.ts b/src/inspection.ts new file mode 100644 index 0000000..3c0ed57 --- /dev/null +++ b/src/inspection.ts @@ -0,0 +1,267 @@ +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")}`; +} + +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/json.ts b/src/json.ts new file mode 100644 index 0000000..53ab378 --- /dev/null +++ b/src/json.ts @@ -0,0 +1,8 @@ +/** + * JSON serialization for command output. 64-bit values (node IDs, Matter + * microsecond timestamps) are bigints internally and render as decimal + * strings, never as lossy JavaScript numbers. + */ +export function toJsonString(value: unknown): string { + return JSON.stringify(value, (_key, inner) => (typeof inner === "bigint" ? inner.toString() : inner), 2); +} diff --git a/src/logging.ts b/src/logging.ts new file mode 100644 index 0000000..e7b26f2 --- /dev/null +++ b/src/logging.ts @@ -0,0 +1,69 @@ +/** + * 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}`; + // All log levels go to stderr: stdout is reserved for command output so + // that --json (and any piped human output) is never interleaved with logs. + process.stderr.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/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; + }); diff --git a/src/sqlite-lazy.ts b/src/sqlite-lazy.ts new file mode 100644 index 0000000..587f662 --- /dev/null +++ b/src/sqlite-lazy.ts @@ -0,0 +1,17 @@ +/** + * Lazy stand-in for node:sqlite, used only by the esbuild bundle (via + * --alias:node:sqlite=./dist/sqlite-lazy.js). + * + * In an ESM bundle, external imports are hoisted and evaluated before any + * bundled code runs, so a direct node:sqlite import would fire Node's + * ExperimentalWarning before the filter in warnings.ts is installed. This + * module loads node:sqlite with require() at module-initialization time + * instead, which the bundle defers until after the filter is active. + */ +import { createRequire } from "node:module"; + +const nodeRequire = createRequire(import.meta.url); +const sqlite = nodeRequire("node:sqlite") as typeof import("node:sqlite"); + +export const DatabaseSync = sqlite.DatabaseSync; +export const StatementSync = sqlite.StatementSync; 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..64ff832 --- /dev/null +++ b/src/sync.ts @@ -0,0 +1,181 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +/** + * Matter epoch conversion and clock comparison. + * + * 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. + */ + +export const MATTER_EPOCH_UNIX_SECONDS = 946_684_800n; + +/** + * The exact distance between the Unix and Matter epochs in microseconds; + * equivalently, Matter epoch zero (2000-01-01T00:00:00Z) expressed as + * Unix-epoch microseconds. + * + * Epoch handling has two layers. On the wire, all epoch-us fields are + * Matter-epoch. At the matter.js API boundary, however, TlvEpochUs values + * are Unix-epoch microseconds in both directions: the library performs the + * wire conversion itself (and rejects pre-converted values). All values + * passed to or read from matter.js are therefore Unix-epoch microseconds. + */ +export const MATTER_EPOCH_AS_UNIX_MICROS = MATTER_EPOCH_UNIX_SECONDS * 1_000_000n; + +/** + * A device is treated as epoch-confused when its clock delta lands within + * this window of the exact Unix/Matter epoch distance: firmware that encodes + * Unix-epoch values on the wire reads as ~30 years ahead after decoding. A + * week comfortably covers any real drift (a wrong clock is stale by hours, + * not decades) while remaining astronomically far from every honest delta. + */ +const EPOCH_SHIFT_DETECTION_WINDOW_MICROS = 7n * 86_400n * 1_000_000n; + +/** 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(); +} + +/** Current time as Unix-epoch microseconds (matter.js's TlvEpochUs convention). */ +export function currentUnixUtcMicroseconds(now: () => number = Date.now): bigint { + return BigInt(now()) * 1_000n; +} + +/** Renders Unix-epoch microseconds as an ISO-8601 UTC instant (millisecond precision). */ +export function formatUnixMicros(unixMicroseconds: bigint): string { + return new Date(Number(unixMicroseconds / 1_000n)).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 (epoch-corrected for off-spec + * devices, i.e. the wall clock the device actually believes), 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; + /** True when the device reports Unix-epoch microseconds instead of Matter-epoch. */ + epochShifted: boolean; + /** + * The delta with any detected epoch shift removed: the device's real clock + * error. Equals deltaMicroseconds for spec-compliant devices. + */ + effectiveDeltaMicroseconds: 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. + * Both arguments are Unix-epoch microseconds, matter.js's API convention. + * + * Detects epoch-confused devices: firmware that encodes Unix-epoch values + * on the wire decodes to a delta of exactly the Unix/Matter epoch distance, + * which is folded out so such a device's real clock error stays visible + * instead of reading as 30 years ahead. + */ +export function compareDeviceClock(deviceMicros: bigint | null, hostMicros: bigint): ClockComparison { + const hostTime = formatUnixMicros(hostMicros); + if (deviceMicros === null) { + return { + deviceTime: null, + hostTime, + deltaMicroseconds: null, + epochShifted: false, + effectiveDeltaMicroseconds: null, + description: "device clock was unset", + }; + } + const delta = deviceMicros - hostMicros; + const shiftError = delta - MATTER_EPOCH_AS_UNIX_MICROS; + const epochShifted = (shiftError < 0n ? -shiftError : shiftError) <= EPOCH_SHIFT_DETECTION_WINDOW_MICROS; + const effective = epochShifted ? shiftError : delta; + const description = epochShifted + ? `device encodes Unix-epoch time on the wire (off-spec); corrected, its clock was ${describeDelta(effective)}` + : `device clock was ${describeDelta(delta)}`; + return { + deviceTime: formatUnixMicros(epochShifted ? deviceMicros - MATTER_EPOCH_AS_UNIX_MICROS : deviceMicros), + hostTime, + deltaMicroseconds: delta, + epochShifted, + effectiveDeltaMicroseconds: effective, + description, + }; +} + +/** "within 1s of host time (412ms behind)" / "1m 23s ahead" for a signed delta. */ +function describeDelta(delta: bigint): string { + const magnitude = delta < 0n ? -delta : delta; + const direction = delta < 0n ? "behind" : "ahead"; + return magnitude < 1_000_000n + ? `within 1s of host time (${formatDurationMicros(magnitude)} ${direction})` + : `${formatDurationMicros(magnitude)} ${direction}`; +} + +/** 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/timesync.ts b/src/timesync.ts new file mode 100644 index 0000000..b3a7aea --- /dev/null +++ b/src/timesync.ts @@ -0,0 +1,268 @@ +import { TimeSynchronization } from "@matter/main/clusters/time-synchronization"; +import type { PairedNode } from "@project-chip/matter.js/device"; +import type { TimeSyncSummary } from "./inspection.js"; +import { Log } from "./logging.js"; +import { + compareDeviceClock, + currentUnixUtcMicroseconds, + formatDurationMicros, + formatUnixMicros, + type ClockComparison, +} from "./sync.js"; +import { + buildDstOffsetList, + buildTimeZoneList, + formatUtcOffset, + type MatterDstOffsetEntry, + type MatterTimeZoneEntry, +} from "./timezone.js"; + +const log = new Log("sync"); + +/** + * One clock synchronization against a live device, driven entirely by the + * capabilities the device itself reports: SetUTCTime always (it is the + * mandatory command), SetTimeZone/SetDSTOffset only when the TimeZone + * feature and the commands are present, and the DST list bounded by the + * device's DSTOffsetListMaxSize. Devices without optional features degrade + * gracefully; a missing Time Synchronization cluster is skipped with a + * warning. + */ + +/** What the device's Time Synchronization cluster supports. */ +export interface TimeSyncCapabilities { + timeZoneFeature: boolean; + supportsSetUtcTime: boolean; + supportsSetTimeZone: boolean; + supportsSetDstOffset: boolean; + timeZoneListMaxSize: number; + dstOffsetListMaxSize: number; +} + +/** Derives capabilities from an inspection summary (live or captured fixture). */ +export function capabilitiesFromTimeSync(summary: TimeSyncSummary): TimeSyncCapabilities { + const attributeValue = (name: string): unknown => + summary.attributes.find(attribute => attribute.name === name)?.value; + const sizeAttribute = (name: string): number => { + const value = attributeValue(name); + // The spec minimum for both list sizes is 1; assume it when unreported. + return typeof value === "number" && Number.isInteger(value) && value >= 1 ? value : 1; + }; + return { + timeZoneFeature: summary.supportedFeatures["timeZone"] === true, + supportsSetUtcTime: summary.supportedCommands["setUtcTime"] === true, + supportsSetTimeZone: summary.supportedCommands["setTimeZone"] === true, + supportsSetDstOffset: summary.supportedCommands["setDstOffset"] === true, + timeZoneListMaxSize: sizeAttribute("timeZoneListMaxSize"), + dstOffsetListMaxSize: sizeAttribute("dstOffsetListMaxSize"), + }; +} + +/** The writes one sync run will perform, decided before touching the device. */ +export interface TimeSyncPlan { + setUtcTime: boolean; + timeZone: MatterTimeZoneEntry[] | null; + dstOffsets: MatterDstOffsetEntry[] | null; +} + +export function planTimeSync( + capabilities: TimeSyncCapabilities, + timezone: string, + from: Date = new Date(), +): TimeSyncPlan { + const timeZoneSupported = capabilities.timeZoneFeature && capabilities.supportsSetTimeZone; + return { + setUtcTime: capabilities.supportsSetUtcTime, + timeZone: timeZoneSupported ? buildTimeZoneList(timezone, from) : null, + dstOffsets: + timeZoneSupported && capabilities.supportsSetDstOffset + ? buildDstOffsetList(timezone, capabilities.dstOffsetListMaxSize, from) + : null, + }; +} + +/** Read-back verification after the writes. */ +export interface SyncVerification { + /** Device utcTime after sync as ISO-8601 (epoch-corrected), or null if unreadable. */ + utcTimeAfter: string | null; + /** Effective clock error after sync in microseconds (epoch shift removed). */ + deltaAfterMicroseconds: bigint | null; + /** True when the device reports Unix-epoch instead of Matter-epoch time. */ + epochShifted: boolean; + verified: boolean; +} + +const VERIFY_TOLERANCE_MICROS = 5_000_000n; + +export interface SyncNodeResult { + nodeId: string; + success: boolean; + /** Set (with success=false) when the device has no Time Synchronization cluster. */ + skipped: boolean; + error: string | null; + clockBefore: ClockComparison | null; + /** The UTC instant written, ISO-8601 and Unix-epoch microseconds (matter.js convention). */ + utcTimeWritten: string | null; + utcTimeWrittenUnixMicroseconds: bigint | null; + timeZoneWritten: MatterTimeZoneEntry[] | null; + dstOffsetsWritten: MatterDstOffsetEntry[] | null; + verification: SyncVerification | null; +} + +function emptyResult(nodeId: bigint): SyncNodeResult { + return { + nodeId: nodeId.toString(), + success: false, + skipped: false, + error: null, + clockBefore: null, + utcTimeWritten: null, + utcTimeWrittenUnixMicroseconds: null, + timeZoneWritten: null, + dstOffsetsWritten: null, + verification: null, + }; +} + +/** Coerces a utcTime attribute value (number | bigint | null | undefined) to bigint | null. */ +function utcTimeToBigint(value: number | bigint | null | undefined): bigint | null { + if (value === null || value === undefined) return null; + return BigInt(value); +} + +/** + * Synchronizes one connected node's clock. Throws only on programming + * errors; device and interaction failures are reported in the result so the + * caller can continue with other nodes. + */ +export async function syncNode(node: PairedNode, timezone: string): Promise<SyncNodeResult> { + const nodeId = BigInt(node.nodeId); + const result = emptyResult(nodeId); + + const client = node.getRootClusterClient(TimeSynchronization.Complete); + if (client === undefined) { + result.skipped = true; + result.error = "no Time Synchronization cluster on the root endpoint"; + log.warn(`Node ${nodeId}: ${result.error}; skipping`); + return result; + } + + try { + const supportedCommands = { + setUtcTime: client.isCommandSupportedByName("setUtcTime"), + setTimeZone: client.isCommandSupportedByName("setTimeZone"), + setDstOffset: client.isCommandSupportedByName("setDstOffset"), + }; + const capabilities: TimeSyncCapabilities = { + timeZoneFeature: client.supportedFeatures["timeZone"] === true, + supportsSetUtcTime: supportedCommands.setUtcTime, + supportsSetTimeZone: supportedCommands.setTimeZone, + supportsSetDstOffset: supportedCommands.setDstOffset, + timeZoneListMaxSize: (await client.attributes.timeZoneListMaxSize.get()) ?? 1, + dstOffsetListMaxSize: (await client.attributes.dstOffsetListMaxSize.get()) ?? 1, + }; + if (!capabilities.supportsSetUtcTime) { + throw new Error("device does not accept SetUTCTime; its clock cannot be synchronized"); + } + const plan = planTimeSync(capabilities, timezone); + + // Read the device clock live (not from cache) for the before/after report. + const deviceBefore = utcTimeToBigint(await client.attributes.utcTime.get(true)); + result.clockBefore = compareDeviceClock(deviceBefore, currentUnixUtcMicroseconds()); + log.info(`Node ${nodeId}: ${result.clockBefore.description}`); + + // Write UTC time last-moment-fresh, as Unix-epoch microseconds: + // matter.js's TlvEpochUs converts to the Matter epoch on the wire. + // Granularity and time source matter for acceptance: a device that + // already has good time may reject (TimeNotAccepted) a claim no better + // than what it holds, so state the strongest truthful claim: the host + // clock is NTP-disciplined (NonMatterSntp) and the timestamp is + // microsecond-precise at send time. + const utcTime = currentUnixUtcMicroseconds(); + await client.commands.setUtcTime({ + utcTime, + granularity: TimeSynchronization.Granularity.MicrosecondsGranularity, + timeSource: TimeSynchronization.TimeSource.NonMatterSntp, + }); + result.utcTimeWritten = formatUnixMicros(utcTime); + result.utcTimeWrittenUnixMicroseconds = utcTime; + log.info(`Node ${nodeId}: SetUTCTime ${result.utcTimeWritten}`); + + if (plan.timeZone !== null) { + const response = await client.commands.setTimeZone({ timeZone: plan.timeZone }); + result.timeZoneWritten = plan.timeZone; + log.info( + `Node ${nodeId}: SetTimeZone ${plan.timeZone[0]!.name} ` + + `(standard offset ${formatUtcOffset(plan.timeZone[0]!.offset)})`, + ); + // dstOffsetRequired=false means the device knows this zone's DST + // rules itself; pushing our list anyway would be redundant. + const dstRequired = response?.dstOffsetRequired !== false; + if (plan.dstOffsets !== null && dstRequired) { + await client.commands.setDstOffset({ dstOffset: plan.dstOffsets }); + result.dstOffsetsWritten = plan.dstOffsets; + log.info(`Node ${nodeId}: SetDSTOffset with ${plan.dstOffsets.length} entries`); + } + } + + // Read back and verify against the host clock at read time. + const deviceAfter = utcTimeToBigint(await client.attributes.utcTime.get(true)); + const after = compareDeviceClock(deviceAfter, currentUnixUtcMicroseconds()); + const effective = after.effectiveDeltaMicroseconds; + const verified = + effective !== null && (effective < 0n ? -effective : effective) <= VERIFY_TOLERANCE_MICROS; + result.verification = { + utcTimeAfter: after.deviceTime, + deltaAfterMicroseconds: effective, + epochShifted: after.epochShifted, + verified, + }; + if (!verified) { + throw new Error( + `read-back verification failed: device utcTime is ${after.deviceTime ?? "unset"} ` + + `(${after.description})`, + ); + } + + result.success = true; + return result; + } catch (cause) { + result.error = cause instanceof Error ? cause.message : String(cause); + return result; + } +} + +export function formatSyncResult(result: SyncNodeResult): string { + const lines: string[] = [`Node ${result.nodeId}:`]; + if (result.skipped) { + lines.push(` Skipped: ${result.error}`); + return lines.join("\n"); + } + if (result.clockBefore !== null) { + lines.push(` Device clock: ${result.clockBefore.deviceTime ?? "unset"}`); + lines.push(` Assessment: ${result.clockBefore.description}`); + } + if (result.utcTimeWritten !== null) { + lines.push(` Time written: ${result.utcTimeWritten}`); + } + if (result.timeZoneWritten !== null) { + const zone = result.timeZoneWritten[0]!; + lines.push(` Time zone: ${zone.name} (standard offset ${formatUtcOffset(zone.offset)})`); + } + if (result.dstOffsetsWritten !== null && result.dstOffsetsWritten.length > 0) { + const last = result.dstOffsetsWritten[result.dstOffsetsWritten.length - 1]!; + const horizon = + last.validUntil === null ? "indefinitely" : `through ${formatUnixMicros(last.validUntil)}`; + lines.push(` DST offsets: ${result.dstOffsetsWritten.length} entries, valid ${horizon}`); + } + if (result.verification !== null && result.verification.deltaAfterMicroseconds !== null) { + const delta = result.verification.deltaAfterMicroseconds; + const magnitude = delta < 0n ? -delta : delta; + lines.push( + ` Verification: device clock within ${formatDurationMicros(magnitude)} of host after sync` + + `${result.verification.epochShifted ? " (device reports Unix-epoch time; corrected)" : ""}`, + ); + } + lines.push(result.success ? " Result: OK" : ` Result: FAILED (${result.error})`); + return lines.join("\n"); +} diff --git a/src/timezone.ts b/src/timezone.ts new file mode 100644 index 0000000..062a811 --- /dev/null +++ b/src/timezone.ts @@ -0,0 +1,190 @@ +/** + * IANA time-zone helpers: validation and offset introspection (config + * validation, status output) plus Matter TimeZone/DSTOffset structure + * generation for the sync command. + */ + +import { MATTER_EPOCH_AS_UNIX_MICROS } from "./sync.js"; + +/** 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)), + }; +} + +/** + * Matter TimeZoneStruct and DSTOffsetStruct as passed to matter.js. All + * timestamps here are Unix-epoch microseconds: matter.js's TlvEpochUs + * converts to Matter-epoch on the wire and rejects pre-converted values. + * "Valid since the beginning of time" is therefore Matter epoch zero + * expressed in Unix microseconds, not 0. + */ +export interface MatterTimeZoneEntry { + /** Standard (non-DST) UTC offset in seconds. */ + offset: number; + /** Unix-epoch microseconds at which the entry takes effect. */ + validAt: bigint; + name?: string; +} + +/** One DST period; offset is added on top of the TimeZone offset. */ +export interface MatterDstOffsetEntry { + offset: number; + validStarting: bigint; + /** Unix-epoch microseconds; null = valid until further notice (last entry only). */ + validUntil: bigint | null; +} + +/** + * The zone's standard (non-DST) UTC offset in seconds: the smaller of the + * mid-January and mid-July offsets of the year. DST increases the offset in + * every zone as reported by ICU (including Europe/Dublin, which ICU models + * as +00:00 standard / +01:00 summer despite IANA's negative-SAVE encoding). + */ +export function standardOffsetSeconds(timezone: string, at: Date = new Date()): number { + const year = at.getUTCFullYear(); + const january = utcOffsetSeconds(timezone, new Date(Date.UTC(year, 0, 15))); + const july = utcOffsetSeconds(timezone, new Date(Date.UTC(year, 6, 15))); + return Math.min(january, july); +} + +/** + * The TimeZone attribute list for SetTimeZone: a single entry carrying the + * zone's standard offset and IANA name, valid from the beginning of time. + */ +export function buildTimeZoneList(timezone: string, at: Date = new Date()): MatterTimeZoneEntry[] { + return [ + { + offset: standardOffsetSeconds(timezone, at), + validAt: MATTER_EPOCH_AS_UNIX_MICROS, + name: timezone.slice(0, 64), + }, + ]; +} + +/** + * The DSTOffset list for SetDstOffset: the DST state in effect at `from` + * followed by upcoming transitions, at most `maxEntries` entries (the + * device's DSTOffsetListMaxSize; spec minimum 1). Entries carry concrete + * validUntil bounds where a next transition is known, so a device left + * unrefreshed falls back to standard time rather than trusting stale DST; + * the periodic sync refreshes the list long before it expires. Zones + * without transitions yield a single open-ended zero entry. + */ +export function buildDstOffsetList( + timezone: string, + maxEntries: number, + from: Date = new Date(), +): MatterDstOffsetEntry[] { + const standard = standardOffsetSeconds(timezone, from); + const entries: MatterDstOffsetEntry[] = []; + let cursor = from; + let currentDst = utcOffsetSeconds(timezone, cursor) - standard; + let validStarting = MATTER_EPOCH_AS_UNIX_MICROS; + const limit = Math.max(1, maxEntries); + while (entries.length < limit) { + const transition = nextOffsetTransition(timezone, cursor); + if (transition === null) { + entries.push({ offset: currentDst, validStarting, validUntil: null }); + break; + } + const untilMicros = BigInt(transition.at.getTime()) * 1_000n; + entries.push({ offset: currentDst, validStarting, validUntil: untilMicros }); + validStarting = untilMicros; + currentDst = transition.offsetAfterSeconds - standard; + cursor = new Date(transition.at.getTime() + 1000); + } + return entries; +} diff --git a/src/warnings.ts b/src/warnings.ts new file mode 100644 index 0000000..8bf2ca5 --- /dev/null +++ b/src/warnings.ts @@ -0,0 +1,19 @@ +/** + * matter.js's storage backend imports node:sqlite, which Node still marks + * experimental and announces with a process-level ExperimentalWarning on + * every run. That one-line warning is pure noise to an operator, so exactly + * that warning is dropped here; every other warning passes through. + * + * This module must be imported before anything that (transitively) imports + * matter.js, because the warning fires when node:sqlite is first loaded. + */ + +const originalEmitWarning = process.emitWarning.bind(process); + +process.emitWarning = ((warning: string | Error, ...rest: unknown[]): void => { + const text = typeof warning === "string" ? warning : warning.message; + if (text.includes("SQLite is an experimental feature")) return; + (originalEmitWarning as (warning: string | Error, ...rest: unknown[]) => void)(warning, ...rest); +}) as typeof process.emitWarning; + +export {}; |
