The park-buzi printer is a K200L (Xprinter/ICS XP-K200L; its LAN board and USB descriptor call it "POS-80"). Its board serves the Rongta's five-row status table under /prt_status.htm — but the reply carries no HTTP status line or headers, which node:http rejects, so the Rongta driver could never read it and the unit was filed in July as "no status page → generic driver" (reachability only). New `k200l` driver (printer-k200l.ts): prints through the generic ESC/POS device (same bytes, TCP 9100 or usblp) and reads the page over a raw socket, tolerant of both the headerless and a proper HTTP reply. Mapping mirrors the Rongta: board unreachable → offline; page not understood → degraded, never ready; any fault → degraded naming it; USB → reachability floor. The Rongta driver is untouched. Tests replay the captured headerless page (devices suite 76). Live against the lab unit: ready; with the cover open the board reports cover open, paper out, off-line. Wiki: new k200l-printer entity (names, network setup from factory 192.168.123.100, board quirks, status page, what it means for park-buzi — over USB the app never saw cover/paper state at all), cross-links on the Rongta, status-monitoring, USB-transport and WSL-networking pages (parking-net pinned to eth1 while the LAN NIC is eth0), index. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
19 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| concept |
|
2026-09-09 | 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 discriminatedTransport({ kind: "tcp", host, port }or{ kind: "usb", devicePath }). Anything other thantransport: "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
fswrite — 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). usblpis raw. Unlike the TCP path there is no FIN/half-close dance (the graceful-close fix was a TCP concern — an earlydestroy()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.
withTimeoutrejects aftertimeoutMs.
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
cashinodriver is reachability-only on both transports (it never had a status page). - The
rongtadriver's richreadStatus()scrapes the board's HTTP/prn_stat.htm— a network feature. Over USB there is no such page, soreadStatus()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:
- the
usblpkernel module is loaded (it is in-box on Ubuntu 26.04; CUPS can claim the interface first — may needusblpto win, or CUPS masked for that device), and - 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.
Superseded 2026-09-09: the XP-K200L DOES serve a status page — the same table as the Rongta, at
/prt_status.htm, without HTTP headers. It now has its ownk200ldriver (raw-socket fetch; LAN = live cover/paper status, USB = reachability floor). See k200l-printer. The note below is kept for the record.Driver-choice note for this clone: the ICS XP-K200L does NOT serve the Rongta
/prn_stat.htmstatus 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); underrongtathe 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.
Field bug — cover-open re-enumeration wedges the container's /dev/usb view; only docker restart, not a host reboot, clears it (investigated 2026-08-30, unconfirmed root cause)
Symptom (park-buzi, unknown/"Generic" USB printer, model not yet identified — see below): every
time the booth operator opens the printer's paper-roll cover to reload paper, the printer's status
goes offline/faulty in the app and never self-recovers — not after the cover closes, not after
a full appliance reboot. The only fix found so far is SSH in and docker restart server.
Ruled out at the application layer. Traced sendRawUsb/probeUsb in printer-escpos.ts: every
print AND every poll tick (device-monitor.ts 8s / printer-monitor.ts 5s) does a fresh
open() → write/probe → close() against the configured devicePath. No fd, socket, or driver
instance is held across calls — driver.create(config) is a throwaway object with no persistent
handle. So a naive "stale Node file descriptor" explanation does not fit this codebase; the
app-layer retry-by-fresh-open-every-poll should self-heal within one poll cycle if the kernel's view
of the device node is current.
Leading hypothesis: the container's bind-mount of /dev/usb, not the Node process, holds the
stale state. Docker Compose wires the printer in as a directory bind-mount
(docker-compose.prod.yml, volumes: - /dev/usb:/dev/usb), chosen deliberately (per its own
comment) so the app survives the printer renumbering to a different lpN. But many USB thermal
printers cut power to their own USB interface board when the cover-open microswitch trips (a
hardware safety/power feature, not just a status flag) — the printer drops off the bus and
re-enumerates, potentially as a new device node, when the cover closes. The host kernel picks
this up fine; the container's mount namespace, once established, is a known Docker/OverlayFS
sharp edge for /dev subtree bind-mounts — it can keep resolving the old node until the mount
itself is redone.
docker restart serverrecreates the container's mount namespace → the/dev/usbbind-mount is redone against current host state → the new node is picked up → fixed.- A full host reboot restarts the container too (
restart: always), but as a boot-time race: if the container starts before the USB subsystem finishes settling, or the printer re-enumerated some time before the reboot and Docker doesn't necessarily redo an already-satisfied bind-mount target on a policy-driven restart, the container can come back up still bound to the pre-incident view. This matches the exact reported asymmetry (reboot doesn't fix it; explicit restart does).
Bench result 2026-09-09 — the re-enumeration hypothesis is FALSIFIED for this unit. The failing printer (
1fc9:2016"POS-80", now on the dev bench, attached to WSL via usbipd) was cover-cycled whiledmesg -wandlsusbwere watched: nothing — no disconnect, no re-enumeration, same bus/device number (a real drop would have shown as a vhci detach, since Windows sees the bus first). So the device node does NOT change when the cover opens, and the container/dev/usbbind-mount cannot be going stale for that reason. The failure is in howusblp/ the app's open-probe reacts to the printer's error state (cover-open status), not in the device node. Next discriminator is the exactdetailtext the monitor logged on park-buzi at the offline transition (docker logs <stack>-server-1 | grep 'device-monitor:.*-> offline'):EBUSY= a handle is held inside the server process (usblp allows ONE opener — candidate: thewithTimeoutopen-leak or a close that never returned; fits "docker restart fixes"),usb open timeout=open()itself blocks in the kernel,EIO=usblp_open's bidirectional read submit failed (printer endpoint state). The theory below is kept for the record.
Lab reproduction FAILED to reproduce (2026-09-09, later the same day). The same printer unit on the
park-labbox (a real Linux host, the booth's exact imagestage-2d9bb15, the prod compose with the/dev/usbbind-mount, the dev DB snapshot with the USB printer added asbooth-receipt, cards printed via the subscription "Reprint card" path): paper out → open cover → load roll → close cover → reprint — no error, status never stuck offline. So the printer, the app's USB transport and the compose wiring are cleared in isolation. What is left is park-buzi's own environment (kernel/USB stack, the physical USB port/hub/cable/power at the booth) and/or that container's history (weeks of uptime before the first failure — a leaked handle needs a prior timeout to exist; a fresh container has none).Status: park-buzi closed (staff shortage), everything shut down — evidence pending. The evidence is on the booth's DISK and survives shutdown/reboot: Docker keeps the container log under
/var/lib/docker/containers/<id>/, the kernel journal is persistent. The day the box powers on again (or lands on the bench), pull these FIRST, before deploying anything:# 1. the app's own record: the exact error text at every offline/ready transition docker logs park-buzi-server-1 2>&1 | grep -E "device-monitor:.*printer.*-> (offline|ready)" # 2. what the HOST kernel saw around those times (usblp errors, resets, disconnects) sudo journalctl -k --since "-30 days" | grep -i -E "usblp|usb 1-|usb 2-|disconnect|reset" # 3. the physical path: hub or direct port? (and note which PSU feeds the printer) lsusb -tReading (1):
EBUSY= a handle stuck inside the server process (usblp allows ONE opener; fits "container restart fixes it") → look at thewithTimeoutleak below;usb open timeout=open()blocks in the kernel;EIO=usblp_open's bidirectional read submit failed (printer/link state). Reading (2): anyUSB disconnect/reset/usblp1: removedat the transition times means the LINK dropped at the booth (cable/port/hub/power) even though the unit never dropped on the bench.Follow-ups that need no booth (proposed, not built): (a)
withTimeoutinprinter-escpos.tsabandons the FileHandle when an open/write times out — close it when the underlying promise eventually settles, so a timeout can never leave the node held; (b) make the monitor self-document the next occurrence: after N consecutive offline polls on a USB printer, log the errno,ls -la /dev/usb, and who holds the node, so the next failure anywhere in the fleet carries its own diagnosis without a person at the booth.
Not confirmed on hardware — and now contradicted by the bench (above). The original plan to confirm at the next occurrence, BEFORE restarting anything:
# host:
ls -la /dev/usb/ && stat /dev/usb/lp1
# container:
docker exec server ls -la /dev/usb/ && docker exec server stat /dev/usb/lp1
A major:minor or inode mismatch between host and container is the smoking gun. Also worth
capturing on the lab RONGTA (different printer, but same cover-open mechanism is plausible):
watch -n1 lsusb + sudo dmesg -w | grep -i -E 'usb|disconnect' while cycling the cover, to see
whether the Bus/Device number changes.
Candidate fixes, not yet implemented (ranked cheapest-to-most-invasive):
- A host-side watchdog/udev rule that detects re-enumeration of this printer (match vendor:product
ID) and runs
docker restart serverautomatically — turns the manual SSH fix into a self-healing one without touching app code. - Same idea but event-driven via a udev rule or systemd path unit watching
/dev/usb, rather than polling. - Switch the compose device wiring from the directory bind-mount to a specific
--device=cgroup passthrough + a udev rule pinning a stable symlink name — reintroduces the renumbering fragility the directory bind-mount was chosen to avoid, so only worth doing alongside (1)/(2), not instead.
Printer identity — IDENTIFIED 2026-09-09. The failing unit is on the dev bench: a K200L
(Xprinter/ICS XP-K200L family — see k200l-printer for the LAN setup and its status page): USB
1fc9:2016, product string "Printer POS-80" (0x1fc9 = NXP, the printer's USB controller chip;
"POS-80" is the generic 80 mm ESC/POS designation — no brand in the descriptor, which is why the app
shows "Generic"). Seen via usbipd list on the Windows host (busid 8-1). Dev-bench caveat: the
stock Microsoft WSL2 kernel (6.6.87.2) has CONFIG_USB_PRINTER not set — usbip/vhci is there,
so the printer can be attached and seen by lsusb, but no usblp → no /dev/usb/lpN → the app's
USB transport and the container's /dev/usb bind-mount cannot be exercised without a custom WSL
kernel (.wslconfig kernel=) built with CONFIG_USB_PRINTER=y. Also, through usbip the
cover-open disconnect is seen by Windows first (usbipd detaches; --auto-attach re-exports), so
the bench only shows whether the device drops off the bus, not the host-kernel/container
staleness itself. Previously: the park-buzi unit showed as "Generic (unknown)" in the app; not
identified by vendor/product ID. Lab reproduction uses a RONGTA unit instead (not
the same hardware), so the lab cannot currently reproduce the park-buzi symptom directly — only
validate the general re-enumeration mechanism. Commands to identify the real park-buzi printer next
time it's reachable via SSH: lsusb, udevadm info -q property -n /dev/usb/lp1, udevadm info -a -n /dev/usb/lp1. This mirrors the same discovery gap already noted above under "Device discovery"
(sysfs ieee1284_id enrichment) — once identified, fold the model into that mechanism's coverage.
Related: rongta-printer, printer-status-monitoring, printer-roles-failover, appliance-provisioning, network-isolation, technology-stack.