/** * 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; }