feat(devices): live device-status footer across all categories
Generalise printer-only monitoring to every configured device. New DeviceMonitor polls all enabled devices each tick (default 8s): printers via rich readStatus(), relays/readers/cameras via the generic healthCheck() reachability probe, flattened to one traffic-light (ready/degraded/offline) + detail, deduped (emit on change only), fail-toward-offline. - device-status bus event + GET /api/devices/status snapshot. - Pushed over the existing /api/ws (hello carries the initial set; device-status frame per change). - Web: live-store devices map, WS handler, DeviceFooter chip-per-device (role label not vendor; click a degraded/offline chip for an issues panel). Verified roleKind resolution + change-only emit on a fresh DB. Note: the footer's UI surface (api type, router mount, i18n devices) rides in the subsequent subscription commit due to shared-file overlap. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { devices, type Db, type DeviceRow } from "@parking/db";
|
||||
import { isMonitorable, registry } from "@parking/devices";
|
||||
import { deviceEvents, type DeviceStatusEvent } from "./device-events.js";
|
||||
import { directionOf, relaysOf } from "./device-resolve.js";
|
||||
|
||||
// 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);
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
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": {
|
||||
const dirs = new Set(relaysOf(row).map((r) => r.direction));
|
||||
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";
|
||||
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;
|
||||
|
||||
constructor(db: Db, log: FastifyBaseLogger, pollMs = POLL_MS) {
|
||||
this.#db = db;
|
||||
this.#log = log;
|
||||
this.#pollMs = pollMs;
|
||||
}
|
||||
|
||||
/** 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));
|
||||
|
||||
// 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)));
|
||||
} 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;
|
||||
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 {
|
||||
const 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() };
|
||||
}
|
||||
}
|
||||
|
||||
const prev = this.#latest.get(row.id);
|
||||
this.#latest.set(row.id, next);
|
||||
if (!prev || prev.state !== next.state || prev.detail !== next.detail) {
|
||||
this.#log.info(
|
||||
`device-monitor: ${next.category}/${next.roleKind ?? "—"} ${row.id} -> ${next.state}${next.detail ? ` (${next.detail})` : ""}`,
|
||||
);
|
||||
deviceEvents.emitDeviceStatus(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user