import type { FastifyBaseLogger } from "fastify"; import { devices, type Db, type DeviceRow } from "@parking/db"; 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). */ const VISION_STATUS_ID = "vision-service"; // Unified live DEVICE monitor — the source for the booth's device-status footer. // Every enabled, configured device is probed on an interval, regardless of // category: a printer via its rich readStatus() (paper/cover/cutter — reusing the // same capability the PrinterMonitor uses), and a relay/reader/camera via the // generic healthCheck() reachability probe every Device implements. The result is // flattened to a common traffic-light (ready | degraded | offline) + a detail // string, cached per device id, and emitted on the bus ONLY when it changes. // // This is device-agnostic (talks to the adapter interfaces, never a driver SDK) // and read-only — polling a device never drives a relay or mutates the ledger. // See wiki/concepts/device-status-monitoring.md, printer-status-monitoring.md. 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: * - reader/camera → the direction inherited from its bound relay (entry/exit/both) * - access → entry/exit/both from its relays[]; "mixed" if it spans more * than one direction; null if it declares none yet * - printer → "lane" (entry-dispenser) | "booth" (booth-receipt) | "wash" (wash-desk) */ function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] { switch (row.category) { case "reader": case "camera": { const d = directionOf(db, row); // entry | exit | both return d; } case "access": { // Only barrier relays carry a role direction; alert (radarAlert) relays don't. const dirs = new Set( relaysOf(row) .map((r) => r.direction) .filter((d): d is "entry" | "exit" | "both" => d !== "radarAlert"), ); if (dirs.size === 0) return null; if (dirs.size > 1) return "mixed"; const only = [...dirs][0]; // entry | exit | both return only ?? null; } case "printer": { const role = (row.config as { role?: string }).role; if (role === "booth-receipt") return "booth"; if (role === "entry-dispenser") return "lane"; if (role === "wash-desk") return "wash"; return null; } default: return null; } } export class DeviceMonitor { readonly #db: Db; readonly #log: FastifyBaseLogger; readonly #pollMs: number; /** Latest unified status per device id. */ readonly #latest = new Map(); #timer: ReturnType | null = null; #ticking = false; /** Optional: the vision service client. When present + enabled, the monitor probes * its /health each tick and shows it as a "vision" chip in the footer. */ readonly #vision: VisionClient | null; constructor(db: Db, log: FastifyBaseLogger, pollMs = POLL_MS, vision: VisionClient | null = null) { this.#db = db; this.#log = log; this.#pollMs = pollMs; this.#vision = vision; } /** Begin polling. Idempotent. */ start(): void { if (this.#timer) return; void this.#tick(); // immediate first pass so the footer fills without a wait this.#timer = setInterval(() => void this.#tick(), this.#pollMs); this.#timer.unref?.(); this.#log.info(`device-monitor: polling every ${this.#pollMs}ms`); } stop(): void { if (this.#timer) { clearInterval(this.#timer); this.#timer = null; } } /** Current snapshot for the API / a freshly-connected WS client. */ snapshot(): DeviceStatusEvent[] { return [...this.#latest.values()]; } async #tick(): Promise { if (this.#ticking) return; // never overlap polls this.#ticking = true; try { // Re-read the device set each tick so a newly-assigned/removed device is // picked up without a restart. const rows = await this.#db.select().from(devices).all(); const enabled = rows.filter((r) => r.enabled); const present = new Set(enabled.map((r) => r.id)); // The vision service is a pseudo-device — keep it in the present set when enabled // so the cleanup below doesn't evict it. if (this.#vision?.enabled) present.add(VISION_STATUS_ID); // Drop devices that are gone/disabled (so the footer doesn't show stale ones). for (const id of [...this.#latest.keys()]) { if (!present.has(id)) this.#latest.delete(id); } await Promise.all([...enabled.map((r) => this.#poll(r)), this.#pollVision()]); } catch (err) { this.#log.warn(`device-monitor tick failed: ${(err as Error).message}`); } finally { this.#ticking = false; } } async #poll(row: DeviceRow): Promise { const cfg = (row.config ?? {}) as Record; const base = { deviceId: row.id, driverId: row.driverId, category: row.category, roleKind: roleKindOf(this.#db, row), }; 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, // don't silently hide it. next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() }; } else { try { 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)) { const s = await device.readStatus(); next = { ...base, state: s.status, detail: s.detail, checkedAt: s.checkedAt }; } else { const h = await device.healthCheck(); next = { ...base, state: h.status, detail: h.detail, checkedAt: new Date().toISOString() }; } } catch (err) { // A probe that throws (build error, timeout) reads as offline — never crash // the tick, and fail toward "there's a problem" rather than false-healthy. next = { ...base, state: "offline", detail: (err as Error).message, checkedAt: new Date().toISOString() }; } } // 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); } /** Probe the vision service /health and publish it as a "vision" footer chip. Skipped * entirely when no client is wired or it's disabled (no chip then). */ async #pollVision(): Promise { if (!this.#vision?.enabled) return; const h = await this.#vision.health(); const state: DeviceStatusEvent["state"] = h.ok && h.ready ? "ready" : h.ready ? "degraded" : "offline"; this.#publish(VISION_STATUS_ID, { deviceId: VISION_STATUS_ID, driverId: "vision", category: "vision", roleKind: null, state, detail: h.ready ? h.recognizer : (h.detail ?? "not ready"), checkedAt: new Date().toISOString(), }); } /** 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); this.#latest.set(id, next); if (!prev || prev.state !== next.state || prev.detail !== next.detail) { this.#log.info( `device-monitor: ${next.category}/${next.roleKind ?? "—"} ${id} -> ${next.state}${next.detail ? ` (${next.detail})` : ""}`, ); deviceEvents.emitDeviceStatus(next); } } }