Files
parking_solution/wiki/concepts/printer-usb-transport.md
T
julian cd3b534e51
Build desktop / desktop (push) Successful in 4m21s
CI / check (push) Successful in 50s
Build & push images / images (push) Successful in 2m54s
feat(setup): USB printer discovery — pick a real /dev/usb device
The kernel numbers usblp nodes by plug/boot order (park-buzi's printer
is lp1); the wizard hardcoded lp0 in labels/default and the admin had to
shell in and `ls /dev/usb`. Now:

- GET /api/setup/usb-printers enumerates /dev/usb/lpN (visible via the
  compose bind-mount) and enriches each with the printer's self-reported
  make/model from sysfs ieee1284_id (readable through Docker's ro /sys).
- The wizard's devicePath becomes a SELECT of printers actually present
  ("/dev/usb/lp1 — Xprinter XP-K200L"): a fresh form preselects the
  first real device; a saved-but-unplugged path stays selectable,
  flagged "saved — not present now"; zero found falls back to free text
  + a check-the-cable hint.
- Transport option label no longer hardcodes lp0.

Wiki: printer-usb-transport marked HARDWARE-VERIFIED (lab 2026-07-07:
full slip + feed + cut over USB — parity with TCP).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 11:35:25 +02:00

9.2 KiB
Raw Blame History

type, tags, sources, updated, status
type tags sources updated status
concept
parking
device
printer
transport
usb
escpos
provisioning
2026-07-06 settled

Printer USB transport (kernel usblp, behind the ESC/POS layer)

The ESC/POS printer drivers (rongta-printer, cashino) can deliver their byte stream over either a raw TCP socket (port 9100) or a local USB character device (/dev/usb/lp0), selected per device by config.transport ("tcp-ip" | "usb"). The original architecture always intended one ESC/POS adapter to cover "USB or network" (parking-system-architecture §BOM); the first implementation shipped TCP-only, and this closes that gap.

The seam — render once, dispatch the transport

Every render*() function in packages/devices/src/drivers/printer-escpos.ts produces a transport-independent ESC/POS Buffer. Only delivery differs. The transport is resolved once per driver from config and every print/probe call site stays transport-blind:

  • transportFromConfig(config) → a discriminated Transport ({ kind: "tcp", host, port } or { kind: "usb", devicePath }). Anything other than transport: "usb" is TCP, so existing host-only configs keep working unchanged (no migration).
  • sendTo(t, payload, timeoutMs) / probeTo(t, timeoutMs) dispatch to the TCP pair (sendRaw/probe) or the USB pair (sendRawUsb/probeUsb).

Adding a transport = one more arm in the dispatcher; not a single rendered byte changes. This is why the CP852 map, the Code128/QR builders, roles/failover, and the receipt/ticket/voucher layouts are all untouched by USB support.

USB transport = the in-box usblp char device

A USB ESC/POS printer plugged into the appliance enumerates as a character device (e.g. /dev/usb/lp0) via the kernel's in-box usblp driver. We just open it O_WRONLY and write the same bytes:

  • No native dependency. A plain fs write — no libusb, no CUPS, no native addon. This keeps the MIT/Apache/BSD-only dependency constraint and the offline-first, minimal-deps appliance posture (see technology-stack, offline-first).
  • usblp is raw. Unlike the TCP path there is no FIN/half-close dance (the graceful-close fix was a TCP concern — an early destroy() could RST-truncate the stream; see rongta-printer). A single open + write delivers the job; we always close the handle.
  • Bounded by a timeout. A wedged USB printer can block the write (or the open) indefinitely; a stuck print must surface as a failure, not hang the entry flow. withTimeout rejects after timeoutMs.

Status over USB — reachability only (honesty rule)

probeUsb is "does the char device exist and open writable" — the USB analogue of the TCP connect probe. A present, openable /dev/usb/lp0 means usblp bound a powered, enumerated printer.

  • The cashino driver is reachability-only on both transports (it never had a status page).
  • The rongta driver's rich readStatus() scrapes the board's HTTP /prn_stat.htm — a network feature. Over USB there is no such page, so readStatus() degrades to the reachability floor (ready/offline only, never a guessed paper/cover state). A USB Rongta is effectively a Cashino for monitoring. This preserves the standing honesty rule from printer-status-monitoring: never report a paper/cover verdict the transport can't actually sense.

Threat model

The USB path is a local character device the booth operator (the threat model's adversary) cannot reach over the network — narrower attack surface than the unauthenticated TCP print socket on the VLAN. Printers are advisory output; nothing about the signed append-only-event-chain or barrier control is touched.

Provisioning dependency (NOT app code) — see open-questions #14

Driving a USB printer depends on the appliance image:

  1. the usblp kernel module is loaded (it is in-box on Ubuntu 26.04; CUPS can claim the interface first — may need usblp to win, or CUPS masked for that device), and
  2. a udev rule grants the server process write access to the node (e.g. a group on /dev/usb/lp*), since the appliance server does not run as root.

This is a appliance-provisioning concern, recorded as open-questions #14 until the on-site printer is confirmed USB and the rule is baked into the image and verified on hardware.

Status

Built 2026-06-24 behind the existing render layer. sendRawUsb/probeUsb/transportFromConfig/ sendTo/probeTo in printer-escpos.ts; cashino + rongta resolve a Transport and dispatch. The setup UI offers a Connection select (Network / USB) + a USB device path field (default /dev/usb/lp0); host/port are not-required so a USB printer needs neither. Covered by printer-escpos.test.ts (USB writes the exact rendered bytes; probe present/absent; transportFromConfig TCP back-compat) and printer-cashino.test.ts (a USB-configured driver prints to the node and reports ready/offline). The on-hardware confirmation + the udev/usblp provisioning are pending (open-questions #14).

Field bug — the NONBLOCK partial-write truncation (found + fixed 2026-07-06)

First on-hardware USB test (ICS XP-K200L, an ESC/POS clone): over TCP it printed + cut fine; over USB it printed the ticket's TEXT but no barcode and no cut. Root cause was in OUR transport, not the printer: sendRawUsb opened the node with O_NONBLOCK and issued ONE write() for the whole job. On a non-blocking usblp fd the kernel accepts only what fits the printer's USB buffer (~8 KB) and returns a short write; the old code never checked bytesWritten, closed the handle, and silently dropped the tail — which is exactly where the barcode (mid-payload) and the CUT (last bytes) live. Small jobs fit one buffer, hence "text prints fine". The regular-file test stand-in can't short-write, so tests never caught it.

Fix: writeAllUsb — chunked loop (4 KB, safely under the usblp buffer) that continues after partial writes, retries EAGAIN/zero-byte writes with a short pause, and fails at the caller's deadline with a (N/M bytes accepted) diagnostic. Driven by fake-handle tests (short writes, EAGAIN interleave, wedged-printer timeout, non-EAGAIN passthrough) since a real file can't reproduce the char device's behaviour.

Second truncation mode — close() cancels the in-flight transfer (lab bench, 2026-07-07). The chunked loop alone STILL truncated on hardware (test slip stopped mid-sentence, no feed, no cut — "press the feed button to see the text"). Verified against drivers/usb/class/usblp.c: write() returns at URB submission (not completion), only ONE write URB is in flight at a time (the next write EAGAINs until it completes), and usblp_release() — i.e. our close() — kills in-flight URBs. The printer drains bulk data at PRINT speed (tiny internal buffer on these clones), so closing right after the last accepted write cancels the still-transferring tail — exactly where the feed + GS V cut bytes live. Kernel-accepted ≠ printer-received.

Fix: the one-URB rule makes acceptance of write N a completion certificate for write N−1. So writeAllUsb now writes the payload's FINAL BYTE alone: when that 1-byte write is accepted, every byte before it is physically in the printer; a short drain pause (USB_DRAIN_MS 300 ms) covers the lone final-byte packet, then close is safe. (usblp also implements poll(POLLOUT) as the true completion signal, but Node cannot poll an arbitrary char-device fd without a native dep — the hold-back + drain gets the same guarantee for all but the final byte, whose packet the printer ACKs immediately after having just freed its buffer.)

✅ HARDWARE-VERIFIED (lab bench, 2026-07-07): with both fixes, the ICS XP-K200L over USB prints the complete slip, feeds, and CUTS — parity with TCP. The USB transport is done.

Device discovery (2026-07-07). The kernel numbers usblp nodes by plug/boot order — park-buzi's printer is lp1, and the admin had to shell in and ls /dev/usb to learn that. The wizard now lists REAL printers: GET /api/setup/usb-printers enumerates /dev/usb/lpN (visible via the compose bind-mount) and enriches each with the printer's self-reported make/model from sysfs (/sys/class/usbmisc/lpN/device/ieee1284_id — readable through Docker's default ro /sys). The devicePath field becomes a SELECT ("/dev/usb/lp1 — Xprinter XP-K200L") with a fresh form preselecting the first present device; a saved-but-unplugged path stays selectable, flagged "saved — not present now"; zero devices found falls back to the free-text path + a check-the-cable hint. The transport option label no longer hardcodes lp0.

Driver-choice note for this clone: the ICS XP-K200L does NOT serve the Rongta /prn_stat.htm status page (checked on hardware at 10.0.10.11 — print socket 9100 open, status page absent), so on NETWORK the honest driver is cashino (reachability-only monitoring); under rongta the monitor would mark a perfectly working printer offline/degraded. Over USB the two drivers behave identically (reachability floor), so either works post-fix. See rongta-printer.

Related: rongta-printer, printer-status-monitoring, printer-roles-failover, appliance-provisioning, network-isolation, technology-stack.