src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/timezone.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/timezone.ts')
-rw-r--r--src/timezone.ts190
1 files changed, 190 insertions, 0 deletions
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;
+}