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:
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user