e14e31a840
Closes the three known follow-ups of the Tills decision (venue-modules.md): - Activity log per till: `tillOfEvent(type, payload)` in @parking/shared (money events by payload till, other events by their owning module's till, everything else booth), applied by `/api/events?till=` in SQL and passed by the hub log, the Drawer "today" panel and the booth feed (history + live pushes). The events route admits a role that holds a module feed permission without event:read and returns only that module's event types — the live-socket rule. - Booth Z-report: `chargesByModuleMinor` sums the chargeLines on the till's payments by module; the ticket bucket excludes them (Bileta = parking only); printed "Lavazh (në biletë)" only when any was taken. The wash till's slip prints "Lavazh:". - Printer role `wash-desk`: the wash till's Z-report and vouchers print there, falling back to the booth printer; nothing falls back to the desk. `printerRoleOf()` is the one reading of the role field (the entry/booth loaders treated any non-booth role as an entry dispenser). Footer label "at wash desk". Also: `GET /api/carwash/settings` opens to carwash:read OR site:read (new requireAnyPermission) — the Wash operator job could not load the desk's category and service pickers. Tests for all four; wiki (shift, printer-roles-failover, venue-modules, log) updated. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
265 lines
11 KiB
TypeScript
265 lines
11 KiB
TypeScript
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<string, DeviceStatusEvent>();
|
|
#timer: ReturnType<typeof setInterval> | 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<void> {
|
|
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<void> {
|
|
const cfg = (row.config ?? {}) as Record<string, unknown>;
|
|
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<void> {
|
|
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<string, number>();
|
|
|
|
/** 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);
|
|
}
|
|
}
|
|
}
|