From 7366ad19cbda8860f246c6bdc5d0de338c57fd6e Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Wed, 24 Jun 2026 20:32:10 +0200 Subject: [PATCH] feat(printer): USB transport behind the ESC/POS render layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ESC/POS printer drivers were TCP-only — every path went through sendRaw/probe to a raw socket on port 9100. Add a USB transport behind the existing render layer without touching a single render*() function. - printer-escpos.ts: sendRawUsb/probeUsb write the same ESC/POS bytes to a kernel usblp char device (/dev/usb/lp0) via a plain fs write — no libusb/CUPS/native dep (keeps MIT-only + minimal-deps appliance). A discriminated Transport + transportFromConfig/sendTo/probeTo dispatch the wire; anything not transport:"usb" is TCP, so existing host-only configs need no migration. Shared transportField/devicePathField config fields. - cashino + rongta resolve a Transport once; both are reachability-only over USB, and the Rongta's HTTP status page degrades to the open-the-node probe over USB (no guessed paper/cover — the standing honesty rule). host/port made not-required so a USB printer needs neither. - Tests: printer-escpos.test.ts (USB writes the exact rendered bytes; probe present/absent; transportFromConfig TCP back-compat) + printer-cashino.test.ts (USB-configured driver prints to the node, ready/offline). USB itself is unverified on hardware (the on-site printers are networked); the appliance-side usblp + udev provisioning is tracked as open-questions #14. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- .../src/drivers/printer-cashino.test.ts | 50 ++++++ .../devices/src/drivers/printer-cashino.ts | 69 +++++---- .../src/drivers/printer-escpos.test.ts | 71 ++++++++- .../devices/src/drivers/printer-escpos.ts | 145 ++++++++++++++++++ .../devices/src/drivers/printer-rongta.ts | 73 +++++---- 5 files changed, 348 insertions(+), 60 deletions(-) create mode 100644 packages/devices/src/drivers/printer-cashino.test.ts diff --git a/packages/devices/src/drivers/printer-cashino.test.ts b/packages/devices/src/drivers/printer-cashino.test.ts new file mode 100644 index 0000000..844347d --- /dev/null +++ b/packages/devices/src/drivers/printer-cashino.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { cashinoDriver } from "./printer-cashino.js"; +import { renderTicket } from "./printer-escpos.js"; + +// End-to-end transport routing through the real driver: a USB-configured Cashino must +// resolve to the char-device transport and write the SAME ESC/POS bytes the TCP path +// would. (The TCP path is exercised by the routing/escpos suites and on hardware.) + +describe("cashinoDriver — USB transport", () => { + let dir: string; + let devicePath: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "cashino-usb-")); + devicePath = join(dir, "lp0"); + // Stand in for an enumerated usblp node (the kernel creates it; we only open it). + writeFileSync(devicePath, ""); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("prints a ticket to the configured USB device path", async () => { + const printer = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 }); + const data = { ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" }; + await printer.printTicket(data); + const written = readFileSync(devicePath); + expect(written.equals(renderTicket(data))).toBe(true); + }); + + it("healthCheck reports ready when the node exists, offline when it doesn't", async () => { + const present = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 }); + expect((await present.healthCheck()).status).toBe("ready"); + // An absent device node (printer unplugged / not enumerated) → offline. + const absent = cashinoDriver.create({ + transport: "usb", + devicePath: join(dir, "absent-lp0"), + timeoutMs: 1000, + }); + expect((await absent.healthCheck()).status).toBe("offline"); + }); + + it("advertises both transports", () => { + expect(cashinoDriver.transports).toContain("usb"); + expect(cashinoDriver.transports).toContain("tcp-ip"); + }); +}); diff --git a/packages/devices/src/drivers/printer-cashino.ts b/packages/devices/src/drivers/printer-cashino.ts index 2419606..3c64b34 100644 --- a/packages/devices/src/drivers/printer-cashino.ts +++ b/packages/devices/src/drivers/printer-cashino.ts @@ -10,20 +10,31 @@ import type { import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js"; import { hostField, portField, stubLog } from "./common.js"; import { - probe, + devicePathField, + probeTo, renderReceipt, renderReport, renderSubscriptionCard, renderTicket, renderWindowChargeNotice, - sendRaw, + sendTo, + transportField, + transportFromConfig, + type Transport, } from "./printer-escpos.js"; -// Cashino 80mm network thermal printer driver. The Cashino is an ESC/POS clone: -// it PRINTS identically to the Rongta (same byte stream — see ./printer-escpos.ts), -// so tickets, reports and subscription cards render the same. What it does NOT -// have is the Rongta board's decoded status web page (/prn_stat.htm). It cannot -// report paper-out / cover-open / cutter faults in a form we trust. +// Cashino 80mm thermal printer driver (network OR USB). The Cashino is an ESC/POS +// clone: it PRINTS identically to the Rongta (same byte stream — see +// ./printer-escpos.ts), so tickets, reports and subscription cards render the same, +// over either transport. What it does NOT have is the Rongta board's decoded status +// web page (/prn_stat.htm). It cannot report paper-out / cover-open / cutter faults +// in a form we trust. +// +// TRANSPORT: a single `config.transport` ("tcp-ip" | "usb") picks the wire; the +// driver resolves it ONCE into a Transport and every print/probe stays transport- +// blind (see transportFromConfig/sendTo/probeTo). USB writes the same bytes to a +// local usblp char device (/dev/usb/lp0); TCP writes to the raw print socket. This +// clone is the natural USB candidate — reachability-only, no status page to lose. // // Therefore this driver deliberately does NOT implement MonitorableDevice // (no readStatus). The device monitor then falls back to the generic @@ -40,13 +51,11 @@ import { class CashinoPrinter implements PrinterDevice { readonly driverId = "cashino"; - readonly #host: string; - readonly #port: number; + readonly #transport: Transport; readonly #timeout: number; constructor(config: DeviceConfig) { - this.#host = String(config.host); - this.#port = config.port ? Number(config.port) : 9100; + this.#transport = transportFromConfig(config); this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000; } @@ -59,15 +68,15 @@ class CashinoPrinter implements PrinterDevice { } /** - * Reachability only — a TCP connect probe of the raw print socket. The Cashino - * has no trustworthy status protocol, so this is the floor and the ceiling of - * what we report: reachable → ready, unreachable → offline. Deliberately NO - * readStatus(): the monitor uses this for the traffic-light, never a guessed - * paper/cover state. + * Reachability only — a connect probe (TCP) or char-device open probe (USB) of + * the print path. The Cashino has no trustworthy status protocol, so this is the + * floor and the ceiling of what we report: reachable → ready, unreachable → + * offline. Deliberately NO readStatus(): the monitor uses this for the + * traffic-light, never a guessed paper/cover state. */ async healthCheck(): Promise { try { - await probe(this.#host, this.#port, this.#timeout); + await probeTo(this.#transport, this.#timeout); return { status: "ready" }; } catch (err) { return { status: "offline", detail: (err as Error).message }; @@ -75,12 +84,12 @@ class CashinoPrinter implements PrinterDevice { } async printTicket(data: TicketData): Promise { - await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout); + await sendTo(this.#transport, renderTicket(data), this.#timeout); stubLog(this.driverId, `printed ticket ${data.ticketId}`); } async printReport(report: PrintReport): Promise { - await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout); + await sendTo(this.#transport, renderReport(report), this.#timeout); stubLog( this.driverId, `printed report "${report.title}" (${report.lines.length} lines)`, @@ -88,17 +97,12 @@ class CashinoPrinter implements PrinterDevice { } async printSubscriptionCard(data: SubscriptionCardData): Promise { - await sendRaw( - this.#host, - this.#port, - renderSubscriptionCard(data), - this.#timeout, - ); + await sendTo(this.#transport, renderSubscriptionCard(data), this.#timeout); stubLog(this.driverId, `printed subscription card ${data.code}`); } async printReceipt(data: ReceiptData): Promise { - await sendRaw(this.#host, this.#port, renderReceipt(data), this.#timeout); + await sendTo(this.#transport, renderReceipt(data), this.#timeout); stubLog( this.driverId, `printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`, @@ -106,7 +110,7 @@ class CashinoPrinter implements PrinterDevice { } async printWindowChargeNotice(data: WindowChargeNoticeData): Promise { - await sendRaw(this.#host, this.#port, renderWindowChargeNotice(data), this.#timeout); + await sendTo(this.#transport, renderWindowChargeNotice(data), this.#timeout); stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`); } } @@ -141,14 +145,17 @@ export const cashinoDriver: PrinterDriver = { category: "printer", label: "Cashino 80mm thermal printer", description: - "Cashino 80mm thermal printer (ESC/POS over raw TCP, port 9100). Prints like the Rongta but has no status page — monitored by reachability ping only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.", - transports: ["tcp-ip"], + "Cashino 80mm thermal printer (ESC/POS over raw TCP port 9100, OR local USB /dev/usb/lp0). Prints like the Rongta but has no status page — monitored by reachability only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.", + transports: ["tcp-ip", "usb"], configFields: [ - hostField, + transportField, + devicePathField, + // host/port are TCP-only; not required because a USB printer needs neither. + { ...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).", + help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100). TCP only.", }, roleField, rankField, diff --git a/packages/devices/src/drivers/printer-escpos.test.ts b/packages/devices/src/drivers/printer-escpos.test.ts index 351c7f3..54db740 100644 --- a/packages/devices/src/drivers/printer-escpos.test.ts +++ b/packages/devices/src/drivers/printer-escpos.test.ts @@ -1,9 +1,15 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { renderTicket, renderReceipt, renderWindowChargeNotice, renderSubscriptionCard, + probeUsb, + sendRawUsb, + transportFromConfig, stamp, } from "./printer-escpos.js"; @@ -103,6 +109,69 @@ describe("CP852 character mapping (the misprint fixes)", () => { }); }); +describe("USB transport (sendRawUsb / probeUsb / transportFromConfig)", () => { + // A regular file stands in for the usblp character device: open(O_WRONLY) + write + // is the same syscall path. This proves the transport is byte-blind — the EXACT + // ESC/POS stream renderTicket produces lands at the device path, with no transport + // touching a rendered byte (the whole point of the seam). + let dir: string; + let devicePath: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "escpos-usb-")); + devicePath = join(dir, "lp0"); + // A real usblp node already EXISTS (created by the kernel on enumeration); we open + // it O_WRONLY without O_CREAT, never create it. Pre-create the stand-in file so the + // test mirrors that — opening an ABSENT path means "printer not present" (offline). + writeFileSync(devicePath, ""); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("writes the exact rendered ESC/POS bytes to the device path", async () => { + const payload = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" }); + await sendRawUsb(devicePath, payload, 1000); + const written = readFileSync(devicePath); + expect(written.equals(payload)).toBe(true); + }); + + it("rejects when the device path can't be opened (printer not present)", async () => { + await expect( + sendRawUsb(join(dir, "absent-lp0"), Buffer.from([0x1b, 0x40]), 1000), + ).rejects.toThrow(); + }); + + it("probeUsb resolves for an existing node, rejects for a missing one", async () => { + await expect(probeUsb(devicePath, 1000)).resolves.toBeUndefined(); + await expect(probeUsb(join(dir, "nope"), 1000)).rejects.toThrow(); + }); + + it("transportFromConfig: transport=usb selects the char device (default /dev/usb/lp0)", () => { + expect(transportFromConfig({ transport: "usb", devicePath: "/dev/usb/lp1" })).toEqual({ + kind: "usb", + devicePath: "/dev/usb/lp1", + }); + expect(transportFromConfig({ transport: "usb" })).toEqual({ + kind: "usb", + devicePath: "/dev/usb/lp0", + }); + }); + + it("transportFromConfig: anything else is TCP (back-compat with host-only configs)", () => { + expect(transportFromConfig({ host: "10.0.0.9" })).toEqual({ + kind: "tcp", + host: "10.0.0.9", + port: 9100, + }); + expect(transportFromConfig({ host: "10.0.0.9", port: 9101 })).toEqual({ + kind: "tcp", + host: "10.0.0.9", + port: 9101, + }); + }); +}); + describe("stamp (Albanian date format)", () => { it("formats an ISO time as ' HH:MM:SS'", () => { // Local-time dependent, so assert the structure + the Albanian month name. diff --git a/packages/devices/src/drivers/printer-escpos.ts b/packages/devices/src/drivers/printer-escpos.ts index 790633f..c83050f 100644 --- a/packages/devices/src/drivers/printer-escpos.ts +++ b/packages/devices/src/drivers/printer-escpos.ts @@ -1,4 +1,6 @@ import { Socket } from "node:net"; +import { open } from "node:fs/promises"; +import { constants as FS } from "node:fs"; import type { PrintReport, ReceiptData, @@ -567,7 +569,150 @@ export function probe( }); } +// --- USB transport (kernel usblp character device) ---------------------------- +// An ESC/POS USB printer plugged into the appliance enumerates as a character +// device (e.g. /dev/usb/lp0) via the in-box `usblp` kernel driver. We deliver the +// SAME ESC/POS byte stream there as over TCP — only the transport differs, not a +// single rendered byte. No libusb / CUPS / native addon: a plain file write keeps +// the MIT-only + offline-first, minimal-deps appliance constraints, and the path is +// a LOCAL char device the booth operator (the threat model's adversary) can't reach +// over the network. Paper/cover is NOT sensed here — same honesty floor as the +// Cashino TCP probe. usblp + a udev rule granting the server write access to the +// node are a provisioning dependency. See wiki/concepts/printer-usb-transport.md. + +/** Bound a promise with a timeout — a wedged USB printer can block a write (or even + * the open) indefinitely, and a stuck print must surface as a failure rather than + * hang the entry flow. The underlying handle leaks on timeout, but the process is + * the appliance server; a failed print is logged and retried/failed-over upstream. */ +function withTimeout(p: Promise, ms: number, msg: string): Promise { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(msg)), ms); + p.then( + (v) => { + clearTimeout(t); + resolve(v); + }, + (e) => { + clearTimeout(t); + reject(e as Error); + }, + ); + }); +} + +/** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp + * is a RAW character device: a single open + write delivers the job — there is no + * FIN/half-close dance (that was a TCP concern, where an early destroy() could + * truncate the stream). We always close the handle (even on a failed write). */ +export async function sendRawUsb( + devicePath: string, + payload: Buffer, + timeoutMs: number, +): Promise { + const handle = await withTimeout( + open(devicePath, FS.O_WRONLY | FS.O_NONBLOCK), + timeoutMs, + "usb open timeout", + ); + try { + await withTimeout(handle.write(payload), timeoutMs, "usb write timeout"); + } finally { + await handle.close(); + } +} + +/** Reachability for a USB printer: the floor is "does the char device exist and + * open writable". A present, openable /dev/usb/lp0 means usblp bound a powered, + * enumerated printer — the USB analogue of the TCP connect probe. (Like the Cashino + * TCP probe, this reports reachability only, never a guessed paper/cover state.) */ +export async function probeUsb(devicePath: string, timeoutMs: number): Promise { + const handle = await withTimeout( + open(devicePath, FS.O_WRONLY | FS.O_NONBLOCK), + timeoutMs, + "usb open timeout", + ); + await handle.close(); +} + +// --- transport dispatch ------------------------------------------------------- +// A discriminated transport so each driver resolves the wire ONCE (from config) and +// every print/probe call site stays transport-blind. Adding a transport = one more +// arm here + the render layer is untouched. + +/** Where a printer's bytes go: a TCP raw-print socket, or a local USB char device. */ +export type Transport = + | { kind: "tcp"; host: string; port: number } + | { kind: "usb"; devicePath: string }; + +/** Build a Transport from a driver's flat config. `transport: "usb"` selects the + * USB char device (`devicePath`, default /dev/usb/lp0); anything else is TCP + * (host + port, default 9100) — so existing network configs with no `transport` + * key keep working unchanged. */ +export function transportFromConfig(config: { + transport?: unknown; + host?: unknown; + port?: unknown; + devicePath?: unknown; +}): Transport { + if (config.transport === "usb") { + return { kind: "usb", devicePath: String(config.devicePath ?? "/dev/usb/lp0") }; + } + return { + kind: "tcp", + host: String(config.host), + port: config.port ? Number(config.port) : 9100, + }; +} + +/** Send an ESC/POS payload over whichever transport the printer is configured for. */ +export function sendTo(t: Transport, payload: Buffer, timeoutMs: number): Promise { + return t.kind === "usb" + ? sendRawUsb(t.devicePath, payload, timeoutMs) + : sendRaw(t.host, t.port, payload, timeoutMs); +} + +/** Reachability probe over whichever transport the printer is configured for. */ +export function probeTo(t: Transport, timeoutMs: number): Promise { + return t.kind === "usb" + ? probeUsb(t.devicePath, timeoutMs) + : probe(t.host, t.port, timeoutMs); +} + +/** Human label for a transport, for status detail / logs. */ +export function transportLabel(t: Transport): string { + return t.kind === "usb" ? t.devicePath : `${t.host}:${t.port}`; +} + // --- shared driver config fields ---------------------------------------------- // Role + failover are identical across ESC/POS printers; defined here so each // driver shares them. See wiki/concepts/printer-roles-failover.md. export type PrinterRole = "entry-dispenser" | "booth-receipt"; + +// --- shared printer config fields (transport) --------------------------------- +// TCP-or-USB is offered identically across the ESC/POS drivers; defined here so each +// shares the exact field set. The setup wizard renders these generically. +import type { ConfigField } from "../registry.js"; + +/** Connection-transport select: network (raw TCP 9100) or local USB char device. */ +export const transportField: ConfigField = { + key: "transport", + label: "Connection", + type: "select", + required: true, + default: "tcp-ip", + options: [ + { value: "tcp-ip", label: "Network (raw TCP, port 9100)" }, + { value: "usb", label: "USB (local /dev/usb/lp0)" }, + ], + help: "USB drives a printer plugged into the appliance (usblp); Network drives one on the isolated device VLAN.", +}; + +/** USB character-device path; used only when transport=usb (ignored for TCP). */ +export const devicePathField: ConfigField = { + key: "devicePath", + label: "USB device", + type: "string", + required: false, + default: "/dev/usb/lp0", + help: "Character device for a USB printer (usblp), e.g. /dev/usb/lp0. Only used when Connection is USB.", +}; diff --git a/packages/devices/src/drivers/printer-rongta.ts b/packages/devices/src/drivers/printer-rongta.ts index 766dfc8..ce7d952 100644 --- a/packages/devices/src/drivers/printer-rongta.ts +++ b/packages/devices/src/drivers/printer-rongta.ts @@ -13,22 +13,28 @@ import type { import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js"; import { hostField, portField, stubLog } from "./common.js"; import { - probe, + devicePathField, + probeTo, renderReceipt, renderReport, renderSubscriptionCard, renderTicket, renderWindowChargeNotice, - sendRaw, + sendTo, + transportField, + transportFromConfig, + type Transport, } from "./printer-escpos.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. The ESC/POS rendering + transport -// are shared with the other ESC/POS clones in ./printer-escpos.ts; what is unique -// to Rongta — and lives here — is LIVE STATUS via the board's own status web page. -// There is no auth on the print socket; like the other field devices it lives on -// the isolated device VLAN. +// 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 @@ -128,14 +134,15 @@ function parseStatusPage(html: string): StatusFlags { class RongtaPrinter implements PrinterDevice, MonitorableDevice { readonly driverId = "rongta"; + readonly #transport: Transport; 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.#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; } @@ -150,7 +157,7 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice { async healthCheck(): Promise { try { - await probe(this.#host, this.#port, this.#timeout); + await probeTo(this.#transport, this.#timeout); return { status: "ready" }; } catch (err) { return { status: "offline", detail: (err as Error).message }; @@ -158,12 +165,12 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice { } async printTicket(data: TicketData): Promise { - await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout); + await sendTo(this.#transport, renderTicket(data), this.#timeout); stubLog(this.driverId, `printed ticket ${data.ticketId}`); } async printReport(report: PrintReport): Promise { - await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout); + await sendTo(this.#transport, renderReport(report), this.#timeout); stubLog( this.driverId, `printed report "${report.title}" (${report.lines.length} lines)`, @@ -171,17 +178,12 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice { } async printSubscriptionCard(data: SubscriptionCardData): Promise { - await sendRaw( - this.#host, - this.#port, - renderSubscriptionCard(data), - this.#timeout, - ); + 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 sendRaw(this.#host, this.#port, renderReceipt(data), this.#timeout); + await sendTo(this.#transport, renderReceipt(data), this.#timeout); stubLog( this.driverId, `printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`, @@ -189,7 +191,7 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice { } async printWindowChargeNotice(data: WindowChargeNoticeData): Promise { - await sendRaw(this.#host, this.#port, renderWindowChargeNotice(data), this.#timeout); + await sendTo(this.#transport, renderWindowChargeNotice(data), this.#timeout); stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`); } @@ -206,6 +208,18 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice { */ 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); @@ -281,14 +295,17 @@ export const rongtaDriver: PrinterDriver = { 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). No auth on the print socket — isolate the VLAN.", - transports: ["tcp-ip"], + "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: [ - hostField, + 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).", + help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100). TCP only.", }, { key: "httpPort", @@ -296,7 +313,7 @@ export const rongtaDriver: PrinterDriver = { type: "port", required: false, default: 80, - help: "Device status page (/prn_stat.htm) port for live monitoring (default 80).", + help: "Device status page (/prn_stat.htm) port for live monitoring (default 80). TCP only.", }, roleField, rankField,