diff options
| author | Luke Hoersten <[email protected]> | 2026-07-26 05:38:33 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-07-26 21:13:51 -0500 |
| commit | 0e536daebe4cceb27eaccdbc4fc8ca066dff71b9 (patch) | |
| tree | c96198815a3928a69ef06f5f0e4d1882317a6276 /test | |
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
Diffstat (limited to 'test')
| -rw-r--r-- | test/commissioning.test.ts | 33 | ||||
| -rw-r--r-- | test/config.test.ts | 73 | ||||
| -rw-r--r-- | test/fabrics.test.ts | 46 | ||||
| -rw-r--r-- | test/fixtures/alpstuga-inspect.json | 504 | ||||
| -rw-r--r-- | test/state.test.ts | 103 | ||||
| -rw-r--r-- | test/sync.test.ts | 133 | ||||
| -rw-r--r-- | test/timesync.test.ts | 79 | ||||
| -rw-r--r-- | test/timezone.test.ts | 139 |
8 files changed, 1110 insertions, 0 deletions
diff --git a/test/commissioning.test.ts b/test/commissioning.test.ts new file mode 100644 index 0000000..676a5e1 --- /dev/null +++ b/test/commissioning.test.ts @@ -0,0 +1,33 @@ +import { ManualPairingCodeCodec } from "@matter/main/types"; +import { describe, expect, it } from "vitest"; +import { parsePairingCode } from "../src/commissioning.js"; + +describe("parsePairingCode", () => { + const validCode = ManualPairingCodeCodec.encode({ discriminator: 3840, passcode: 20202021 }); + + it("parses a valid 11-digit manual code", () => { + const parsed = parsePairingCode(validCode); + expect(parsed.passcode).toBe(20202021); + expect(parsed.shortDiscriminator).toBe(3840 >> 8); + }); + + it("accepts hyphens and whitespace", () => { + const withHyphens = `${validCode.slice(0, 4)}-${validCode.slice(4, 7)}-${validCode.slice(7)}`; + expect(parsePairingCode(` ${withHyphens} `).passcode).toBe(20202021); + }); + + it("rejects empty input", () => { + expect(() => parsePairingCode("")).toThrow(/empty/); + expect(() => parsePairingCode(" ")).toThrow(/empty/); + }); + + it("rejects codes of the wrong length", () => { + expect(() => parsePairingCode("12345")).toThrow(/11 or 21 digits/); + expect(() => parsePairingCode("123456789012")).toThrow(/11 or 21 digits/); + }); + + it("rejects codes with an invalid checksum", () => { + const corrupted = validCode.slice(0, -1) + ((Number(validCode.at(-1)) + 1) % 10).toString(); + expect(() => parsePairingCode(corrupted)).toThrow(); + }); +}); diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..1caecd8 --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { ConfigError, parseNodeId, validateConfig } from "../src/config.js"; + +const minimal = { storagePath: "/var/lib/mattertimesync" }; + +describe("validateConfig", () => { + it("accepts a minimal configuration and applies defaults", () => { + const config = validateConfig(minimal); + expect(config.storagePath).toBe(minimal.storagePath); + expect(config.timezone).toBe("America/Chicago"); + expect(config.logLevel).toBe("info"); + }); + + it("accepts a full configuration", () => { + const config = validateConfig({ + ...minimal, + timezone: "Europe/Berlin", + logLevel: "debug", + }); + expect(config.timezone).toBe("Europe/Berlin"); + expect(config.logLevel).toBe("debug"); + }); + + it("rejects non-object documents", () => { + expect(() => validateConfig([])).toThrow(ConfigError); + expect(() => validateConfig("x")).toThrow(ConfigError); + expect(() => validateConfig(null)).toThrow(ConfigError); + }); + + it("requires storagePath", () => { + expect(() => validateConfig({})).toThrow(/storagePath/); + expect(() => validateConfig({ storagePath: "" })).toThrow(/storagePath/); + }); + + it("rejects unknown fields", () => { + expect(() => validateConfig({ ...minimal, unexpected: 1 })).toThrow(/unknown field "unexpected"/); + // Fields from abandoned designs are rejected, not ignored. + expect(() => validateConfig({ ...minimal, syncIntervalHours: 24 })).toThrow(/unknown field/); + expect(() => validateConfig({ ...minimal, syncOnReconnect: true })).toThrow(/unknown field/); + expect(() => validateConfig({ ...minimal, nodeId: "1" })).toThrow(/unknown field/); + }); + + it("rejects invalid time zones", () => { + expect(() => validateConfig({ ...minimal, timezone: "Central Time" })).toThrow(/timezone/); + expect(() => validateConfig({ ...minimal, timezone: "" })).toThrow(/timezone/); + expect(() => validateConfig({ ...minimal, timezone: 5 })).toThrow(/timezone/); + }); + + it("rejects invalid log levels", () => { + expect(() => validateConfig({ ...minimal, logLevel: "verbose" })).toThrow(/logLevel/); + }); +}); + +describe("parseNodeId", () => { + it("parses decimal strings without precision loss", () => { + expect(parseNodeId("1")).toBe(1n); + expect(parseNodeId("9007199254740993")).toBe(9007199254740993n); + expect(parseNodeId("18446744073709551615")).toBe(18446744073709551615n); + }); + + it("accepts small safe integers", () => { + expect(parseNodeId(42)).toBe(42n); + }); + + it("rejects unsafe or invalid values", () => { + expect(() => parseNodeId("18446744073709551616")).toThrow(ConfigError); // 2^64 + expect(() => parseNodeId("-1")).toThrow(ConfigError); + expect(() => parseNodeId(1.5)).toThrow(ConfigError); + expect(() => parseNodeId("0x10")).toThrow(ConfigError); + expect(() => parseNodeId(Number.MAX_SAFE_INTEGER + 2)).toThrow(ConfigError); + expect(() => parseNodeId(null)).toThrow(ConfigError); + }); +}); diff --git a/test/fabrics.test.ts b/test/fabrics.test.ts new file mode 100644 index 0000000..b3cd18e --- /dev/null +++ b/test/fabrics.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { findLikelyStrays, formatFabricTable, type FabricTable } from "../src/fabrics.js"; + +const table: FabricTable = { + supportedFabrics: 5, + commissionedFabrics: 4, + entries: [ + { fabricIndex: 1, fabricId: 100n, nodeId: 10n, vendorId: 0x1349, label: "Apple Home", isOurs: false }, + { fabricIndex: 2, fabricId: 200n, nodeId: 20n, vendorId: 0xfff1, label: "mattertimesync", isOurs: true }, + { fabricIndex: 3, fabricId: 300n, nodeId: 30n, vendorId: 0xfff1, label: "mattertimesync", isOurs: false }, + { fabricIndex: 4, fabricId: 400n, nodeId: 40n, vendorId: 0xfff1, label: "mattertimesync", isOurs: false }, + ], +}; + +describe("findLikelyStrays", () => { + it("flags entries with our label that are not our identity", () => { + expect(findLikelyStrays(table).map(entry => entry.fabricIndex)).toEqual([3, 4]); + }); + + it("never flags other ecosystems' entries", () => { + const strays = findLikelyStrays(table); + expect(strays.some(entry => entry.label === "Apple Home")).toBe(false); + }); + + it("flags nothing when our identity is not in the table", () => { + const withoutOurs: FabricTable = { + ...table, + entries: table.entries.map(entry => ({ ...entry, isOurs: false })), + }; + expect(findLikelyStrays(withoutOurs)).toEqual([]); + }); +}); + +describe("formatFabricTable", () => { + const output = formatFabricTable(1n, table); + + it("shows slot usage and marks our entry", () => { + expect(output).toContain("4 of 5 slots used"); + expect(output).toContain("[this controller]"); + }); + + it("names known vendors and lists stale indices", () => { + expect(output).toContain("(Apple)"); + expect(output).toContain("index 3, index 4"); + }); +}); diff --git a/test/fixtures/alpstuga-inspect.json b/test/fixtures/alpstuga-inspect.json new file mode 100644 index 0000000..1edfbfc --- /dev/null +++ b/test/fixtures/alpstuga-inspect.json @@ -0,0 +1,504 @@ +{ + "nodeId": "1", + "basicInformation": { + "vendorName": "IKEA of Sweden", + "vendorId": 4476, + "productName": "ALPSTUGA air quality monitor", + "productId": 12289, + "hardwareVersion": "P2.0", + "softwareVersion": "1.0.26", + "uniqueId": "3e4b6ef1d93c64187e026d0c1c1ba319" + }, + "endpoints": [ + { + "endpointId": 0, + "name": "MA-otarequestor", + "deviceTypes": [ + { + "name": "MA-otarequestor", + "code": "0x0012", + "revision": 1 + }, + { + "name": "MA-rootnode", + "code": "0x0016", + "revision": 4 + } + ], + "serverClusters": [ + { + "id": "0x001d", + "name": "Descriptor", + "revision": 2, + "isUnknown": false, + "vendorSpecific": false, + "supportedFeatures": { + "tagList": false + } + }, + { + "id": "0x001f", + "name": "AccessControl", + "revision": 1, + "isUnknown": false, + "vendorSpecific": false, + "supportedFeatures": { + "extension": false, + "managedDevice": false, + "auxiliary": false + } + }, + { + "id": "0x0028", + "name": "BasicInformation", + "revision": 3, + "isUnknown": false, + "vendorSpecific": false, + "supportedFeatures": {} + }, + { + "id": "0x002a", + "name": "OtaSoftwareUpdateRequestor", + "revision": 1, + "isUnknown": false, + "vendorSpecific": false, + "supportedFeatures": {} + }, + { + "id": "0x0030", + "name": "GeneralCommissioning", + "revision": 1, + "isUnknown": false, + "vendorSpecific": false, + "supportedFeatures": { + "termsAndConditions": false, + "networkRecovery": false + } + }, + { + "id": "0x0031", + "name": "NetworkCommissioning", + "revision": 2, + "isUnknown": false, + "vendorSpecific": false, + "supportedFeatures": { + "wiFiNetworkInterface": false, + "threadNetworkInterface": true, + "ethernetNetworkInterface": false + } + }, + { + "id": "0x0033", + "name": "GeneralDiagnostics", + "revision": 2, + "isUnknown": false, + "vendorSpecific": false, + "supportedFeatures": { + "dataModelTest": false + } + }, + { + "id": "0x0035", + "name": "ThreadNetworkDiagnostics", + "revision": 2, + "isUnknown": false, + "vendorSpecific": false, + "supportedFeatures": { + "packetCounts": false, + "errorCounts": false, + "mleCounts": false, + "macCounts": false + } + }, + { + "id": "0x0038", + "name": "TimeSynchronization", + "revision": 2, + "isUnknown": false, + "vendorSpecific": false, + "supportedFeatures": { + "timeZone": true, + "ntpClient": false, + "ntpServer": false, + "timeSyncClient": false + } + }, + { + "id": "0x003c", + "name": "AdministratorCommissioning", + "revision": 1, + "isUnknown": false, + "vendorSpecific": false, + "supportedFeatures": { + "basic": true + } + }, + { + "id": "0x003e", + "name": "OperationalCredentials", + "revision": 1, + "isUnknown": false, + "vendorSpecific": false, + "supportedFeatures": {} + }, + { + "id": "0x003f", + "name": "GroupKeyManagement", + "revision": 2, + "isUnknown": false, + "vendorSpecific": false, + "supportedFeatures": { + "cacheAndSync": false, + "groupcast": false + } + } + ], + "clientClusters": [ + "0x0029" + ] + }, + { + "endpointId": 1, + "name": "MA-airqualitysensor", + "deviceTypes": [ + { + "name": "MA-airqualitysensor", + "code": "0x002c", + "revision": 1 + } + ], + "serverClusters": [ + { + "id": "0x0003", + "name": "Identify", + "revision": 4, + "isUnknown": false, + "vendorSpecific": false, + "supportedFeatures": {} + }, + { + "id": "0x0006", + "name": "OnOff", + "revision": 6, + "isUnknown": false, + "vendorSpecific": false, + "supportedFeatures": { + "lighting": false, + "deadFrontBehavior": true, + "offOnly": false + } + }, + { + "id": "0x001d", + "name": "Descriptor", + "revision": 2, + "isUnknown": false, + "vendorSpecific": false, + "supportedFeatures": { + "tagList": false + } + }, + { + "id": "0x005b", + "name": "AirQuality", + "revision": 1, + "isUnknown": false, + "vendorSpecific": false, + "supportedFeatures": { + "fair": false, + "moderate": false, + "veryPoor": false, + "extremelyPoor": false + } + }, + { + "id": "0x0402", + "name": "TemperatureMeasurement", + "revision": 4, + "isUnknown": false, + "vendorSpecific": false, + "supportedFeatures": {} + }, + { + "id": "0x0405", + "name": "RelativeHumidityMeasurement", + "revision": 3, + "isUnknown": false, + "vendorSpecific": false, + "supportedFeatures": {} + }, + { + "id": "0x040d", + "name": "CarbonDioxideConcentrationMeasurement", + "revision": 3, + "isUnknown": false, + "vendorSpecific": false, + "supportedFeatures": { + "numericMeasurement": true, + "levelIndication": true, + "mediumLevel": false, + "criticalLevel": false, + "peakMeasurement": false, + "averageMeasurement": false + } + }, + { + "id": "0x042a", + "name": "Pm25ConcentrationMeasurement", + "revision": 3, + "isUnknown": false, + "vendorSpecific": false, + "supportedFeatures": { + "numericMeasurement": true, + "levelIndication": true, + "mediumLevel": false, + "criticalLevel": false, + "peakMeasurement": false, + "averageMeasurement": false + } + } + ], + "clientClusters": [] + } + ], + "timeSynchronization": { + "endpointId": 0, + "clusterRevision": 2, + "supportedFeatures": { + "timeZone": true, + "ntpClient": false, + "ntpServer": false, + "timeSyncClient": false + }, + "featureMap": { + "timeZone": true, + "ntpClient": false, + "ntpServer": false, + "timeSyncClient": false + }, + "supportedCommands": { + "setUtcTime": true, + "setTrustedTimeSource": false, + "setTimeZone": true, + "setDstOffset": true, + "setDefaultNtp": false + }, + "attributes": [ + { + "id": "0x0", + "name": "0", + "value": "1785111495000000" + }, + { + "id": "0x1", + "name": "1", + "value": 4 + }, + { + "id": "0x5", + "name": "5", + "value": [ + { + "offset": -21600, + "validAt": "946684800000000", + "name": "America/Chicago" + } + ] + }, + { + "id": "0x6", + "name": "6", + "value": [ + { + "offset": 3600, + "validStarting": "946684800000000", + "validUntil": "1793516400000000" + }, + { + "offset": 0, + "validStarting": "1793516400000000", + "validUntil": "1805011200000000" + } + ] + }, + { + "id": "0x7", + "name": "7", + "value": "1785093495000000" + }, + { + "id": "0x8", + "name": "8", + "value": 2 + }, + { + "id": "0xa", + "name": "10", + "value": 2 + }, + { + "id": "0xb", + "name": "11", + "value": 2 + }, + { + "id": "0xfff8", + "name": "65528", + "value": [ + 3 + ] + }, + { + "id": "0xfff9", + "name": "65529", + "value": [ + 0, + 2, + 4 + ] + }, + { + "id": "0xfffb", + "name": "65531", + "value": [ + 0, + 1, + 5, + 6, + 7, + 8, + 10, + 11, + 65528, + 65529, + 65531, + 65532, + 65533 + ] + }, + { + "id": "0xfffc", + "name": "65532", + "value": { + "timeZone": true, + "ntpClient": false, + "ntpServer": false, + "timeSyncClient": false + } + }, + { + "id": "0xfffd", + "name": "65533", + "value": 2 + }, + { + "id": "0xfffd", + "name": "clusterRevision", + "value": 2 + }, + { + "id": "0xfffc", + "name": "featureMap", + "value": { + "timeZone": true, + "ntpClient": false, + "ntpServer": false, + "timeSyncClient": false + } + }, + { + "id": "0x0", + "name": "utcTime", + "value": "1785111495000000" + }, + { + "id": "0x1", + "name": "granularity", + "value": 4 + }, + { + "id": "0x5", + "name": "timeZone", + "value": [ + { + "offset": -21600, + "validAt": "946684800000000", + "name": "America/Chicago" + } + ] + }, + { + "id": "0x6", + "name": "dstOffset", + "value": [ + { + "offset": 3600, + "validStarting": "946684800000000", + "validUntil": "1793516400000000" + }, + { + "offset": 0, + "validStarting": "1793516400000000", + "validUntil": "1805011200000000" + } + ] + }, + { + "id": "0x7", + "name": "localTime", + "value": "1785093495000000" + }, + { + "id": "0x8", + "name": "timeZoneDatabase", + "value": 2 + }, + { + "id": "0xa", + "name": "timeZoneListMaxSize", + "value": 2 + }, + { + "id": "0xb", + "name": "dstOffsetListMaxSize", + "value": 2 + }, + { + "id": "0xfffb", + "name": "attributeList", + "value": [ + 0, + 1, + 5, + 6, + 7, + 8, + 10, + 11, + 65528, + 65529, + 65531, + 65532, + 65533 + ] + }, + { + "id": "0xfff9", + "name": "acceptedCommandList", + "value": [ + 0, + 2, + 4 + ] + }, + { + "id": "0xfff8", + "name": "generatedCommandList", + "value": [ + 3 + ] + } + ] + }, + "vendorSpecificClusters": [] +} diff --git a/test/state.test.ts b/test/state.test.ts new file mode 100644 index 0000000..a80fcfd --- /dev/null +++ b/test/state.test.ts @@ -0,0 +1,103 @@ +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + EMPTY_NODE_STATE, + nodeState, + readServiceState, + removeNodeState, + serviceStatePath, + updateNodeState, + writeServiceState, +} from "../src/state.js"; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "mattertimesync-state-")); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("service state", () => { + it("returns empty state when the file does not exist", () => { + expect(readServiceState(join(dir, "missing.json"))).toEqual({ nodes: {} }); + }); + + it("round-trips per-node state through disk", () => { + const path = serviceStatePath(dir); + const state = { + nodes: { + "12345678901234567890": { + lastSuccessfulConnection: "2026-07-26T20:00:00Z", + lastSuccessfulSync: "2026-07-26T20:00:02Z", + lastAttemptedSync: "2026-07-26T20:00:01Z", + lastError: null, + }, + "2": { ...EMPTY_NODE_STATE, lastError: "unreachable" }, + }, + }; + writeServiceState(path, state); + expect(readServiceState(path)).toEqual(state); + }); + + it("leaves no temporary file behind after writing", () => { + const path = serviceStatePath(dir); + writeServiceState(path, { nodes: {} }); + expect(readdirSync(dir)).toEqual(["service-state.json"]); + }); + + it("returns empty state for a corrupt file instead of throwing", () => { + const path = join(dir, "corrupt.json"); + writeFileSync(path, "{ this is not json"); + expect(readServiceState(path)).toEqual({ nodes: {} }); + }); + + it("drops malformed node keys and wrongly typed fields", () => { + const path = join(dir, "typed.json"); + writeFileSync( + path, + JSON.stringify({ + nodes: { + "not-a-node-id": { lastError: "x" }, + "7": { lastError: ["array"], lastSuccessfulSync: "2026-07-26T20:00:02Z" }, + }, + }), + ); + expect(readServiceState(path)).toEqual({ + nodes: { "7": { ...EMPTY_NODE_STATE, lastSuccessfulSync: "2026-07-26T20:00:02Z" } }, + }); + }); + + it("merges patches into one node without touching others", () => { + const path = serviceStatePath(dir); + updateNodeState(path, 1n, { lastError: "boom" }); + updateNodeState(path, 2n, { lastSuccessfulSync: "2026-07-26T20:00:02Z" }); + updateNodeState(path, 1n, { lastAttemptedSync: "2026-07-26T20:00:01Z" }); + + const state = readServiceState(path); + expect(nodeState(state, 1n)).toEqual({ + ...EMPTY_NODE_STATE, + lastError: "boom", + lastAttemptedSync: "2026-07-26T20:00:01Z", + }); + expect(nodeState(state, 2n)).toEqual({ + ...EMPTY_NODE_STATE, + lastSuccessfulSync: "2026-07-26T20:00:02Z", + }); + }); + + it("removes a node's entry", () => { + const path = serviceStatePath(dir); + updateNodeState(path, 1n, { lastError: "boom" }); + updateNodeState(path, 2n, {}); + removeNodeState(path, 1n); + + const state = readServiceState(path); + expect(Object.keys(state.nodes)).toEqual(["2"]); + expect(nodeState(state, 1n)).toEqual(EMPTY_NODE_STATE); + }); +}); diff --git a/test/sync.test.ts b/test/sync.test.ts new file mode 100644 index 0000000..d48f286 --- /dev/null +++ b/test/sync.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { + MATTER_EPOCH_UNIX_SECONDS, + compareDeviceClock, + currentMatterUtcMicroseconds, + formatDurationMicros, + formatMatterMicros, + matterMicrosToUnixMillis, + unixMillisToMatterMicros, +} from "../src/sync.js"; + +describe("Matter epoch conversion", () => { + it("maps 2000-01-01T00:00:00Z to 0 microseconds", () => { + const unixMs = BigInt(Date.parse("2000-01-01T00:00:00Z")); + expect(unixMillisToMatterMicros(unixMs)).toBe(0n); + }); + + it("maps 2000-01-01T00:00:01Z to 1,000,000 microseconds", () => { + const unixMs = BigInt(Date.parse("2000-01-01T00:00:01Z")); + expect(unixMillisToMatterMicros(unixMs)).toBe(1_000_000n); + }); + + it("keeps the Unix epoch conversion exact", () => { + expect(unixMillisToMatterMicros(0n)).toBe(-MATTER_EPOCH_UNIX_SECONDS * 1_000_000n); + expect(unixMillisToMatterMicros(0n)).toBe(-946_684_800_000_000n); + }); + + it("keeps current dates exact through bigint round-trips", () => { + const unixMs = BigInt(Date.parse("2026-07-26T20:00:00.123Z")); + const micros = unixMillisToMatterMicros(unixMs); + expect(micros).toBe((unixMs - 946_684_800_000n) * 1_000n); + expect(matterMicrosToUnixMillis(micros)).toBe(unixMs); + }); + + it("stays exact beyond JavaScript number precision", () => { + // Microsecond timestamps pass Number.MAX_SAFE_INTEGER around year 2285; + // conversion must not degrade there, proving no number arithmetic occurs. + const unixMs = BigInt(Date.parse("2300-01-01T00:00:00.001Z")); + const micros = unixMillisToMatterMicros(unixMs); + expect(micros > BigInt(Number.MAX_SAFE_INTEGER)).toBe(true); + expect(matterMicrosToUnixMillis(micros)).toBe(unixMs); + }); + + it("computes the current time via the injected clock", () => { + const nowMs = Date.parse("2026-07-26T20:00:00Z"); + const micros = currentMatterUtcMicroseconds(() => nowMs); + expect(micros).toBe(unixMillisToMatterMicros(BigInt(nowMs))); + }); + + it("formats Matter microseconds as an ISO instant", () => { + expect(formatMatterMicros(0n)).toBe("2000-01-01T00:00:00.000Z"); + expect(formatMatterMicros(1_000_000n)).toBe("2000-01-01T00:00:01.000Z"); + }); +}); + +describe("formatDurationMicros", () => { + it("scales units with magnitude", () => { + expect(formatDurationMicros(0n)).toBe("0us"); + expect(formatDurationMicros(999n)).toBe("999us"); + expect(formatDurationMicros(412_000n)).toBe("412ms"); + expect(formatDurationMicros(3_200_000n)).toBe("3.2s"); + expect(formatDurationMicros(59_000_000n)).toBe("59s"); + expect(formatDurationMicros(125_000_000n)).toBe("2m 5s"); + expect(formatDurationMicros(3_840_000_000n)).toBe("1h 4m"); + // 90,061 seconds = 1d 1h 1m 1s; seconds are dropped once days appear. + expect(formatDurationMicros(90_061_000_000n)).toBe("1d 1h 1m"); + }); + + it("rejects negative durations", () => { + expect(() => formatDurationMicros(-1n)).toThrow(); + }); +}); + +describe("compareDeviceClock", () => { + // compareDeviceClock operates on Unix-epoch microseconds (matter.js convention). + const host = BigInt(Date.parse("2026-07-26T20:00:00.000Z")) * 1_000n; + + it("reports an unset device clock", () => { + const comparison = compareDeviceClock(null, host); + expect(comparison.deviceTime).toBeNull(); + expect(comparison.deltaMicroseconds).toBeNull(); + expect(comparison.hostTime).toBe("2026-07-26T20:00:00.000Z"); + expect(comparison.description).toBe("device clock was unset"); + }); + + it("reports a device clock behind the host", () => { + const comparison = compareDeviceClock(host - 83_000_000n, host); + expect(comparison.deviceTime).toBe("2026-07-26T19:58:37.000Z"); + expect(comparison.deltaMicroseconds).toBe(-83_000_000n); + expect(comparison.description).toBe("device clock was 1m 23s behind"); + }); + + it("reports a device clock ahead of the host", () => { + const comparison = compareDeviceClock(host + 5_500_000n, host); + expect(comparison.deltaMicroseconds).toBe(5_500_000n); + expect(comparison.description).toBe("device clock was 5.5s ahead"); + }); + + it("treats sub-second deltas as in sync but still reports them", () => { + const comparison = compareDeviceClock(host - 412_000n, host); + expect(comparison.description).toBe("device clock was within 1s of host time (412ms behind)"); + }); + + it("detects a device that encodes Unix-epoch time on the wire (wrong epoch bug)", () => { + // Such firmware decodes to exactly 946,684,800s ahead at the matter.js + // API boundary. The raw delta stays exact; the effective delta folds + // the shift out. + const wrong = host + 946_684_800n * 1_000_000n + 1_400_000n; + const comparison = compareDeviceClock(wrong, host); + expect(comparison.epochShifted).toBe(true); + expect(comparison.deltaMicroseconds).toBe(946_684_800_000_000n + 1_400_000n); + expect(comparison.effectiveDeltaMicroseconds).toBe(1_400_000n); + expect(comparison.deviceTime).toBe("2026-07-26T20:00:01.400Z"); + expect(comparison.description).toBe( + "device encodes Unix-epoch time on the wire (off-spec); corrected, its clock was 1.4s ahead", + ); + }); + + it("does not fold out large errors that are not the epoch shift", () => { + // 30 days past the shift window: a genuinely broken clock, not epoch bug. + const wrong = host + 946_684_800n * 1_000_000n + 30n * 86_400n * 1_000_000n; + const comparison = compareDeviceClock(wrong, host); + expect(comparison.epochShifted).toBe(false); + expect(comparison.effectiveDeltaMicroseconds).toBe(comparison.deltaMicroseconds); + expect(comparison.description).toContain("ahead"); + }); + + it("keeps spec-compliant comparisons unshifted", () => { + const comparison = compareDeviceClock(host - 83_000_000n, host); + expect(comparison.epochShifted).toBe(false); + expect(comparison.effectiveDeltaMicroseconds).toBe(-83_000_000n); + }); +}); diff --git a/test/timesync.test.ts b/test/timesync.test.ts new file mode 100644 index 0000000..f51369f --- /dev/null +++ b/test/timesync.test.ts @@ -0,0 +1,79 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { TimeSyncSummary } from "../src/inspection.js"; +import { compareDeviceClock } from "../src/sync.js"; +import { capabilitiesFromTimeSync, planTimeSync } from "../src/timesync.js"; + +/** + * Tests driven by the captured `inspect --json` output of the real device + * (IKEA ALPSTUGA air quality monitor, firmware 1.0.26), per the plan: the + * sync behavior is derived from inspected capabilities, not assumptions. + */ + +const fixture = JSON.parse(readFileSync(join(__dirname, "fixtures", "alpstuga-inspect.json"), "utf8")) as { + basicInformation: { vendorName: string; productName: string }; + timeSynchronization: TimeSyncSummary; +}; + +describe("alpstuga fixture", () => { + it("is the expected device", () => { + expect(fixture.basicInformation.vendorName).toBe("IKEA of Sweden"); + expect(fixture.timeSynchronization).not.toBeNull(); + }); + + it("derives capabilities from the inspection", () => { + const capabilities = capabilitiesFromTimeSync(fixture.timeSynchronization); + expect(capabilities).toEqual({ + timeZoneFeature: true, + supportsSetUtcTime: true, + supportsSetTimeZone: true, + supportsSetDstOffset: true, + timeZoneListMaxSize: 2, + dstOffsetListMaxSize: 2, + }); + }); + + it("plans UTC + time zone + a 2-entry DST list for this device", () => { + const capabilities = capabilitiesFromTimeSync(fixture.timeSynchronization); + const plan = planTimeSync(capabilities, "America/Chicago", new Date("2026-07-26T00:00:00Z")); + expect(plan.setUtcTime).toBe(true); + expect(plan.timeZone).toEqual([ + { offset: -21600, validAt: 946_684_800_000_000n, name: "America/Chicago" }, + ]); + expect(plan.dstOffsets).toHaveLength(2); + expect(plan.dstOffsets![0]!.offset).toBe(3600); + expect(plan.dstOffsets![1]!.offset).toBe(0); + }); + + it("reads the fixture's utcTime as healthy Unix-epoch microseconds", () => { + // matter.js's TlvEpochUs decodes wire values to Unix-epoch microseconds, + // so the fixture's utcTime (read at commissioning, 2026-07-27T00:18:15Z + // wall clock) compares cleanly against that instant with no epoch shift. + const attribute = fixture.timeSynchronization.attributes.find(a => a.name === "utcTime"); + const deviceMicros = BigInt(attribute!.value as string); + const captureInstant = BigInt(Date.parse("2026-07-27T00:18:20Z")) * 1_000n; + const comparison = compareDeviceClock(deviceMicros, captureInstant); + expect(comparison.epochShifted).toBe(false); + expect(comparison.deviceTime).toBe("2026-07-27T00:18:15.000Z"); + const effective = comparison.effectiveDeltaMicroseconds!; + const magnitude = effective < 0n ? -effective : effective; + expect(magnitude < 60n * 1_000_000n).toBe(true); + }); + + it("degrades to UTC-only when the TimeZone feature is absent", () => { + const utcOnly: TimeSyncSummary = { + ...fixture.timeSynchronization, + supportedFeatures: { ...fixture.timeSynchronization.supportedFeatures, timeZone: false }, + supportedCommands: { + ...fixture.timeSynchronization.supportedCommands, + setTimeZone: false, + setDstOffset: false, + }, + }; + const plan = planTimeSync(capabilitiesFromTimeSync(utcOnly), "America/Chicago"); + expect(plan.setUtcTime).toBe(true); + expect(plan.timeZone).toBeNull(); + expect(plan.dstOffsets).toBeNull(); + }); +}); diff --git a/test/timezone.test.ts b/test/timezone.test.ts new file mode 100644 index 0000000..d4ba6d6 --- /dev/null +++ b/test/timezone.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { + buildDstOffsetList, + buildTimeZoneList, + formatUtcOffset, + isValidTimeZone, + nextOffsetTransition, + standardOffsetSeconds, + utcOffsetSeconds, +} from "../src/timezone.js"; + +// All builder timestamps are Unix-epoch microseconds (matter.js TlvEpochUs +// convention); "since the beginning of time" is Matter epoch zero in Unix us. +const MATTER_ZERO = 946_684_800_000_000n; +const FALL_BACK_2026 = BigInt(Date.parse("2026-11-01T07:00:00Z")) * 1_000n; +const SPRING_FORWARD_2027 = BigInt(Date.parse("2027-03-14T08:00:00Z")) * 1_000n; + +describe("isValidTimeZone", () => { + it("accepts IANA names", () => { + expect(isValidTimeZone("America/Chicago")).toBe(true); + expect(isValidTimeZone("Europe/Berlin")).toBe(true); + expect(isValidTimeZone("UTC")).toBe(true); + }); + + it("rejects invalid names", () => { + expect(isValidTimeZone("Central Time")).toBe(false); + expect(isValidTimeZone("")).toBe(false); + expect(isValidTimeZone("America/Springfield")).toBe(false); + }); +}); + +describe("utcOffsetSeconds", () => { + it("returns CST offset in January", () => { + expect(utcOffsetSeconds("America/Chicago", new Date("2026-01-15T12:00:00Z"))).toBe(-6 * 3600); + }); + + it("returns CDT offset in July", () => { + expect(utcOffsetSeconds("America/Chicago", new Date("2026-07-15T12:00:00Z"))).toBe(-5 * 3600); + }); + + it("returns 0 for UTC", () => { + expect(utcOffsetSeconds("UTC", new Date("2026-07-15T12:00:00Z"))).toBe(0); + }); + + it("handles half-hour offsets", () => { + expect(utcOffsetSeconds("Asia/Kolkata", new Date("2026-07-15T12:00:00Z"))).toBe(5.5 * 3600); + }); +}); + +describe("formatUtcOffset", () => { + it("formats negative, positive, and zero offsets", () => { + expect(formatUtcOffset(-6 * 3600)).toBe("UTC-06:00"); + expect(formatUtcOffset(5.5 * 3600)).toBe("UTC+05:30"); + expect(formatUtcOffset(0)).toBe("UTC+00:00"); + }); +}); + +describe("nextOffsetTransition", () => { + it("finds the exact spring-forward instant for Chicago", () => { + const transition = nextOffsetTransition("America/Chicago", new Date("2026-01-15T00:00:00Z")); + expect(transition).not.toBeNull(); + // 2026-03-08 02:00 CST (UTC-6) -> 03:00 CDT (UTC-5): 08:00:00 UTC. + expect(transition!.at.toISOString()).toBe("2026-03-08T08:00:00.000Z"); + expect(transition!.offsetBeforeSeconds).toBe(-6 * 3600); + expect(transition!.offsetAfterSeconds).toBe(-5 * 3600); + }); + + it("finds the exact fall-back instant for Chicago", () => { + const transition = nextOffsetTransition("America/Chicago", new Date("2026-07-15T00:00:00Z")); + expect(transition).not.toBeNull(); + // 2026-11-01 02:00 CDT (UTC-5) -> 01:00 CST (UTC-6): 07:00:00 UTC. + expect(transition!.at.toISOString()).toBe("2026-11-01T07:00:00.000Z"); + expect(transition!.offsetBeforeSeconds).toBe(-5 * 3600); + expect(transition!.offsetAfterSeconds).toBe(-6 * 3600); + }); + + it("returns null for fixed-offset zones", () => { + expect(nextOffsetTransition("UTC", new Date("2026-01-15T00:00:00Z"))).toBeNull(); + }); +}); + +describe("standardOffsetSeconds", () => { + it("returns the non-DST offset regardless of season", () => { + expect(standardOffsetSeconds("America/Chicago", new Date("2026-07-15T00:00:00Z"))).toBe(-6 * 3600); + expect(standardOffsetSeconds("America/Chicago", new Date("2026-01-15T00:00:00Z"))).toBe(-6 * 3600); + }); + + it("handles the southern hemisphere", () => { + // Sydney: AEST UTC+10 standard, AEDT UTC+11 in southern summer. + expect(standardOffsetSeconds("Australia/Sydney", new Date("2026-01-15T00:00:00Z"))).toBe(10 * 3600); + }); + + it("handles fixed-offset zones", () => { + expect(standardOffsetSeconds("UTC", new Date("2026-07-15T00:00:00Z"))).toBe(0); + expect(standardOffsetSeconds("Asia/Kolkata", new Date("2026-07-15T00:00:00Z"))).toBe(5.5 * 3600); + }); +}); + +describe("buildTimeZoneList", () => { + it("builds a single always-valid entry with the standard offset and IANA name", () => { + const list = buildTimeZoneList("America/Chicago", new Date("2026-07-26T00:00:00Z")); + expect(list).toEqual([{ offset: -6 * 3600, validAt: MATTER_ZERO, name: "America/Chicago" }]); + }); +}); + +describe("buildDstOffsetList", () => { + const from = new Date("2026-07-26T00:00:00Z"); + + it("builds the active DST period plus the following one (max 2, as on ALPSTUGA)", () => { + expect(buildDstOffsetList("America/Chicago", 2, from)).toEqual([ + { offset: 3600, validStarting: MATTER_ZERO, validUntil: FALL_BACK_2026 }, + { offset: 0, validStarting: FALL_BACK_2026, validUntil: SPRING_FORWARD_2027 }, + ]); + }); + + it("caps the list at the device's maximum", () => { + expect(buildDstOffsetList("America/Chicago", 1, from)).toEqual([ + { offset: 3600, validStarting: MATTER_ZERO, validUntil: FALL_BACK_2026 }, + ]); + }); + + it("emits a single open-ended zero entry for zones without DST", () => { + expect(buildDstOffsetList("UTC", 2, from)).toEqual([ + { offset: 0, validStarting: MATTER_ZERO, validUntil: null }, + ]); + expect(buildDstOffsetList("Asia/Kolkata", 5, from)).toEqual([ + { offset: 0, validStarting: MATTER_ZERO, validUntil: null }, + ]); + }); + + it("starts from standard time when DST is not in effect", () => { + const winter = new Date("2026-01-15T00:00:00Z"); + const springForward2026 = BigInt(Date.parse("2026-03-08T08:00:00Z")) * 1_000n; + const list = buildDstOffsetList("America/Chicago", 2, winter); + expect(list[0]).toEqual({ offset: 0, validStarting: MATTER_ZERO, validUntil: springForward2026 }); + expect(list[1]!.offset).toBe(3600); + expect(list[1]!.validStarting).toBe(springForward2026); + }); +}); |
