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:
2026-06-24 20:32:10 +02:00
parent 5a5fedf4f4
commit 7366ad19cb
5 changed files with 348 additions and 60 deletions
+38 -31
View File
@@ -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<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 };
@@ -75,12 +84,12 @@ class CashinoPrinter implements PrinterDevice {
}
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)`,
@@ -88,17 +97,12 @@ class CashinoPrinter implements PrinterDevice {
}
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: 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}`,
@@ -106,7 +110,7 @@ class CashinoPrinter implements PrinterDevice {
}
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}`);
}
}
@@ -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,