import type { FastifyBaseLogger } from "fastify"; import { eq, devices, type Db } from "@parking/db"; import { isMonitorable, registry, type PrinterStatus, } from "@parking/devices"; import { deviceEvents, type PrinterStatusEvent } from "./device-events.js"; // Live printer-status monitor. Polls every enabled printer that supports // readStatus() on an interval, caches the latest status in memory, and emits a // "printer-status" event on the device bus whenever a printer's status CHANGES // (so the UI/SSE stream and any future entry-flow logic react without polling // the device themselves). See wiki/concepts/printer-status-monitoring.md. // // The poll is the booth's early warning: it surfaces "paper out" / "cover open" // BEFORE a driver presses the entry button and no ticket prints. Reachability // failures degrade to status "offline" — the same signal as a dead printer. const POLL_MS = Number(process.env.PRINTER_POLL_MS ?? 5000); /** A cached entry: the last status plus the device's identity for the UI. */ interface CachedStatus extends PrinterStatusEvent {} export class PrinterMonitor { readonly #db: Db; readonly #log: FastifyBaseLogger; readonly #pollMs: number; /** Latest status per device id. */ readonly #latest = new Map(); /** Live adapter per device id (rebuilt when the set of printers changes). */ readonly #devices = new Map ReturnType; meta: Omit }>(); #timer: ReturnType | 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; // Kick an immediate pass so status is populated without waiting a full cycle. void this.#tick(); this.#timer = setInterval(() => void this.#tick(), this.#pollMs); // Don't keep the event loop alive solely for the monitor. this.#timer.unref?.(); this.#log.info(`printer-monitor: polling every ${this.#pollMs}ms`); } stop(): void { if (this.#timer) { clearInterval(this.#timer); this.#timer = null; } } /** Current snapshot for the API. */ snapshot(): CachedStatus[] { return [...this.#latest.values()]; } /** Reload the set of monitored printers from lane_devices (call after assign). */ async refreshDevices(): Promise { const rows = await this.#db .select() .from(devices) .where(eq(devices.category, "printer")) .all(); const seen = new Set(); for (const row of rows) { if (!row.enabled) continue; const driver = registry.get(row.driverId); if (!driver) continue; const cfg = row.config as Record; // Probe-build once to check the driver yields a monitorable device. let monitorable: boolean; try { monitorable = isMonitorable(driver.create(cfg as never)); } catch { monitorable = false; } if (!monitorable) continue; seen.add(row.id); this.#devices.set(row.id, { build: () => driver.create(cfg as never), meta: { deviceId: row.id, driverId: row.driverId, role: typeof cfg.role === "string" ? cfg.role : undefined, }, }); } // Drop devices that are no longer present/enabled. for (const id of [...this.#devices.keys()]) { if (!seen.has(id)) { this.#devices.delete(id); this.#latest.delete(id); } } } async #tick(): Promise { if (this.#ticking) return; // never overlap polls this.#ticking = true; try { await this.refreshDevices(); await Promise.all( [...this.#devices.entries()].map(([id, entry]) => this.#poll(id, entry)), ); } catch (err) { this.#log.warn(`printer-monitor tick failed: ${(err as Error).message}`); } finally { this.#ticking = false; } } async #poll(id: string, entry: { build: () => ReturnType; meta: Omit }): Promise { let status: PrinterStatus; try { const device = entry.build(); if (!isMonitorable(device)) return; status = await device.readStatus(); } catch (err) { status = { status: "offline", detail: (err as Error).message, checkedAt: new Date().toISOString(), }; } const event: PrinterStatusEvent = { ...entry.meta, status }; const prev = this.#latest.get(id); this.#latest.set(id, event); if (!prev || statusChanged(prev.status, status)) { this.#log.info( `printer-monitor: ${entry.meta.role ?? "printer"} ${id} -> ${status.status}${status.detail ? ` (${status.detail})` : ""}`, ); deviceEvents.emitPrinterStatus(event); } } } /** Did the operator-meaningful status change between two reads? */ function statusChanged(a: PrinterStatus, b: PrinterStatus): boolean { return ( a.status !== b.status || a.paperEnd !== b.paperEnd || a.paperNearEnd !== b.paperNearEnd || a.coverOpen !== b.coverOpen || a.cutterError !== b.cutterError || a.offline !== b.offline ); }