src.nth.io/

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/cli.ts9
-rw-r--r--src/commissioning.ts101
-rw-r--r--src/config.ts99
-rw-r--r--src/controller.ts102
-rw-r--r--src/device.ts72
-rw-r--r--src/fabrics.ts111
-rw-r--r--src/inspection.ts267
-rw-r--r--src/json.ts8
-rw-r--r--src/logging.ts69
-rw-r--r--src/main.ts381
-rw-r--r--src/sqlite-lazy.ts17
-rw-r--r--src/state.ts90
-rw-r--r--src/sync.ts181
-rw-r--r--src/timesync.ts268
-rw-r--r--src/timezone.ts190
-rw-r--r--src/warnings.ts19
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 {};