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:
@@ -1,4 +1,5 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { PrinterStatus } from "@parking/devices";
|
||||
|
||||
// Internal event bus for device-originated events (button presses, etc.).
|
||||
// Hardware drivers / inbound device pushes emit here; business logic (entry
|
||||
@@ -14,6 +15,15 @@ export interface DeviceInputEvent {
|
||||
readonly source: "push" | "poll";
|
||||
}
|
||||
|
||||
/** A printer's status as tracked by the live monitor (status + identity). */
|
||||
export interface PrinterStatusEvent {
|
||||
readonly deviceId: string; // lane_devices id
|
||||
readonly lane: number;
|
||||
readonly driverId: string;
|
||||
readonly role?: string; // entry-dispenser | booth-receipt
|
||||
readonly status: PrinterStatus;
|
||||
}
|
||||
|
||||
class DeviceEventBus extends EventEmitter {
|
||||
emitInput(event: DeviceInputEvent): void {
|
||||
this.emit("input", event);
|
||||
@@ -22,6 +32,15 @@ class DeviceEventBus extends EventEmitter {
|
||||
this.on("input", cb);
|
||||
return () => this.off("input", cb);
|
||||
}
|
||||
|
||||
/** Emitted by the printer monitor whenever a printer's status CHANGES. */
|
||||
emitPrinterStatus(event: PrinterStatusEvent): void {
|
||||
this.emit("printer-status", event);
|
||||
}
|
||||
onPrinterStatus(cb: (event: PrinterStatusEvent) => void): () => void {
|
||||
this.on("printer-status", cb);
|
||||
return () => this.off("printer-status", cb);
|
||||
}
|
||||
}
|
||||
|
||||
/** Process-wide device event bus. */
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requireRole } from "../auth.js";
|
||||
import { deviceEvents } from "../device-events.js";
|
||||
import type { PrinterMonitor } from "../printer-monitor.js";
|
||||
|
||||
// Live printer-status API. The PrinterMonitor polls printers in the background;
|
||||
// these endpoints expose its cache (snapshot) and a live push stream (SSE) so the
|
||||
// booth UI shows paper-out / cover-open / offline in real time. Any authenticated
|
||||
// operator may read status (it's operational, not a setup action).
|
||||
|
||||
export async function printerRoutes(
|
||||
app: FastifyInstance,
|
||||
monitor: PrinterMonitor,
|
||||
): Promise<void> {
|
||||
const guard = requireRole("admin", "operator", "cashier", "readonly");
|
||||
|
||||
// Current status of every monitored printer (cached — no device round-trip).
|
||||
app.get("/api/printers/status", { preHandler: guard }, async () => ({
|
||||
printers: monitor.snapshot(),
|
||||
}));
|
||||
|
||||
// Live stream: emits the full snapshot on connect, then one event per change.
|
||||
// Server-Sent Events — one-way, survives proxies, trivially consumed by the SPA.
|
||||
app.get("/api/printers/status/stream", { preHandler: guard }, (req, reply) => {
|
||||
reply.raw.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-cache",
|
||||
connection: "keep-alive",
|
||||
});
|
||||
|
||||
const send = (event: string, data: unknown) => {
|
||||
reply.raw.write(`event: ${event}\n`);
|
||||
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
// Initial state so a fresh client doesn't wait for the next change.
|
||||
send("snapshot", { printers: monitor.snapshot() });
|
||||
|
||||
const unsubscribe = deviceEvents.onPrinterStatus((e) => send("status", e));
|
||||
|
||||
// Heartbeat keeps intermediaries from closing an idle connection.
|
||||
const heartbeat = setInterval(() => reply.raw.write(": ping\n\n"), 25000);
|
||||
heartbeat.unref?.();
|
||||
|
||||
req.raw.on("close", () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import jwt from "@fastify/jwt";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { createDb, type Db } from "@parking/db";
|
||||
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
|
||||
import { PrinterMonitor } from "./printer-monitor.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
import { deviceRoutes } from "./routes/devices.js";
|
||||
import { printerRoutes } from "./routes/printers.js";
|
||||
import { setupRoutes } from "./routes/setup.js";
|
||||
|
||||
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify
|
||||
@@ -49,6 +51,14 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// the device's lane_devices config (written on assign).
|
||||
await deviceRoutes(app, db);
|
||||
|
||||
// Live printer-status monitor: polls printers (paper/cover/cutter/offline) and
|
||||
// pushes changes to the booth UI. setupRoutes() has already registered the
|
||||
// built-in drivers the monitor needs. See wiki/concepts/printer-status-monitoring.md.
|
||||
const printerMonitor = new PrinterMonitor(db, app.log);
|
||||
await printerRoutes(app, printerMonitor);
|
||||
app.addHook("onReady", async () => printerMonitor.start());
|
||||
app.addHook("onClose", async () => printerMonitor.stop());
|
||||
|
||||
// TODO: entry flow (input event → signed event → print → relay), event-log routes.
|
||||
|
||||
return app;
|
||||
|
||||
Reference in New Issue
Block a user