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
@@ -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<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
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<void> {
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<void> {
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<void> {
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<void> {
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.",
};