src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/sync.ts
blob: ec3577cf680260c95a6b142adea45e6b82d9d1bb (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
import { execFile } from "node:child_process";
import { promisify } from "node:util";

/**
 * Matter epoch conversion.
 *
 * 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.
 *
 * The Time Synchronization write commands (SetUtcTime, SetTimeZone,
 * SetDstOffset) are intentionally NOT implemented yet: per the project plan
 * they are Phase 5/6 work that must be driven by the real device's inspected
 * capabilities. Run `inspect` against the hardware first.
 */

export const MATTER_EPOCH_UNIX_SECONDS = 946_684_800n;

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

/**
 * 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, 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;
  /** 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.
 */
export function compareDeviceClock(deviceMicros: bigint | null, hostMicros: bigint): ClockComparison {
  const hostTime = formatMatterMicros(hostMicros);
  if (deviceMicros === null) {
    return {
      deviceTime: null,
      hostTime,
      deltaMicroseconds: null,
      description: "device clock was unset",
    };
  }
  const delta = deviceMicros - hostMicros;
  const magnitude = delta < 0n ? -delta : delta;
  const description =
    magnitude < 1_000_000n
      ? `device clock was within 1s of host time (${formatDurationMicros(magnitude)} ${
          delta < 0n ? "behind" : "ahead"
        })`
      : `device clock was ${formatDurationMicros(magnitude)} ${delta < 0n ? "behind" : "ahead"}`;
  return { deviceTime: formatMatterMicros(deviceMicros), hostTime, deltaMicroseconds: delta, description };
}

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