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 { EventEmitter } from "node:events";
|
||||||
|
import type { PrinterStatus } from "@parking/devices";
|
||||||
|
|
||||||
// Internal event bus for device-originated events (button presses, etc.).
|
// Internal event bus for device-originated events (button presses, etc.).
|
||||||
// Hardware drivers / inbound device pushes emit here; business logic (entry
|
// Hardware drivers / inbound device pushes emit here; business logic (entry
|
||||||
@@ -14,6 +15,15 @@ export interface DeviceInputEvent {
|
|||||||
readonly source: "push" | "poll";
|
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 {
|
class DeviceEventBus extends EventEmitter {
|
||||||
emitInput(event: DeviceInputEvent): void {
|
emitInput(event: DeviceInputEvent): void {
|
||||||
this.emit("input", event);
|
this.emit("input", event);
|
||||||
@@ -22,6 +32,15 @@ class DeviceEventBus extends EventEmitter {
|
|||||||
this.on("input", cb);
|
this.on("input", cb);
|
||||||
return () => this.off("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. */
|
/** 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 Fastify, { type FastifyInstance } from "fastify";
|
||||||
import { createDb, type Db } from "@parking/db";
|
import { createDb, type Db } from "@parking/db";
|
||||||
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
|
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
|
||||||
|
import { PrinterMonitor } from "./printer-monitor.js";
|
||||||
import { authRoutes } from "./routes/auth.js";
|
import { authRoutes } from "./routes/auth.js";
|
||||||
import { deviceRoutes } from "./routes/devices.js";
|
import { deviceRoutes } from "./routes/devices.js";
|
||||||
|
import { printerRoutes } from "./routes/printers.js";
|
||||||
import { setupRoutes } from "./routes/setup.js";
|
import { setupRoutes } from "./routes/setup.js";
|
||||||
|
|
||||||
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify
|
// 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).
|
// the device's lane_devices config (written on assign).
|
||||||
await deviceRoutes(app, db);
|
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.
|
// TODO: entry flow (input event → signed event → print → relay), event-log routes.
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { registry } from "../registry.js";
|
import { registry } from "../registry.js";
|
||||||
import { dingtianDriver } from "./access-dingtian.js";
|
import { dingtianDriver } from "./access-dingtian.js";
|
||||||
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
||||||
|
import { rongtaDriver } from "./printer-rongta.js";
|
||||||
import { tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
import { tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
||||||
|
|
||||||
let registered = false;
|
let registered = false;
|
||||||
@@ -17,6 +18,7 @@ export function registerBuiltinDrivers(): void {
|
|||||||
registry.register(tcpipReaderDriver);
|
registry.register(tcpipReaderDriver);
|
||||||
registry.register(hikvisionDriver);
|
registry.register(hikvisionDriver);
|
||||||
registry.register(dahuaDriver);
|
registry.register(dahuaDriver);
|
||||||
|
registry.register(rongtaDriver);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -25,4 +27,5 @@ export {
|
|||||||
tcpipReaderDriver,
|
tcpipReaderDriver,
|
||||||
hikvisionDriver,
|
hikvisionDriver,
|
||||||
dahuaDriver,
|
dahuaDriver,
|
||||||
|
rongtaDriver,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<Record<StatusFlag, boolean>>;
|
||||||
|
|
||||||
|
/** Label text on the status page (NBSP/space-normalised, lowercased) → our key. */
|
||||||
|
const STATUS_FIELDS: Record<string, StatusFlag> = {
|
||||||
|
"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<string> {
|
||||||
|
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 `<TD>label</TD>
|
||||||
|
* <TD>Yes|No</TD>` 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[^>]*>([^<]*?)<\/TD>\s*<TD[^>]*>([^<]*?)<\/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<void> {
|
||||||
|
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<void> {
|
||||||
|
await this.healthCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
stubLog(this.driverId, "disconnect");
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(): Promise<DeviceHealth> {
|
||||||
|
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<void> {
|
||||||
|
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<PrinterStatus> {
|
||||||
|
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<StatusFlag, string> = {
|
||||||
|
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<PrinterDevice>).printTicket === "function";
|
||||||
|
}
|
||||||
@@ -15,4 +15,12 @@ export {
|
|||||||
tcpipReaderDriver,
|
tcpipReaderDriver,
|
||||||
hikvisionDriver,
|
hikvisionDriver,
|
||||||
dahuaDriver,
|
dahuaDriver,
|
||||||
|
rongtaDriver,
|
||||||
} from "./drivers/index.js";
|
} from "./drivers/index.js";
|
||||||
|
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
|
||||||
|
export {
|
||||||
|
orderForRole,
|
||||||
|
printWithFailover,
|
||||||
|
NoPrinterAvailableError,
|
||||||
|
type PrinterInstance,
|
||||||
|
} from "./printer-routing.js";
|
||||||
|
|||||||
@@ -191,3 +191,37 @@ export interface TicketData {
|
|||||||
export interface PrinterDevice extends Device {
|
export interface PrinterDevice extends Device {
|
||||||
printTicket(data: TicketData): Promise<void>;
|
printTicket(data: TicketData): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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<PrinterStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMonitorable(device: Device): device is Device & MonitorableDevice {
|
||||||
|
return typeof (device as Partial<MonitorableDevice>).readStatus === "function";
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<void>,
|
||||||
|
): Promise<string> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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://<host>/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]]).
|
||||||
@@ -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) |
|
| 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 |
|
| 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 |
|
| Casual identity | [[lpr-camera]] (Milesight, edge AI) | Plate = ticket + independent record |
|
||||||
| Ticket dispenser | Custom VKP80 | Parking-grade thermal/ESC-POS |
|
| Ticket dispenser | [[rongta-printer]] 80mm (entry-dispenser role) | ESC/POS over raw TCP 9100; driver written |
|
||||||
| Booth printer | Epson TM / Citizen (USB or network) | ESC/POS; one adapter covers both transports |
|
| 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** |
|
| 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 |
|
| Host machine | Fanless industrial PC + UPS + [[atecc608]] | Reliability, power-loss safety, offline signing |
|
||||||
| Network | Managed VLAN switch, PoE+ | Isolate the open control protocol |
|
| Network | Managed VLAN switch, PoE+ | Isolate the open control protocol |
|
||||||
|
|||||||
@@ -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://<host>/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]].
|
||||||
+4
-1
@@ -7,7 +7,7 @@ updated: 2026-06-14
|
|||||||
# Index
|
# Index
|
||||||
|
|
||||||
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
|
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 & navigation
|
||||||
- [[overview]] — the top-level synthesis and entry point.
|
- [[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.
|
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source.
|
||||||
- [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued.
|
- [[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).
|
- [[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).
|
- [[bom]] — reference bill of materials (barrier, loops, controller, readers, payment, host, network).
|
||||||
|
|
||||||
## Concepts — foundational forces
|
## 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-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).
|
- [[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.
|
- [[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.
|
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
|
||||||
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
||||||
|
|
||||||
|
|||||||
+22
@@ -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
|
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
|
login is COSMETIC, not a boundary — the signed event log remains the real
|
||||||
guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user