Rongta 80mm printer: driver, role-based failover, live status monitoring

Add the rongta PrinterDevice driver (ESC/POS over raw TCP 9100) and the
device-agnostic pieces around it:

- Roles + failover: each printer declares a role (entry-dispenser/booth-
  receipt) and failoverRank; printer-routing.ts picks the best healthy printer
  and falls back outside->booth for entry tickets (never the reverse).
- Live status: MonitorableDevice.readStatus()/PrinterStatus capability. The
  Rongta driver scrapes the device's own /prn_stat.htm (Cover/Cutter/Paper
  End/Near End/Off-Line) rather than hand-decoding DLE EOT, whose reply bytes
  on this clone don't match the canonical ESC/POS bit layout (verified on
  hardware) -- avoids a false-healthy. Maps to ready/degraded/offline, fail
  safe on an unreachable or unexpected page.
- Server PrinterMonitor polls enabled printers (PRINTER_POLL_MS, default 5s),
  caches latest, emits "printer-status" on change. Exposed via
  GET /api/printers/status and an SSE stream for the booth UI.

Verified against 10.0.10.6: ready when healthy, offline when unreachable
(no throw), bus emits on change and suppresses unchanged reads.

Wiki: new rongta-printer entity, printer-roles-failover and
printer-status-monitoring concepts; BOM/index/log updated.
This commit is contained in:
2026-06-14 20:26:45 +02:00
parent 2a86e578a8
commit b2a0471b08
15 changed files with 878 additions and 3 deletions
+159
View File
@@ -0,0 +1,159 @@
import type { FastifyBaseLogger } from "fastify";
import { eq, laneDevices, 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(laneDevices)
.where(eq(laneDevices.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,
lane: row.lane,
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} (lane ${entry.meta.lane}) -> ${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
);
}