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:
@@ -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.",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user