diff --git a/apps/server/src/device-monitor.test.ts b/apps/server/src/device-monitor.test.ts new file mode 100644 index 0000000..f00209f --- /dev/null +++ b/apps/server/src/device-monitor.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { localIsoWithOffset } from "./device-monitor.js"; + +// The camera clock-sync sends the SITE's wall-clock now with an explicit UTC offset +// (ISAPI localTime) — the offset is what makes the instant unambiguous regardless of +// the camera's own tz/DST config. Pin the DST both-sides behaviour for the site tz. + +describe("localIsoWithOffset (camera clock sync payload)", () => { + it("Tirane summer = +02:00 (CEST)", () => { + expect(localIsoWithOffset("Europe/Tirane", new Date("2026-07-07T10:00:00Z"))).toBe( + "2026-07-07T12:00:00+02:00", + ); + }); + it("Tirane winter = +01:00 (CET)", () => { + expect(localIsoWithOffset("Europe/Tirane", new Date("2026-01-15T10:00:00Z"))).toBe( + "2026-01-15T11:00:00+01:00", + ); + }); + it("UTC = +00:00", () => { + expect(localIsoWithOffset("UTC", new Date("2026-07-07T10:00:00Z"))).toBe( + "2026-07-07T10:00:00+00:00", + ); + }); +}); diff --git a/apps/server/src/device-monitor.ts b/apps/server/src/device-monitor.ts index 0652532..b14af43 100644 --- a/apps/server/src/device-monitor.ts +++ b/apps/server/src/device-monitor.ts @@ -1,9 +1,10 @@ import type { FastifyBaseLogger } from "fastify"; import { devices, type Db, type DeviceRow } from "@parking/db"; -import { isMonitorable, registry } from "@parking/devices"; +import { isClockSyncable, isMonitorable, registry, type Device } from "@parking/devices"; import { deviceEvents, type DeviceStatusEvent } from "./device-events.js"; import { directionOf, relaysOf } from "./device-resolve.js"; import type { VisionClient } from "./vision-client.js"; +import { siteTz } from "./subscription-window.js"; /** Synthetic device id for the vision service in the status footer (it's a service, * not a device row, but shares the footer's traffic-light + WS plumbing). */ @@ -23,6 +24,40 @@ const VISION_STATUS_ID = "vision-service"; const POLL_MS = Number(process.env.DEVICE_POLL_MS ?? 8000); +// Camera clock sync (Hikvision loses its clock on power cuts — reboots at the 1970 +// epoch until a human logs into its web UI). The monitor re-syncs from the HOST +// clock (the site's offline time authority) at the offline→ready edge — exactly the +// power-restored moment — plus a daily backstop; drift under the threshold is left +// alone. See wiki/entities/lpr-camera.md (clock sync). +const CLOCK_SYNC_BACKSTOP_MS = 24 * 60 * 60 * 1000; +const CLOCK_MAX_DRIFT_SEC = 60; + +/** The site's wall-clock now as ISO WITH utc offset (e.g. 2026-07-07T15:30:22+02:00) + * — what ISAPI's localTime wants. Derived via Intl for the site tz (no dep). */ +export function localIsoWithOffset(tz: string, at = new Date()): string { + const fmt = new Intl.DateTimeFormat("en-CA", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }); + const p = Object.fromEntries(fmt.formatToParts(at).map((x) => [x.type, x.value])); + const wallAsUtcMs = Date.UTC( + Number(p.year), Number(p.month) - 1, Number(p.day), + Number(p.hour), Number(p.minute), Number(p.second), + ); + const offMin = Math.round((wallAsUtcMs - at.getTime()) / 60_000); + const sign = offMin < 0 ? "-" : "+"; + const abs = Math.abs(offMin); + const hh = String(Math.floor(abs / 60)).padStart(2, "0"); + const mm = String(abs % 60).padStart(2, "0"); + return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}${sign}${hh}:${mm}`; +} + /** * The device's ROLE descriptor for the footer (never the vendor). Direction-style * tokens the client localises next to the category: @@ -139,6 +174,7 @@ export class DeviceMonitor { }; let next: DeviceStatusEvent; + let device: Device | null = null; const driver = registry.get(row.driverId); if (!driver) { // Configured against a driver that's no longer registered — surface it, @@ -146,7 +182,7 @@ export class DeviceMonitor { next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() }; } else { try { - const device = driver.create(cfg as never); + device = driver.create(cfg as never); // Printers expose richer paper/cover/cutter status; everything else uses // the generic reachability probe. Both flatten to the same traffic-light. if (isMonitorable(device)) { @@ -163,6 +199,33 @@ export class DeviceMonitor { } } + // Camera clock re-sync at the power-restored edge (prev offline/unknown → + // ready) + a daily backstop. Stamped BEFORE the async attempt so a failing + // camera is retried at backstop cadence, never every poll. + if (row.category === "camera" && next.state === "ready" && device && isClockSyncable(device)) { + const prev = this.#latest.get(row.id); + const cameBack = !prev || prev.state === "offline"; + const last = this.#clockSyncedAt.get(row.id) ?? 0; + if (cameBack || Date.now() - last > CLOCK_SYNC_BACKSTOP_MS) { + this.#clockSyncedAt.set(row.id, Date.now()); + const cam = device; + void (async () => { + try { + const r = await cam.syncClock(localIsoWithOffset(siteTz(this.#db)), CLOCK_MAX_DRIFT_SEC); + if (r.synced) { + // A large jump is the 1970 power-cut signature — warn (persisted) so + // the reboot stays visible; a small correction is routine info. + const msg = `device-monitor: camera ${row.id} clock synced (was ${r.driftSeconds ?? "unparseable"}s off)`; + if (r.driftSeconds == null || r.driftSeconds > 3600) this.#log.warn(msg); + else this.#log.info(msg); + } + } catch (err) { + this.#log.warn(`device-monitor: camera ${row.id} clock sync failed: ${(err as Error).message}`); + } + })(); + } + } + this.#publish(row.id, next); } @@ -183,6 +246,9 @@ export class DeviceMonitor { }); } + /** Per-camera timestamp of the last clock-sync ATTEMPT (backstop pacing). */ + readonly #clockSyncedAt = new Map(); + /** Cache + emit a status, but only when it CHANGED (state or detail). */ #publish(id: string, next: DeviceStatusEvent): void { const prev = this.#latest.get(id); diff --git a/packages/devices/src/drivers/camera.test.ts b/packages/devices/src/drivers/camera.test.ts index 5b205c6..53f9623 100644 --- a/packages/devices/src/drivers/camera.test.ts +++ b/packages/devices/src/drivers/camera.test.ts @@ -7,7 +7,11 @@ import type { DigestGetResult } from "./http-digest.js"; // fail FAST on a config error (401 auth / 404 path). See camera.ts. const digestGet = vi.fn<(...a: unknown[]) => Promise>(); -vi.mock("./http-digest.js", () => ({ digestGet: (...a: unknown[]) => digestGet(...a) })); +const digestRequest = vi.fn<(...a: unknown[]) => Promise>(); +vi.mock("./http-digest.js", () => ({ + digestGet: (...a: unknown[]) => digestGet(...a), + digestRequest: (...a: unknown[]) => digestRequest(...a), +})); // Import the driver AFTER the mock is registered. const { hikvisionDriver } = await import("./camera.js"); @@ -22,6 +26,7 @@ function makeCamera() { beforeEach(() => { digestGet.mockReset(); + digestRequest.mockReset(); vi.useFakeTimers(); }); afterEach(() => { @@ -143,3 +148,64 @@ describe("healthCheck detail is a STABLE size bucket (log-noise fix, 2026-07-05) expect(tiny.detail).toBe("snapshot <1 KB"); }); }); + +describe("hikvision syncClock — the 1970 power-cut recovery (ISAPI /System/time)", () => { + const timeXml = (localTime: string) => + reply( + 200, + ``, + ); + const HOST_NOW = "2026-07-07T12:00:00+02:00"; + + function cam() { + return makeCamera() as unknown as { + syncClock(localIso: string, maxDriftSec: number): Promise<{ driftSeconds: number | null; synced: boolean }>; + }; + } + + it("in-sync camera: reads, does NOT set", async () => { + digestRequest.mockResolvedValueOnce(timeXml("2026-07-07T12:00:10+02:00")); + const r = await cam().syncClock(HOST_NOW, 60); + expect(r).toEqual({ driftSeconds: 10, synced: false }); + expect(digestRequest).toHaveBeenCalledTimes(1); // GET only + }); + + it("1970 camera: PUTs manual time with the host instant, echoing the camera's timeZone", async () => { + digestRequest + .mockResolvedValueOnce(timeXml("1970-01-01T03:12:44+01:00")) + .mockResolvedValueOnce(reply(200, "")); + const r = await cam().syncClock(HOST_NOW, 60); + expect(r.synced).toBe(true); + expect(r.driftSeconds).toBeGreaterThan(1_000_000_000); // ~56 years + const put = digestRequest.mock.calls[1]![0] as { method: string; path: string; body: string }; + expect(put.method).toBe("PUT"); + expect(put.path).toBe("/ISAPI/System/time"); + expect(put.body).toContain("manual"); + expect(put.body).toContain(`${HOST_NOW}`); + expect(put.body).toContain("CST-2:00:00DST01:00:00"); // echoed, never invented + }); + + it("unparseable camera time = infinite drift → syncs", async () => { + digestRequest + .mockResolvedValueOnce(reply(200, "")) + .mockResolvedValueOnce(reply(200, "")); + const r = await cam().syncClock(HOST_NOW, 60); + expect(r).toEqual({ driftSeconds: null, synced: true }); + }); + + it("a failed set surfaces as an error (monitor logs it, backstop retries)", async () => { + digestRequest + .mockResolvedValueOnce(timeXml("1970-01-01T01:00:00+01:00")) + .mockResolvedValueOnce(reply(403, "denied")); + await expect(cam().syncClock(HOST_NOW, 60)).rejects.toThrow("clock set failed: HTTP 403"); + }); + + it("the dahua driver does NOT claim the capability (no ISAPI time endpoint)", async () => { + const { dahuaDriver, hikvisionDriver } = await import("./camera.js"); + const { isClockSyncable } = await import("../interfaces.js"); + const mk = (d: typeof dahuaDriver) => + d.create({ host: "10.0.10.12", port: 80, username: "admin", password: "x", channel: 1 }); + expect(isClockSyncable(mk(dahuaDriver))).toBe(false); + expect(isClockSyncable(mk(hikvisionDriver))).toBe(true); + }); +}); diff --git a/packages/devices/src/drivers/camera.ts b/packages/devices/src/drivers/camera.ts index 00f36b0..12521b6 100644 --- a/packages/devices/src/drivers/camera.ts +++ b/packages/devices/src/drivers/camera.ts @@ -1,5 +1,6 @@ import type { CameraDevice, + ClockSyncResult, DeviceHealth, Snapshot, SnapshotContext, @@ -12,7 +13,7 @@ import { usernameField, stubLog, } from "./common.js"; -import { digestGet } from "./http-digest.js"; +import { digestGet, digestRequest } from "./http-digest.js"; // Camera drivers — entry/exit snapshot-on-event. The host pulls a still over // HTTP when an event fires; the bytes are stored and referenced from the signed @@ -160,6 +161,73 @@ class HttpCamera implements CameraDevice { localAddress: this.#localAddress, }); } + + /** Digest request against an arbitrary device path (ISAPI config reads/writes). */ + protected isapi(method: "GET" | "PUT", path: string, body?: string) { + return digestRequest({ + host: this.#host, + port: this.#port, + path, + method, + body, + user: this.#user, + password: this.#password, + timeoutMs: this.#timeout, + localAddress: this.#localAddress, + }); + } +} + +// --- Hikvision clock sync (ISAPI /System/time) --------------------------------- +// These cameras lose their clock on a power cut (no/dead RTC battery): they reboot +// at the 1970 epoch and stay there until a human logs into the web UI (which +// silently pushes the browser clock). A wrong camera clock corrupts the OSD +// timestamp burned into every snapshot — the evidence trail — and the times on +// ANPR pushes. So the host (the site's time authority — offline-first, no NTP +// dependency) re-syncs the camera over the same Digest-auth ISAPI used for +// snapshots. The device monitor calls this at the offline→ready edge (the +// power-restored moment) + a daily backstop. See wiki/entities/lpr-camera.md. + +class HikvisionCamera extends HttpCamera { + /** + * Read the camera clock; when it drifts more than `maxDriftSec` from `localIso` + * (the site's wall-clock now, WITH utc offset), set it via + * PUT /ISAPI/System/time. The camera's own `timeZone` string is echoed back + * verbatim — we correct the CLOCK, never fight the tz/DST config; `localIso`'s + * explicit offset makes the instant unambiguous regardless of that config. + */ + async syncClock(localIso: string, maxDriftSec: number): Promise { + const read = await this.isapi("GET", "/ISAPI/System/time"); + if (read.status !== 200) { + throw new Error(`clock read failed: HTTP ${read.status}`); + } + const xml = read.body.toString("utf8"); + const cameraTime = xml.match(/([^<]+)<\/localTime>/)?.[1]?.trim() ?? null; + const timeZone = xml.match(/([^<]+)<\/timeZone>/)?.[1]?.trim() ?? ""; + + // Drift: parse both sides as instants. A camera reply without a UTC offset (or + // otherwise unparseable) can't be trusted → treat as infinite drift and sync. + const cameraMs = cameraTime ? Date.parse(cameraTime) : NaN; + const hostMs = Date.parse(localIso); + const driftSeconds = Number.isFinite(cameraMs) + ? Math.round(Math.abs(hostMs - cameraMs) / 1000) + : null; + if (driftSeconds != null && driftSeconds <= maxDriftSec) { + return { driftSeconds, synced: false }; + } + + const body = + `` + + ``; + const put = await this.isapi("PUT", "/ISAPI/System/time", body); + if (put.status !== 200) { + throw new Error(`clock set failed: HTTP ${put.status}`); + } + stubLog(this.driverId, `clock synced (was ${driftSeconds ?? "unparseable"}s off)`); + return { driftSeconds, synced: true }; + } } const channelField: ConfigField = { @@ -250,7 +318,7 @@ export const hikvisionDriver: CameraDriver = { // ISAPI channel id: , e.g. ch1 main = 101, ch1 sub = 102, ch2 main = 201. // stream 1 → "01" (main), 2 → "02" (sub). create: (c) => - new HttpCamera( + new HikvisionCamera( "hikvision", c, (ch, stream) => `/ISAPI/Streaming/channels/${ch}0${stream}/picture`, diff --git a/packages/devices/src/drivers/http-digest.ts b/packages/devices/src/drivers/http-digest.ts index 7c30a25..a065441 100644 --- a/packages/devices/src/drivers/http-digest.ts +++ b/packages/devices/src/drivers/http-digest.ts @@ -73,19 +73,33 @@ export interface DigestGetOptions { readonly localAddress?: string; } -function getOnce( - o: DigestGetOptions, +/** digestGet + a method and optional body — for ISAPI configuration writes + * (e.g. PUT /ISAPI/System/time). The digest handshake is method-aware (HA2 + * hashes the method), so this generalisation is the real one, not a shortcut. */ +export interface DigestRequestOptions extends DigestGetOptions { + readonly method: "GET" | "PUT" | "POST"; + readonly body?: Buffer | string; + readonly contentType?: string; +} + +function requestOnce( + o: DigestRequestOptions, authHeader?: string, ): Promise<{ res: IncomingMessage; body: Buffer }> { return new Promise((resolve, reject) => { + const payload = o.body == null ? null : Buffer.isBuffer(o.body) ? o.body : Buffer.from(o.body, "utf8"); const headers: Record = {}; if (authHeader) headers["authorization"] = authHeader; + if (payload) { + headers["content-type"] = o.contentType ?? "application/xml"; + headers["content-length"] = String(payload.length); + } const req = httpRequest( { host: o.host, port: o.port, path: o.path, - method: "GET", + method: o.method, timeout: o.timeoutMs, localAddress: o.localAddress, headers, @@ -97,19 +111,21 @@ function getOnce( }, ); req.on("error", reject); - req.on("timeout", () => req.destroy(new Error("digest GET timeout"))); + req.on("timeout", () => req.destroy(new Error(`digest ${o.method} timeout`))); + if (payload) req.write(payload); req.end(); }); } /** - * GET a resource with HTTP Digest auth. Does the standard two-shot handshake: + * Request a resource with HTTP Digest auth. Does the standard two-shot handshake: * the first request (no Authorization) draws a 401 + challenge, the second - * carries the computed response. If the server doesn't challenge (200 straight + * carries the computed response (the body is sent BOTH times — the challenge shot + * needs the same request shape). If the server doesn't challenge (200 straight * away, or no auth required), the first response is returned as-is. */ -export async function digestGet(o: DigestGetOptions): Promise { - const first = await getOnce(o); +export async function digestRequest(o: DigestRequestOptions): Promise { + const first = await requestOnce(o); if (first.res.statusCode !== 401) { return { status: first.res.statusCode ?? 0, @@ -129,11 +145,16 @@ export async function digestGet(o: DigestGetOptions): Promise { } const challenge = parseChallenge(challengeHeader); - const auth = buildAuthHeader(challenge, o.user, o.password, "GET", o.path); - const second = await getOnce(o, auth); + const auth = buildAuthHeader(challenge, o.user, o.password, o.method, o.path); + const second = await requestOnce(o, auth); return { status: second.res.statusCode ?? 0, contentType: String(second.res.headers["content-type"] ?? ""), body: second.body, }; } + +/** GET with Digest auth (the original entry point; snapshots and status reads). */ +export function digestGet(o: DigestGetOptions): Promise { + return digestRequest({ ...o, method: "GET" }); +} diff --git a/packages/devices/src/interfaces.ts b/packages/devices/src/interfaces.ts index 1d273bf..42643c8 100644 --- a/packages/devices/src/interfaces.ts +++ b/packages/devices/src/interfaces.ts @@ -194,6 +194,30 @@ export function isCamera(device: Device): device is Device & CameraDevice { return typeof (device as Partial).captureSnapshot === "function"; } +/** Outcome of a camera clock sync attempt (see ClockSyncDevice). */ +export interface ClockSyncResult { + /** Camera-vs-host drift in whole seconds at check time; null = the camera's + * reply was unparseable (treated as infinite drift → sync). */ + readonly driftSeconds: number | null; + /** True when the camera clock was actually set (drift exceeded the threshold). */ + readonly synced: boolean; +} + +/** Optional capability: a device whose clock the HOST can read + set. Hikvision + * cameras lose their clock on power cuts (no/dead RTC battery, reboot at the 1970 + * epoch) and only heal when a human logs into the web UI — so the device monitor + * re-syncs them from the host clock at the offline→ready edge + a daily backstop. + * See wiki/entities/lpr-camera.md (clock sync). */ +export interface ClockSyncDevice { + /** Compare the device clock to `localIso` (the site's wall-clock now, WITH utc + * offset) and set it when drift exceeds `maxDriftSec`. */ + syncClock(localIso: string, maxDriftSec: number): Promise; +} + +export function isClockSyncable(device: Device): device is Device & ClockSyncDevice { + return typeof (device as Partial).syncClock === "function"; +} + export interface SnapshotContext { readonly direction: "entry" | "exit"; } diff --git a/wiki/entities/lpr-camera.md b/wiki/entities/lpr-camera.md index 45ace53..35e02c8 100644 --- a/wiki/entities/lpr-camera.md +++ b/wiki/entities/lpr-camera.md @@ -2,7 +2,7 @@ type: entity tags: [parking, hardware, readers, offline-first] sources: [parking-system-architecture] -updated: 2026-06-27 +updated: 2026-07-07 --- # LPR Camera @@ -110,6 +110,34 @@ Covered by `packages/devices/src/drivers/camera.test.ts` (retry behaviour + the selection). `healthCheck()` deliberately reports a live 503 as `degraded` (it surfaces a genuinely saturated main stream rather than hiding it behind a retry). +## Clock sync — the 1970 power-cut reset (built 2026-07-07) + +Field observation (park-buzi): after a power cut these cameras come back with their clock at the +**1970 epoch** (no/dead RTC battery, no NTP) and stay there until a human logs into the web UI — +Hikvision's web login silently pushes the browser clock. A wrong camera clock corrupts the OSD +timestamp burned into every snapshot (the evidence trail) and the times on ANPR pushes. + +Built: the HOST is the site's time authority (offline-first — no NTP infra dependency), and the +device monitor re-syncs each Hikvision camera over the same Digest-auth ISAPI used for snapshots: + +- **Trigger:** the camera's **offline → ready transition** (exactly the power-restored moment) + + a **24h backstop**; the attempt timestamp is stamped BEFORE the async call, so a failing camera + retries at backstop cadence, never every 8s poll. +- **Mechanics** (`HikvisionCamera.syncClock`): `GET /ISAPI/System/time`, parse `localTime`; drift ≤ + 60s → leave alone. Beyond that → `PUT /ISAPI/System/time` with `timeMode=manual`, the site's + wall-clock now WITH explicit utc offset (`localIsoWithOffset(siteTz)`, e.g. + `2026-07-07T15:30:22+02:00` — the offset makes the instant unambiguous), and the camera's own + `timeZone` string **echoed back verbatim** (we correct the clock, never fight its tz/DST config). + An unparseable camera reply counts as infinite drift → sync. +- **Visibility:** a sync after a big jump (>1h — the power-cut signature) logs at **warn** + (persisted to app_logs); small corrections log info. Failures log warn. +- **Scope:** Hikvision only (`isClockSyncable` capability guard); the Dahua driver's CGI has no + ISAPI time endpoint — a Dahua clock sync would be its own driver work. +- Rejected alternative: camera-side **NTP against the booth** (chrony on the appliance). More + standard, but adds a provisioning dependency per booth and the camera polls NTP on ITS schedule + — a freshly power-cycled camera could still sit at 1970 for a while, which is precisely the + moment that matters. + ## Camera PUSH — "Alarm Server" event notifications (2026-06-22) Separate from the **pull** snapshot path above: newer Hikvision firmware can **push** an event to diff --git a/wiki/log.md b/wiki/log.md index 66c5d0f..f752ef0 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2505,3 +2505,14 @@ GET /api/setup/usb-printers enumerates /dev/usb/lpN + sysfs ieee1284_id make/mod devicePath is now a select of PRESENT printers (fresh form preselects the first; a saved-but- absent path stays selectable, flagged; none found → free-text + hint). Transport option label no longer hardcodes lp0. Details on [[printer-usb-transport]]. + +## [2026-07-07] update | Camera clock sync via ISAPI — the 1970 power-cut reset healed + +park-buzi observation: power-cut Hik cameras reboot at the 1970 epoch (no RTC battery, no NTP) +until a web-UI login pushes the browser clock — corrupting snapshot OSD timestamps (evidence) and +ANPR push times meanwhile. Built host-as-time-authority sync (details on [[lpr-camera]] §Clock +sync): device monitor triggers at the offline→ready edge + 24h backstop; HikvisionCamera.syncClock +GETs /ISAPI/System/time, and beyond 60s drift PUTs manual time with the site's wall-clock + explicit +offset, echoing the camera's timeZone verbatim; >1h jumps log warn (persisted). digest client +generalised GET→GET/PUT/POST with body (the handshake was already method-aware). Capability-guarded +(isClockSyncable — hikvision only). 8 new tests (5 devices, 3 tz-offset).