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