4af8b56dda
Make the vision service genuinely configurable (was env-only). - SetupWizard: an "ANPR" checkbox on the camera form (writes config.anpr; persisted only when on; sq+en) — opt-in is no longer raw JSON. - DeviceMonitor optionally takes the VisionClient and probes /health each tick, emitting a "vision" pseudo-device → a Vision chip (ready/degraded/offline + recognizer) in the booth footer when VISION_ENABLED, no chip when off. Widened the DeviceStatus category union (server + web) + footer maps + devices.catVision. Verified: ready/fast_alpr when up, 0 chips when disabled. - apps/vision/.env.example (Python service) + a VISION_* block in apps/server/.env.example (Node side) + a Configuration section in opencv-anpr-service.md covering all four layers and the caveats: the two processes share the VISION_ prefix but need SEPARATE .env files; bind /analyze to 127.0.0.1; cache model weights at deploy; an unbound anpr camera recognizes but every read is refused. Build + lint green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
193 lines
7.5 KiB
TypeScript
193 lines
7.5 KiB
TypeScript
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";
|
|
import type { VisionClient } from "./vision-client.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);
|
|
|
|
/**
|
|
* 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;
|
|
|
|
/** 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;
|
|
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() };
|
|
}
|
|
}
|
|
|
|
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(),
|
|
});
|
|
}
|
|
|
|
/** 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);
|
|
}
|
|
}
|
|
}
|