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

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

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

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

Wiki: new rongta-printer entity, printer-roles-failover and
printer-status-monitoring concepts; BOM/index/log updated.
This commit is contained in:
2026-06-14 20:26:45 +02:00
parent 2a86e578a8
commit b2a0471b08
15 changed files with 878 additions and 3 deletions
+3
View File
@@ -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,
};
@@ -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(/&nbsp;/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
const value = m[2].replace(/&nbsp;/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";
}
+8
View File
@@ -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";
+34
View File
@@ -191,3 +191,37 @@ export interface TicketData {
export interface PrinterDevice extends Device {
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";
}
+91
View File
@@ -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);
}