Files
parking_solution/apps/server/src/printer-monitor.ts
T
julian 1efa77bf56 devices: pool-of-spaces model — drop lane, per-relay direction
A parking lot is one pool of spaces with a flexible set of entry/exit
points — no "lane". Direction is a property of each RELAY inside an access
controller; readers/cameras bind to a controller relay and inherit it.

Schema:
- drop `lane` from ledger_events, device_events, sessions
- rename lane_devices -> devices (no lane/direction columns)
- access config.relays=[{relay,direction,button?}]; reader/camera
  config.controllerId+relay binding
- fresh 0000_baseline migration (history reset; dev data was throwaway)

Signed ledger:
- remove `lane` from canonicalize(); bump signer keyId sw-hmac-v1 -> v2
  (v1 events won't verify under v2 — intentional, gated per-event by keyId)

Server:
- new device-resolve.ts (replaces lane-map.ts): relayForButton,
  relayForDevice, firstRelayByDirection, devicesByDirection
- entry-flow: button terminal -> its relay; exit/permit: reader's bound
  relay; dispatcher resolves the bound relay + inherited direction
- camera snapshots fire by direction site-wide, async, never block open
- DeviceConfig widened to nested JSON for relays[]

Web:
- wizard: no lane selector; add controllers (relay map + entry-button
  terminal) first, then bind readers/cameras/printers to a controller relay

Wiki: new entry-exit-points.md (replaces lane-direction); reworked
entry-exit-readers, parking-session, first-run-setup, device-registry,
append-only-event-chain, device-events; removed stale lane/LaneMap mentions.
2026-06-16 20:29:38 +02:00

159 lines
5.2 KiB
TypeScript

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<string, CachedStatus>();
/** Live adapter per device id (rebuilt when the set of printers changes). */
readonly #devices = new Map<string, { build: () => ReturnType<typeof registry.create>; meta: Omit<PrinterStatusEvent, "status"> }>();
#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;
// 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<void> {
const rows = await this.#db
.select()
.from(devices)
.where(eq(devices.category, "printer"))
.all();
const seen = new Set<string>();
for (const row of rows) {
if (!row.enabled) continue;
const driver = registry.get(row.driverId);
if (!driver) continue;
const cfg = row.config as Record<string, unknown>;
// 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<void> {
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<typeof registry.create>; meta: Omit<PrinterStatusEvent, "status"> }): Promise<void> {
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
);
}