From 0e536daebe4cceb27eaccdbc4fc8ca066dff71b9 Mon Sep 17 00:00:00 2001 From: Luke Hoersten Date: Sun, 26 Jul 2026 05:38:33 -0500 Subject: Implement mattertimesync: one-shot Matter time synchronization CLI A standalone CLI Matter controller that joins Matter devices' existing setups as a secondary administrator (multi-admin) and sets their clocks via the standard Time Synchronization cluster. Built on matter.js 0.17.6 using the CommissioningController API. One-shot runs from a systemd timer, no daemon: being a secondary controller is fabric membership, not a running process, so each invocation loads the persisted keys, discovers the device via operational DNS-SD, opens a fresh CASE session, does its work, and exits. - TypeScript scaffold: strict tsc, oxlint, prettier, vitest (66 tests: Matter epoch conversion, config validation, IANA timezone offsets and exact DST transition search, atomic state writes, pairing-code parsing, clock-delta reporting, capability planning against the captured device fixture) - Validated JSON configuration with bigint-safe values; unknown fields rejected loudly - Persistent controller fabric with the same identity across restarts. The fabric doubles as the device registry: every node commissioned onto it is kept in sync, and membership changes only through commission and decommission, so no separate device list can drift - On-network multi-admin commissioning from a manual or QR pairing code; any number of devices, one per-device pairing code each. A device already on the fabric rejects re-commissioning via fabric conflict - sync, validated against real hardware (IKEA ALPSTUGA air quality monitor over Thread, commissioned alongside Apple Home): refuses to run unless the host is NTP-synchronized, then per node reads utcTime, reports the correction with direction ("device clock was 1m 23s behind", handling unset clocks in exact bigint microseconds), writes SetUTCTime, SetTimeZone (standard offset plus IANA name), and SetDSTOffset (transition list bounded by the device's capacity), and verifies by reading the clock back. Capability-driven throughout: UTC-only devices degrade gracefully, cluster-less devices are skipped with a warning. Devices whose firmware encodes Unix-epoch time on the wire are detected by the exact 946684800s shift and reported corrected. Note matter.js's TlvEpochUs API convention: values are Unix-epoch microseconds at the API boundary; the library converts to Matter epoch on the wire - Commands: nodes, inspect (human/JSON; also reads the fabric table fabric-unfiltered, matching our own entry by fabric ID plus root public key and flagging likely-stale orphans; strictly read-only, since this tool never uses its admin rights against other fabrics' entries), status, and decommission (each device drops our fabric while staying paired to its primary ecosystem; targets all devices unless --node narrows it) - Logs (service and matter.js library, plain format) go to stderr; stdout carries only command output, so --json (available on nodes, inspect, sync, status, fabrics) is always clean parseable JSON with 64-bit values as decimal strings. Node's node:sqlite ExperimentalWarning is filtered before matter.js loads - systemd oneshot service (dedicated non-root user, hardening) plus hourly timer with randomized delay - Deployment as a single esbuild bundle (mattertimesync.mjs, ~4.5 MB): all runtime deps are pure JavaScript, so the target needs only Node 20+, with no npm, registry access, or build toolchain. The Bun sqlite driver stays external behind its runtime guard and node:sqlite is aliased to a lazy shim so the bundle runs under plain Node. npm pack remains an alternative install path --- src/cli.ts | 9 ++ src/commissioning.ts | 101 ++++++++++++++ src/config.ts | 99 +++++++++++++ src/controller.ts | 102 ++++++++++++++ src/device.ts | 72 ++++++++++ src/fabrics.ts | 111 +++++++++++++++ src/inspection.ts | 267 ++++++++++++++++++++++++++++++++++++ src/json.ts | 8 ++ src/logging.ts | 69 ++++++++++ src/main.ts | 381 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/sqlite-lazy.ts | 17 +++ src/state.ts | 90 ++++++++++++ src/sync.ts | 181 ++++++++++++++++++++++++ src/timesync.ts | 268 ++++++++++++++++++++++++++++++++++++ src/timezone.ts | 190 +++++++++++++++++++++++++ src/warnings.ts | 19 +++ 16 files changed, 1984 insertions(+) create mode 100644 src/cli.ts create mode 100644 src/commissioning.ts create mode 100644 src/config.ts create mode 100644 src/controller.ts create mode 100644 src/device.ts create mode 100644 src/fabrics.ts create mode 100644 src/inspection.ts create mode 100644 src/json.ts create mode 100644 src/logging.ts create mode 100644 src/main.ts create mode 100644 src/sqlite-lazy.ts create mode 100644 src/state.ts create mode 100644 src/sync.ts create mode 100644 src/timesync.ts create mode 100644 src/timezone.ts create mode 100644 src/warnings.ts (limited to 'src') 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 { + 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; + + // 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 { + 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 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 { + 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 { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_, 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 = { + 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 { + 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; +} + +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; + featureMap: unknown; + supportedCommands: Record; + 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 { + 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(); + 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, + })); + + 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 { + 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 = ``; + } + 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, + 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 ""; + 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 = { debug: 0, info: 1, warn: 2, error: 3 }; +const LEVEL_LABEL: Record = { + 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 ] [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 ] [--json] Connect to a device and dump its live endpoints, clusters, + time-sync capabilities, and fabric table + +Commands with side effects: + commission Join a device as an additional Matter admin + (writes the device's fabric table and local storage) + sync [--node ] [--json] Set each device's clock: UTC time, time zone, DST offsets + decommission [--node ] 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 Configuration file (default: ${DEFAULT_CONFIG_PATH}) + --json Machine-readable output (64-bit values as decimal strings) + --help Show this help +`; + +async function main(): Promise { + 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 { + const pairingCode = codeArgument?.trim(); + if (pairingCode === undefined || pairingCode.length === 0) { + process.stderr.write( + "Usage: commission (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 { + 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 { + 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 { + // 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 { + 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 { + 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; +} + +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; + const nodes: Record = {}; + 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; + 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): 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 { + 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 { + 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 {}; -- cgit v1.2.3