import { request as httpRequest } from "node:http"; import type { Device, DeviceHealth, MonitorableDevice, PrinterDevice, PrinterStatus, PrintReport, SubscriptionCardData, TicketData, WindowChargeNoticeData, } from "../interfaces.js"; import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js"; import { hostField, portField, stubLog } from "./common.js"; import { devicePathField, probeTo, renderReceipt, renderReport, renderSubscriptionCard, renderTicket, renderWindowChargeNotice, sendTo, transportField, transportFromConfig, type Transport, } from "./printer-escpos.js"; // Rongta 80mm thermal printer driver (network OR USB). 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 — or over a local USB usblp // char device. The ESC/POS rendering + transport are shared with the other ESC/POS // clones in ./printer-escpos.ts (config.transport picks the wire); what is unique to // Rongta — and lives here — is LIVE STATUS via the board's own status web page. That // page is a NETWORK feature: a USB Rongta degrades to reachability-only monitoring // (see readStatus). There is no auth on the print socket; like the other field // devices a networked unit 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. // --- 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. // A clone that does NOT serve this page (e.g. the Cashino) uses its own driver // with a plain reachability probe — it must not pretend to report paper/cover. /** The fault flags the status page reports (a subset of PrinterStatus). */ type StatusFlag = | "coverOpen" | "cutterError" | "paperEnd" | "paperNearEnd" | "offline"; type StatusFlags = Partial>; /** Label text on the status page (NBSP/space-normalised, lowercased) → our key. */ const STATUS_FIELDS: Record = { "cover is open": "coverOpen", "cutter error": "cutterError", "paper end": "paperEnd", "paper near end": "paperNearEnd", "printer off-line": "offline", }; /** GET the status page over HTTP and return the raw HTML. */ function fetchStatusPage( host: string, httpPort: number, timeoutMs: number, ): Promise { return new Promise((resolve, reject) => { const req = httpRequest( { host, port: httpPort, path: "/prn_stat.htm", method: "GET", timeout: timeoutMs, }, (res) => { let data = ""; res.on("data", (c) => (data += c)); res.on("end", () => res.statusCode === 200 ? resolve(data) : reject(new Error(`status page HTTP ${res.statusCode}`)), ); }, ); req.on("error", reject); req.on("timeout", () => req.destroy(new Error("status page timeout"))); req.end(); }); } /** * Parse /prn_stat.htm into boolean flags. Each fault is a `label * Yes|No` pair. Returns only the recognised fields; a missing field is * left undefined so the caller can detect an unexpected page (fail safe, not a * false "ok"). */ function parseStatusPage(html: string): StatusFlags { const out: StatusFlags = {}; const rowRe = /]*>([^<]*?)<\/TD>\s*]*>([^<]*?)<\/TD>/gi; let m: RegExpExecArray | null; while ((m = rowRe.exec(html))) { if (m[1] === undefined || m[2] === undefined) continue; const label = m[1] .replace(/ /gi, " ") .replace(/\s+/g, " ") .trim() .toLowerCase(); const value = m[2] .replace(/ /gi, " ") .trim() .toLowerCase(); const key = STATUS_FIELDS[label]; if (key && (value === "yes" || value === "no")) { out[key] = value === "yes"; } } return out; } class RongtaPrinter implements PrinterDevice, MonitorableDevice { readonly driverId = "rongta"; readonly #transport: Transport; readonly #host: string; readonly #httpPort: number; readonly #timeout: number; constructor(config: DeviceConfig) { this.#transport = transportFromConfig(config); // Kept for the HTTP status page (TCP only); empty on a USB printer. this.#host = config.host ? String(config.host) : ""; this.#httpPort = config.httpPort ? Number(config.httpPort) : 80; this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000; } async connect(): Promise { await this.healthCheck(); } async disconnect(): Promise { stubLog(this.driverId, "disconnect"); } async healthCheck(): Promise { try { await probeTo(this.#transport, this.#timeout); return { status: "ready" }; } catch (err) { return { status: "offline", detail: (err as Error).message }; } } async printTicket(data: TicketData): Promise { await sendTo(this.#transport, renderTicket(data), this.#timeout); stubLog(this.driverId, `printed ticket ${data.ticketId}`); } async printReport(report: PrintReport): Promise { await sendTo(this.#transport, renderReport(report), this.#timeout); stubLog( this.driverId, `printed report "${report.title}" (${report.lines.length} lines)`, ); } async printSubscriptionCard(data: SubscriptionCardData): Promise { await sendTo(this.#transport, renderSubscriptionCard(data), this.#timeout); stubLog(this.driverId, `printed subscription card ${data.code}`); } async printReceipt(data: import("../interfaces.js").ReceiptData): Promise { await sendTo(this.#transport, renderReceipt(data), this.#timeout); stubLog( this.driverId, `printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`, ); } async printWindowChargeNotice(data: WindowChargeNoticeData): Promise { await sendTo(this.#transport, renderWindowChargeNotice(data), this.#timeout); stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`); } /** * Live operator-actionable status, scraped from the device's own status page. * The board decodes the ESC/POS status bits itself, so we trust its Yes/No * over hand-decoding this clone's non-standard DLE EOT reply. * * - status page unreachable → offline (the same signal as a dead printer), * - page reachable but a recognised field missing → degraded (don't claim * "ready" off a page we didn't fully understand — fail safe), * - any fault flag true → degraded, * - otherwise → ready. */ async readStatus(): Promise { const checkedAt = new Date().toISOString(); // The status page is an HTTP feature of the network board; a USB printer has no // such page. Degrade to the reachability floor (open the char device) and report // ready/offline only — never a guessed paper/cover state, same honesty rule as // the Cashino. (A USB Rongta is effectively a Cashino for monitoring purposes.) if (this.#transport.kind === "usb") { try { await probeTo(this.#transport, this.#timeout); return { status: "ready", checkedAt }; } catch (err) { return { status: "offline", detail: (err as Error).message, checkedAt }; } } let html: string; try { html = await fetchStatusPage(this.#host, this.#httpPort, this.#timeout); } catch (err) { return { status: "offline", detail: (err as Error).message, checkedAt }; } const flags = parseStatusPage(html); const expected: StatusFlag[] = [ "coverOpen", "cutterError", "paperEnd", "paperNearEnd", "offline", ]; const missing = expected.filter((k) => flags[k] === undefined); if (missing.length > 0) { return { status: "degraded", detail: `unexpected status page (missing: ${missing.join(", ")})`, checkedAt, }; } const faults = expected.filter((k) => flags[k] === true); const labels: Record = { paperEnd: "paper out", coverOpen: "cover open", cutterError: "cutter error", offline: "printer off-line", paperNearEnd: "paper low", }; return { status: faults.length > 0 ? "degraded" : "ready", ...flags, detail: faults.length > 0 ? faults.map((f) => labels[f]).join(", ") : undefined, checkedAt, }; } } /** Where a printer sits: at the lane (entry tickets), in the booth (receipts, reports, * the backup for entry tickets) or at the wash desk (the Car Wash till's slips). */ export type PrinterRole = "entry-dispenser" | "booth-receipt" | "wash-desk"; 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)" }, { value: "wash-desk", label: "Wash desk printer (Car Wash till slips)" }, ], help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline. The wash desk's Z-reports and vouchers print on the wash desk printer, falling back to the booth printer.", }; 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 that serve the /prn_stat.htm status page) over raw TCP (port 9100), OR local USB /dev/usb/lp0. The decoded status page is a network feature — a USB Rongta is monitored by reachability only. No auth on the print socket — isolate the VLAN.", transports: ["tcp-ip", "usb"], configFields: [ transportField, devicePathField, // host/port/status-page are TCP-only; not required for a USB printer. { ...hostField, required: false, help: `${hostField.help} Leave blank for a USB printer.` }, { ...portField(9100), required: false, help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100). TCP only.", }, { 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). TCP only.", }, roleField, rankField, { key: "timeoutMs", label: "Timeout (ms)", type: "number", required: false, default: 3000, }, ], create: (c) => new RongtaPrinter(c), }; /** Type guard exposed for callers that need to read a device's printer role. */ export function isPrinter(device: Device): device is PrinterDevice { return typeof (device as Partial).printTicket === "function"; }