1efa77bf56
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.
159 lines
5.2 KiB
TypeScript
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
|
|
);
|
|
}
|