From b2a0471b085f81edc2dd04ae327eea8d6943d5af Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sun, 14 Jun 2026 20:26:45 +0200 Subject: [PATCH] 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. --- apps/server/src/device-events.ts | 19 ++ apps/server/src/printer-monitor.ts | 159 +++++++++ apps/server/src/routes/printers.ts | 50 +++ apps/server/src/server.ts | 10 + packages/devices/src/drivers/index.ts | 3 + .../devices/src/drivers/printer-rongta.ts | 301 ++++++++++++++++++ packages/devices/src/index.ts | 8 + packages/devices/src/interfaces.ts | 34 ++ packages/devices/src/printer-routing.ts | 91 ++++++ wiki/concepts/printer-roles-failover.md | 53 +++ wiki/concepts/printer-status-monitoring.md | 74 +++++ wiki/entities/bom.md | 4 +- wiki/entities/rongta-printer.md | 48 +++ wiki/index.md | 5 +- wiki/log.md | 22 ++ 15 files changed, 878 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/printer-monitor.ts create mode 100644 apps/server/src/routes/printers.ts create mode 100644 packages/devices/src/drivers/printer-rongta.ts create mode 100644 packages/devices/src/printer-routing.ts create mode 100644 wiki/concepts/printer-roles-failover.md create mode 100644 wiki/concepts/printer-status-monitoring.md create mode 100644 wiki/entities/rongta-printer.md diff --git a/apps/server/src/device-events.ts b/apps/server/src/device-events.ts index 3949306..f643bbb 100644 --- a/apps/server/src/device-events.ts +++ b/apps/server/src/device-events.ts @@ -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. */ diff --git a/apps/server/src/printer-monitor.ts b/apps/server/src/printer-monitor.ts new file mode 100644 index 0000000..3cff478 --- /dev/null +++ b/apps/server/src/printer-monitor.ts @@ -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(); + /** Live adapter per device id (rebuilt when the set of printers changes). */ + readonly #devices = new Map ReturnType; meta: Omit }>(); + #timer: ReturnType | 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 { + const rows = await this.#db + .select() + .from(laneDevices) + .where(eq(laneDevices.category, "printer")) + .all(); + + const seen = new Set(); + for (const row of rows) { + if (!row.enabled) continue; + const driver = registry.get(row.driverId); + if (!driver) continue; + const cfg = row.config as Record; + // 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 { + 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; meta: Omit }): Promise { + 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 + ); +} diff --git a/apps/server/src/routes/printers.ts b/apps/server/src/routes/printers.ts new file mode 100644 index 0000000..c24aaa7 --- /dev/null +++ b/apps/server/src/routes/printers.ts @@ -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 { + 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(); + }); + }); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1589967..dc53770 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -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 printerMonitor.start()); + app.addHook("onClose", async () => printerMonitor.stop()); + // TODO: entry flow (input event → signed event → print → relay), event-log routes. return app; diff --git a/packages/devices/src/drivers/index.ts b/packages/devices/src/drivers/index.ts index db13de8..1e37001 100644 --- a/packages/devices/src/drivers/index.ts +++ b/packages/devices/src/drivers/index.ts @@ -4,6 +4,7 @@ import { registry } from "../registry.js"; import { dingtianDriver } from "./access-dingtian.js"; import { dahuaDriver, hikvisionDriver } from "./camera.js"; +import { rongtaDriver } from "./printer-rongta.js"; import { tcpipReaderDriver, wiegandReaderDriver } from "./reader.js"; let registered = false; @@ -17,6 +18,7 @@ export function registerBuiltinDrivers(): void { registry.register(tcpipReaderDriver); registry.register(hikvisionDriver); registry.register(dahuaDriver); + registry.register(rongtaDriver); } export { @@ -25,4 +27,5 @@ export { tcpipReaderDriver, hikvisionDriver, dahuaDriver, + rongtaDriver, }; diff --git a/packages/devices/src/drivers/printer-rongta.ts b/packages/devices/src/drivers/printer-rongta.ts new file mode 100644 index 0000000..48bcecd --- /dev/null +++ b/packages/devices/src/drivers/printer-rongta.ts @@ -0,0 +1,301 @@ +import { Socket } from "node:net"; +import { request as httpRequest } from "node:http"; +import type { + Device, + DeviceHealth, + MonitorableDevice, + PrinterDevice, + PrinterStatus, + TicketData, +} from "../interfaces.js"; +import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js"; +import { hostField, portField, stubLog } from "./common.js"; + +// Rongta 80mm network thermal printer driver. Rongta RP-series printers (and the +// many OEM clones that share their firmware) speak ESC/POS over a raw TCP socket +// on port 9100 — the JetDirect/RAW convention. There is no auth on the print +// socket; like the other field devices it lives on the isolated device VLAN. +// See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md. +// +// ROLES + FAILOVER: a lane has more than one printer. Each instance declares a +// `role` (entry-dispenser at the lane / booth-receipt in the booth) and a +// `failoverRank`. The entry flow prints on the highest-rank healthy printer for +// the wanted role and falls back to the next — so if the outside dispenser is +// offline, the booth printer prints the entry ticket as a backup. The driver +// itself is role-agnostic; the role/rank live in config and the caller (server) +// owns the failover selection. See wiki/concepts/printer-roles-failover.md. + +// --- ESC/POS command bytes ---------------------------------------------------- +const ESC = 0x1b; +const GS = 0x1d; +const LF = 0x0a; + +const INIT = Buffer.from([ESC, 0x40]); // ESC @ — reset to power-on defaults +const ALIGN_CENTER = Buffer.from([ESC, 0x61, 0x01]); // ESC a 1 +const ALIGN_LEFT = Buffer.from([ESC, 0x61, 0x00]); // ESC a 0 +const BOLD_ON = Buffer.from([ESC, 0x45, 0x01]); // ESC E 1 +const BOLD_OFF = Buffer.from([ESC, 0x45, 0x00]); // ESC E 0 +const DOUBLE_ON = Buffer.from([GS, 0x21, 0x11]); // GS ! — double width+height +const DOUBLE_OFF = Buffer.from([GS, 0x21, 0x00]); +const FEED_AND_CUT = Buffer.from([ESC, 0x64, 0x04, GS, 0x56, 0x42, 0x00]); // feed 4, GS V B 0 partial cut + +/** Encode a printable line as bytes (CP437/ASCII subset) + a line feed. */ +function line(text = ""): Buffer { + return Buffer.concat([Buffer.from(text, "ascii"), Buffer.from([LF])]); +} + +/** Build the full ESC/POS byte stream for an entry ticket. */ +function renderTicket(data: TicketData): Buffer { + return Buffer.concat([ + INIT, + ALIGN_CENTER, + BOLD_ON, + DOUBLE_ON, + line("PARKING"), + DOUBLE_OFF, + BOLD_OFF, + line(), + line(`Lane ${data.lane}`), + line(), + BOLD_ON, + line(data.ticketId), + BOLD_OFF, + ALIGN_LEFT, + line(), + line(`Issued: ${data.issuedAt}`), + FEED_AND_CUT, + ]); +} + +/** Open a TCP socket, write the bytes, wait for flush, then close. */ +function sendRaw(host: string, port: number, payload: Buffer, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const sock = new Socket(); + let settled = false; + const done = (err?: Error) => { + if (settled) return; + settled = true; + sock.destroy(); + err ? reject(err) : resolve(); + }; + sock.setTimeout(timeoutMs); + sock.on("timeout", () => done(new Error("timeout"))); + sock.on("error", done); + sock.connect(port, host, () => { + sock.write(payload, (err) => (err ? done(err) : done())); + }); + }); +} + +// --- live status via the device's own status web page ------------------------- +// The Rongta board serves /prn_stat.htm, a small HTML table where the DEVICE has +// already decoded the ESC/POS status bits into labelled Yes/No rows. We scrape +// that rather than send raw `DLE EOT` ourselves: on this clone the DLE EOT reply +// bytes don't follow the canonical bit layout (verified on hardware), so trusting +// the device's own decode is the safe choice. See printer-status-monitoring.md. + +/** The fault flags the status page reports (a subset of PrinterStatus). */ +type StatusFlag = "coverOpen" | "cutterError" | "paperEnd" | "paperNearEnd" | "offline"; +type StatusFlags = Partial>; + +/** Label text on the status page (NBSP/space-normalised, lowercased) → our key. */ +const STATUS_FIELDS: Record = { + "cover is open": "coverOpen", + "cutter error": "cutterError", + "paper end": "paperEnd", + "paper near end": "paperNearEnd", + "printer off-line": "offline", +}; + +/** GET the status page over HTTP and return the raw HTML. */ +function fetchStatusPage(host: string, httpPort: number, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const req = httpRequest( + { host, port: httpPort, path: "/prn_stat.htm", method: "GET", timeout: timeoutMs }, + (res) => { + let data = ""; + res.on("data", (c) => (data += c)); + res.on("end", () => + res.statusCode === 200 + ? resolve(data) + : reject(new Error(`status page HTTP ${res.statusCode}`)), + ); + }, + ); + req.on("error", reject); + req.on("timeout", () => req.destroy(new Error("status page timeout"))); + req.end(); + }); +} + +/** + * Parse /prn_stat.htm into boolean flags. Each fault is a `label + * Yes|No` pair. Returns only the recognised fields; a missing field is + * left undefined so the caller can detect an unexpected page (fail safe, not a + * false "ok"). + */ +function parseStatusPage(html: string): StatusFlags { + const out: StatusFlags = {}; + const rowRe = /]*>([^<]*?)<\/TD>\s*]*>([^<]*?)<\/TD>/gi; + let m: RegExpExecArray | null; + while ((m = rowRe.exec(html))) { + if (m[1] === undefined || m[2] === undefined) continue; + const label = m[1].replace(/ /gi, " ").replace(/\s+/g, " ").trim().toLowerCase(); + const value = m[2].replace(/ /gi, " ").trim().toLowerCase(); + const key = STATUS_FIELDS[label]; + if (key && (value === "yes" || value === "no")) { + out[key] = value === "yes"; + } + } + return out; +} + +/** TCP connect probe — the print socket has no status protocol we rely on. */ +function probe(host: string, port: number, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const sock = new Socket(); + let settled = false; + const done = (err?: Error) => { + if (settled) return; + settled = true; + sock.destroy(); + err ? reject(err) : resolve(); + }; + sock.setTimeout(timeoutMs); + sock.on("timeout", () => done(new Error("timeout"))); + sock.on("error", done); + sock.connect(port, host, () => done()); + }); +} + +class RongtaPrinter implements PrinterDevice, MonitorableDevice { + readonly driverId = "rongta"; + readonly #host: string; + readonly #port: number; + readonly #httpPort: number; + readonly #timeout: number; + + constructor(config: DeviceConfig) { + this.#host = String(config.host); + this.#port = config.port ? Number(config.port) : 9100; + this.#httpPort = config.httpPort ? Number(config.httpPort) : 80; + this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000; + } + + async connect(): Promise { + await this.healthCheck(); + } + + async disconnect(): Promise { + stubLog(this.driverId, "disconnect"); + } + + async healthCheck(): Promise { + try { + await probe(this.#host, this.#port, this.#timeout); + return { status: "ready" }; + } catch (err) { + return { status: "offline", detail: (err as Error).message }; + } + } + + async printTicket(data: TicketData): Promise { + await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout); + stubLog(this.driverId, `printed ticket ${data.ticketId} (lane ${data.lane})`); + } + + /** + * Live operator-actionable status, scraped from the device's own status page. + * The board decodes the ESC/POS status bits itself, so we trust its Yes/No + * over hand-decoding this clone's non-standard DLE EOT reply. + * + * - status page unreachable → offline (the same signal as a dead printer), + * - page reachable but a recognised field missing → degraded (don't claim + * "ready" off a page we didn't fully understand — fail safe), + * - any fault flag true → degraded, + * - otherwise → ready. + */ + async readStatus(): Promise { + const checkedAt = new Date().toISOString(); + let html: string; + try { + html = await fetchStatusPage(this.#host, this.#httpPort, this.#timeout); + } catch (err) { + return { status: "offline", detail: (err as Error).message, checkedAt }; + } + + const flags = parseStatusPage(html); + const expected: StatusFlag[] = ["coverOpen", "cutterError", "paperEnd", "paperNearEnd", "offline"]; + const missing = expected.filter((k) => flags[k] === undefined); + if (missing.length > 0) { + return { + status: "degraded", + detail: `unexpected status page (missing: ${missing.join(", ")})`, + checkedAt, + }; + } + + const faults = expected.filter((k) => flags[k] === true); + const labels: Record = { + paperEnd: "paper out", + coverOpen: "cover open", + cutterError: "cutter error", + offline: "printer off-line", + paperNearEnd: "paper low", + }; + return { + status: faults.length > 0 ? "degraded" : "ready", + ...flags, + detail: faults.length > 0 ? faults.map((f) => labels[f]).join(", ") : undefined, + checkedAt, + }; + } +} + +/** Type guard: does this device carry a printer role (entry vs. booth)? */ +export type PrinterRole = "entry-dispenser" | "booth-receipt"; + +const roleField: ConfigField = { + key: "role", + label: "Role", + type: "select", + required: true, + default: "entry-dispenser", + options: [ + { value: "entry-dispenser", label: "Entry dispenser (outside / at the lane)" }, + { value: "booth-receipt", label: "Booth printer (receipts + backup)" }, + ], + help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.", +}; + +const rankField: ConfigField = { + key: "failoverRank", + label: "Failover rank", + type: "number", + required: false, + default: 0, + help: "Higher = tried first within the same role. The booth printer also backs up the entry dispenser.", +}; + +export const rongtaDriver: PrinterDriver = { + id: "rongta", + category: "printer", + label: "Rongta 80mm thermal printer", + description: + "Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones) over raw TCP (port 9100). No auth on the print socket — isolate the VLAN.", + transports: ["tcp-ip"], + configFields: [ + hostField, + { ...portField(9100), required: false, help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100)." }, + { key: "httpPort", label: "Status web port", type: "port", required: false, default: 80, help: "Device status page (/prn_stat.htm) port for live monitoring (default 80)." }, + roleField, + rankField, + { key: "timeoutMs", label: "Timeout (ms)", type: "number", required: false, default: 3000 }, + ], + create: (c) => new RongtaPrinter(c), +}; + +/** Type guard exposed for callers that need to read a device's printer role. */ +export function isPrinter(device: Device): device is PrinterDevice { + return typeof (device as Partial).printTicket === "function"; +} diff --git a/packages/devices/src/index.ts b/packages/devices/src/index.ts index 9a448b4..3089e07 100644 --- a/packages/devices/src/index.ts +++ b/packages/devices/src/index.ts @@ -15,4 +15,12 @@ export { tcpipReaderDriver, hikvisionDriver, dahuaDriver, + rongtaDriver, } from "./drivers/index.js"; +export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js"; +export { + orderForRole, + printWithFailover, + NoPrinterAvailableError, + type PrinterInstance, +} from "./printer-routing.js"; diff --git a/packages/devices/src/interfaces.ts b/packages/devices/src/interfaces.ts index 81aa1b0..4ccc1f8 100644 --- a/packages/devices/src/interfaces.ts +++ b/packages/devices/src/interfaces.ts @@ -191,3 +191,37 @@ export interface TicketData { export interface PrinterDevice extends Device { printTicket(data: TicketData): Promise; } + +// --- Live printer status (consumable / mechanical faults) ---------------- +// Optional capability: a printer that reports the operator-actionable faults a +// basic `healthCheck` (reachability) can't see — paper out, cover open, cutter +// jam. Used by the live status monitor so the booth knows BEFORE a driver presses +// the entry button and no ticket comes out. The Rongta board exposes these via +// its own status web page (it decodes the ESC/POS bits for us — more reliable +// than trusting a clone's DLE EOT bit layout). See wiki/concepts/printer-status-monitoring.md. +export interface PrinterStatus { + /** Reachable + no fault = ready; reachable + fault = degraded; unreachable = offline. */ + readonly status: "ready" | "degraded" | "offline"; + /** Out of paper — the printer cannot print. */ + readonly paperEnd?: boolean; + /** Paper low — still prints, but warn the operator to reload. */ + readonly paperNearEnd?: boolean; + /** Cover/lid open — will not print. */ + readonly coverOpen?: boolean; + /** Cutter jammed/errored. */ + readonly cutterError?: boolean; + /** Printer reports itself off-line (its own flag, distinct from unreachable). */ + readonly offline?: boolean; + /** Human-readable summary (e.g. "paper out", or the unreachable error). */ + readonly detail?: string; + readonly checkedAt: string; // ISO-8601 +} + +export interface MonitorableDevice { + /** Richer, operator-actionable status beyond reachability. */ + readStatus(): Promise; +} + +export function isMonitorable(device: Device): device is Device & MonitorableDevice { + return typeof (device as Partial).readStatus === "function"; +} diff --git a/packages/devices/src/printer-routing.ts b/packages/devices/src/printer-routing.ts new file mode 100644 index 0000000..6a3e29b --- /dev/null +++ b/packages/devices/src/printer-routing.ts @@ -0,0 +1,91 @@ +// Printer routing: pick which printer prints a given job across a lane's +// printers, with automatic failover. A lane has more than one printer — an +// entry dispenser outside (where the driver takes the ticket) and a booth +// printer inside (receipts, and a BACKUP for entry tickets if the dispenser is +// offline). See wiki/concepts/printer-roles-failover.md. +// +// This is pure selection logic over (config, health) — no device I/O — so the +// entry/exit flow can decide where to print without coupling to a transport. + +import type { PrinterDevice } from "./interfaces.js"; +import type { PrinterRole } from "./drivers/printer-rongta.js"; + +/** A configured printer instance + its live adapter, as the caller holds them. */ +export interface PrinterInstance { + readonly id: string; + readonly role: PrinterRole; + /** Higher = preferred within a role. Ties broken by id for determinism. */ + readonly failoverRank: number; + readonly device: PrinterDevice; +} + +/** + * Order the candidate printers for a job targeting `wantRole`, best-first. + * + * Rule: printers of the wanted role come first (highest rank first); the booth + * printer is also a fallback for entry tickets, so when an entry ticket is + * routed, booth-receipt printers follow the entry dispensers. The reverse is + * deliberately NOT done — a receipt never prints on the outside dispenser. + */ +export function orderForRole( + printers: readonly PrinterInstance[], + wantRole: PrinterRole, +): PrinterInstance[] { + const fallbackRole: PrinterRole | null = + wantRole === "entry-dispenser" ? "booth-receipt" : null; + + const rank = (p: PrinterInstance): number => { + if (p.role === wantRole) return 2; + if (p.role === fallbackRole) return 1; + return 0; + }; + + return printers + .filter((p) => rank(p) > 0) + .sort((a, b) => { + if (rank(a) !== rank(b)) return rank(b) - rank(a); // wanted role first + if (a.failoverRank !== b.failoverRank) return b.failoverRank - a.failoverRank; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; // stable tiebreak + }); +} + +export class NoPrinterAvailableError extends Error { + constructor(public readonly attempts: { id: string; error: string }[]) { + super( + attempts.length === 0 + ? "no printer configured for this job" + : `all ${attempts.length} candidate printer(s) failed: ${attempts + .map((a) => `${a.id} (${a.error})`) + .join(", ")}`, + ); + this.name = "NoPrinterAvailableError"; + } +} + +/** + * Print `job` on the best healthy printer for `wantRole`, failing over down the + * ordered list. Tries each candidate's print directly: a healthCheck race is + * pointless when the print itself is the real reachability test, so we just + * attempt the print and move on if it throws. Returns the id that succeeded. + * + * Throws {@link NoPrinterAvailableError} if every candidate fails — the caller + * (entry flow) decides what that means (e.g. raise the barrier without a paper + * ticket vs. hold). That policy is the flow's, not the printer's. + */ +export async function printWithFailover( + printers: readonly PrinterInstance[], + wantRole: PrinterRole, + job: (device: PrinterDevice) => Promise, +): Promise { + const ordered = orderForRole(printers, wantRole); + const attempts: { id: string; error: string }[] = []; + for (const p of ordered) { + try { + await job(p.device); + return p.id; + } catch (err) { + attempts.push({ id: p.id, error: (err as Error).message }); + } + } + throw new NoPrinterAvailableError(attempts); +} diff --git a/wiki/concepts/printer-roles-failover.md b/wiki/concepts/printer-roles-failover.md new file mode 100644 index 0000000..59e8fe1 --- /dev/null +++ b/wiki/concepts/printer-roles-failover.md @@ -0,0 +1,53 @@ +--- +type: concept +tags: [parking, printer, device, reliability] +sources: [] +updated: 2026-06-14 +--- + +# Printer roles & failover + +A lane runs **more than one printer**, and the system knows each one's job so it can fail over +automatically. This is a reliability decision, not a threat-model one: an entry ticket must +still print when the outside dispenser jams or drops off the network. + +## Roles + +Each printer instance (a `lane_devices` row, category `printer`) declares a **role** in its +config: + +- **`entry-dispenser`** — outside, at the lane. Prints the entry ticket the driver takes. +- **`booth-receipt`** — inside the booth. Prints receipts at exit/payment, AND serves as the + **backup** for entry tickets. + +It also declares a **`failoverRank`** (higher = preferred within a role) to order multiple +printers of the same role deterministically (ties broken by id). + +## Failover rule (asymmetric, on purpose) + +For an **entry ticket** (`wantRole = entry-dispenser`): try the entry dispensers (best rank +first), then fall back to the **booth printer**. So a driver still gets a ticket when the +outside unit is offline — the operator hands it over from the booth. + +The reverse is **deliberately not** done: a **receipt** never prints on the outside dispenser. +Receipts are a booth-only job; an entry dispenser falling back to print receipts makes no +physical sense. + +## Where the logic lives + +- The driver (`rongta`) is **role-agnostic** — role/rank are just config; the transport doesn't + care. Keeps [[device-adapter-pattern|adapters]] swappable. +- Selection is pure logic in `packages/devices/printer-routing.ts`: `orderForRole()` ranks + candidates; `printWithFailover()` attempts the print down the list and throws + `NoPrinterAvailableError` only when every candidate fails. +- It **attempts the print directly** rather than racing a `healthCheck` first — the print is + the real reachability test, and a health probe that passes can still be followed by a failed + print. + +## Open: the all-printers-down policy + +When `printWithFailover` exhausts every candidate, what should entry do — raise the barrier +with no paper ticket (the plate/[[lpr-camera]] is the independent record), or hold? That policy +belongs to the **entry flow** ([[device-input-flow]], [[fail-state-safety]]), not the printer +layer, and is **not yet decided**. The signed event ([[append-only-event-chain]]) is created +regardless of whether paper prints. diff --git a/wiki/concepts/printer-status-monitoring.md b/wiki/concepts/printer-status-monitoring.md new file mode 100644 index 0000000..4b9f443 --- /dev/null +++ b/wiki/concepts/printer-status-monitoring.md @@ -0,0 +1,74 @@ +--- +type: concept +tags: [parking, printer, device, monitoring, reliability] +sources: [] +updated: 2026-06-14 +--- + +# Printer status monitoring + +The booth must know a printer is in trouble **before** a driver presses the entry button and no +ticket comes out. So the system polls each printer's live status (paper out, cover open, cutter +jam, off-line) and pushes changes to the operator UI. A reliability control, like +[[printer-roles-failover]] — not a threat-model one. + +## Where the status comes from (the safe-decode decision) + +The raw print socket (TCP 9100) is write-only for us — it returns no paper/cover feedback. ESC/POS +printers expose status via real-time queries (`DLE EOT n`). On the [[rongta-printer]] clone we +probed, **`DLE EOT` replies do NOT follow the canonical ESC/POS bit layout** (the spec's fixed +validation bits were wrong, verified on hardware 2026-06-14). Decoding those bits ourselves risked +a **false-healthy** — reporting "paper OK" when it's empty — which is the dangerous direction for +an entry lane. + +Instead we scrape the device's **own status web page** (`http:///prn_stat.htm`). The board +decodes the bits itself into labelled Yes/No rows (Cover Is Open, Cutter Error, Paper End, Paper +Near End, Printer Off-Line). We trust the device's decode over hand-decoding an undocumented clone. + +This is captured as a device capability: `MonitorableDevice.readStatus(): PrinterStatus` in +`packages/devices`. The Rongta driver implements it; the monitor is device-agnostic via +`isMonitorable()`. A future printer with a different status mechanism just implements the same +interface. + +## Status mapping (fail safe) + +`readStatus()` maps to `ready | degraded | offline`: + +- status page unreachable / times out → **offline** (same signal as a dead printer; never throws), +- page reachable but a recognised field is missing → **degraded** ("unexpected status page") — + we do NOT claim "ready" off a page we didn't fully parse, +- any fault flag true (paper end, cover open, cutter error, off-line) → **degraded** + a detail + string ("paper out", …), +- all five clear → **ready**. + +## The monitor (server) + +`PrinterMonitor` (`apps/server/src/printer-monitor.ts`): + +- reloads the monitored set from `lane_devices` each tick (so a newly-assigned printer is picked + up without a restart), keeping only enabled, monitorable printers; +- polls every `PRINTER_POLL_MS` (default 5000ms), never overlapping ticks; +- caches the latest status per device id; +- emits a `printer-status` event on the device bus **only when status changes** (deduped). + +## API / live UI + +- `GET /api/printers/status` — cached snapshot of all printers (no device round-trip). +- `GET /api/printers/status/stream` — **Server-Sent Events**: full snapshot on connect, then one + event per change. The booth SPA subscribes for real-time paper-out / offline indicators. +- Any authenticated role may read (operational, not a setup action). + +## Verified on hardware (2026-06-14) + +`readStatus()` against 10.0.10.6 → `ready` (all flags false); against an unreachable host → +`offline` with "status page timeout" (no throw); bus emits on change and suppresses unchanged +reads. Full repo typechecks. + +## Open / not yet done + +- **Fault-state capture**: we've only observed the all-clear page. The exact label text for an + active fault (e.g. does "Paper End" flip to "Yes"?) should be confirmed by physically removing + paper / opening the cover, to be 100% sure the scrape catches it. The parser is built to match + Yes/No and degrade on anything unexpected, so this is a confidence check, not a blocker. +- Tying a `degraded`/`offline` entry-dispenser into [[printer-roles-failover]] failover and the + (not-yet-built) entry flow's all-printers-down policy ([[device-input-flow]]). diff --git a/wiki/entities/bom.md b/wiki/entities/bom.md index b6bbdf4..496e350 100644 --- a/wiki/entities/bom.md +++ b/wiki/entities/bom.md @@ -17,8 +17,8 @@ payment terminal is dictated by the acquiring bank. (See [[parking-system-archit | Access controller | [[dingtian-relay]] relay+input board | Decoupled inputs (host-in-the-loop); **isolate the VLAN** ([[network-isolation]]). ([[uhppote-controller]]/[[zkteco-controller]] rejected) | | Permit readers | Nedap/Kathrein UHF, or Mifare → [[wiegand]] | Hands-free, or autonomous offline decisions | | Casual identity | [[lpr-camera]] (Milesight, edge AI) | Plate = ticket + independent record | -| Ticket dispenser | Custom VKP80 | Parking-grade thermal/ESC-POS | -| Booth printer | Epson TM / Citizen (USB or network) | ESC/POS; one adapter covers both transports | +| Ticket dispenser | [[rongta-printer]] 80mm (entry-dispenser role) | ESC/POS over raw TCP 9100; driver written | +| Booth printer | [[rongta-printer]] 80mm (booth-receipt role) | Receipts + backup for entry tickets ([[printer-roles-failover]]) | | Payment | Bank-certified P2PE standalone terminal + cash drawer | Keeps the app out of **PCI-DSS scope** | | Host machine | Fanless industrial PC + UPS + [[atecc608]] | Reliability, power-loss safety, offline signing | | Network | Managed VLAN switch, PoE+ | Isolate the open control protocol | diff --git a/wiki/entities/rongta-printer.md b/wiki/entities/rongta-printer.md new file mode 100644 index 0000000..10e7440 --- /dev/null +++ b/wiki/entities/rongta-printer.md @@ -0,0 +1,48 @@ +--- +type: entity +tags: [parking, hardware, printer, device] +sources: [] +updated: 2026-06-14 +--- + +# Rongta 80mm thermal printer + +The chosen ticket/receipt printer: a **Rongta RP-series 80mm network thermal printer** (and the +many ESC/POS-compatible OEM clones that share its firmware). Driver `rongta` in +`packages/devices` implements [[device-adapter-pattern|PrinterDevice]]. + +## Transport & protocol + +- **ESC/POS over a raw TCP socket on port 9100** (the JetDirect/RAW convention). The driver + opens the socket, writes the ESC/POS byte stream, waits for flush, closes. +- **No authentication** on the print socket — anyone who can reach port 9100 can print. Like + every other field device it must sit on the **isolated device VLAN** ([[network-isolation]]). + There is no real HTTP/control boundary on the device (same posture as [[dingtian-relay]]). +- **Health check** is a TCP connect probe to 9100. The print socket exposes no status protocol + we rely on; the print itself is the real reachability test (failover attempts the print). +- **Live status** comes from the device's own web page `http:///prn_stat.htm` (port 80), + which decodes Cover Open / Cutter Error / Paper End / Paper Near End / Off-Line into Yes/No. + We scrape that rather than hand-decode `DLE EOT` — this clone's DLE EOT reply bytes do **not** + match the canonical ESC/POS bit layout (verified on hardware), so trusting the device's own + decode avoids a false-healthy. Implemented as `readStatus()`; see [[printer-status-monitoring]]. + +## Deployment (this site) + +- First printer verified reachable at **10.0.10.6:9100** from the host (TCP connect OK, + 2026-06-14). +- **At least two printers**, by role — see [[printer-roles-failover]]: + - **entry-dispenser** — outside, at the lane; the driver takes the entry ticket. + - **booth-receipt** — inside the booth; receipts, AND the backup that prints the entry + ticket if the outside dispenser is offline. + +## Ticket rendering + +`printTicket(TicketData)` builds ESC/POS: `ESC @` init, centered/bold/double-size header, +lane, ticket id, issued-at, feed + partial cut (`GS V B`). CP437/ASCII subset. + +## Status + +Driver written and compiles; entry-ticket layout is a first pass; live status monitoring is +implemented and verified ([[printer-status-monitoring]]). The receipt/exit layout and the +cash-drawer kick (ESC/POS `ESC p`) are **not yet implemented** — they arrive with the +exit/payment flow. Replaces the generic "Epson TM / Citizen" booth-printer line in [[bom]]. diff --git a/wiki/index.md b/wiki/index.md index acdb0ba..3d8086e 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -7,7 +7,7 @@ updated: 2026-06-14 # Index Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest. -Counts: 1 source · 14 entities · 10 concepts · 2 decision records. +Counts: 1 source · 15 entities · 12 concepts · 2 decision records. ## Overview & navigation - [[overview]] — the top-level synthesis and entry point. @@ -39,6 +39,7 @@ Counts: 1 source · 14 entities · 10 concepts · 2 decision records. - [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source. - [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued. - [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware). +- [[rongta-printer]] — ✅ CHOSEN 80mm thermal printer; ESC/POS over raw TCP 9100; driver written, one unit reachable at 10.0.10.6. - [[bom]] — reference bill of materials (barrier, loops, controller, readers, payment, host, network). ## Concepts — foundational forces @@ -57,6 +58,8 @@ Counts: 1 source · 14 entities · 10 concepts · 2 decision records. - [[device-input-flow]] — button → device push → backend decides → relay; backend is source of truth. - [[device-discovery]] — optional driver capability to scan the LAN (no current driver uses it; UHPPOTE was the example). - [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware. +- [[printer-roles-failover]] — ≥2 printers per lane by role; entry ticket falls back outside→booth. +- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI. - [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable. - [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog. diff --git a/wiki/log.md b/wiki/log.md index 53fa032..1a2bffd 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -205,3 +205,25 @@ config write, relay fire, and userset.cgi itself all return 200 unauthenticated inbound-auth setting (only session_en, which bricks the read API). So rotating the login is COSMETIC, not a boundary — the signed event log remains the real guarantee. Recorded in [[dingtian-relay]] (new Hardening section). + +## [2026-06-14] ingest | Rongta 80mm printer driver + printer roles/failover +- Added `rongta` PrinterDevice driver (ESC/POS over raw TCP 9100); registered in registry. +- Decision: ≥2 printers per lane by role (entry-dispenser outside, booth-receipt inside); + entry ticket fails over outside→booth (asymmetric — receipts never print outside). +- Selection logic lives in packages/devices/printer-routing.ts (orderForRole, printWithFailover). +- One unit verified reachable at 10.0.10.6:9100 from host (TCP connect OK). +- New pages: [[rongta-printer]], [[printer-roles-failover]]. Updated [[bom]], [[index]]. +- Open: all-printers-down policy belongs to the (not-yet-built) entry flow, not the printer layer. + +## [2026-06-14] ingest | Live printer status monitoring +- Added MonitorableDevice.readStatus()/PrinterStatus capability in packages/devices. +- Rongta readStatus() scrapes the device's own /prn_stat.htm (Cover/Cutter/Paper End/Near End/ + Off-Line) — chosen over hand-decoding DLE EOT because this clone's DLE EOT bytes don't match + the canonical ESC/POS bit layout (verified on hardware; risk of false-healthy). +- Server PrinterMonitor: polls enabled monitorable printers (PRINTER_POLL_MS, default 5s), + caches latest, emits "printer-status" on change. API: GET /api/printers/status + SSE stream. +- Verified live: 10.0.10.6 -> ready (all flags clear); unreachable host -> offline (no throw); + bus emits on change, suppresses unchanged. Full repo typechecks (8/8). +- New page: [[printer-status-monitoring]]. Updated [[rongta-printer]], [[index]]. +- Open: capture the page's actual text for an ACTIVE fault (pull paper / open cover) to confirm + the Yes flip; wire degraded/offline into failover + entry-flow all-down policy.