feat(printer): USB transport behind the ESC/POS render layer
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
This commit is contained in:
@@ -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<DeviceHealth> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<PrinterStatus> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user