diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts
index 46b0cf7..57aaf8c 100644
--- a/apps/server/src/routes/setup.ts
+++ b/apps/server/src/routes/setup.ts
@@ -560,6 +560,39 @@ export async function setupRoutes(
},
);
+ // USB printers PRESENT on the box: enumerate /dev/usb/lpN (the usblp nodes the
+ // container sees via the /dev/usb bind-mount) and enrich each with the printer's
+ // self-reported make/model from sysfs (ieee1284_id — readable through Docker's
+ // default ro /sys). The wizard offers these as a SELECT so the admin never has to
+ // shell in and `ls /dev/usb` to learn the kernel picked lp1 (field friction,
+ // park-buzi 2026-07-07). Empty list = no usblp printer plugged/visible.
+ app.get("/api/setup/usb-printers", { preHandler: adminGuard }, async () => {
+ const { readdir, readFile } = await import("node:fs/promises");
+ let names: string[] = [];
+ try {
+ names = (await readdir("/dev/usb")).filter((n) => /^lp\d+$/.test(n)).sort();
+ } catch {
+ return { printers: [] }; // no /dev/usb at all — nothing plugged (or no mount)
+ }
+ const printers = await Promise.all(
+ names.map(async (n) => {
+ // ieee1284_id: "MFG:Xprinter;CMD:ESCPOS;MDL:XP-K200L;…" — best-effort.
+ let description: string | null = null;
+ try {
+ const id = await readFile(`/sys/class/usbmisc/${n}/device/ieee1284_id`, "utf8");
+ const pick = (key: string) => id.match(new RegExp(`(?:^|;)\\s*${key}:([^;]+)`, "i"))?.[1]?.trim();
+ const mfg = pick("MFG") ?? pick("MANUFACTURER");
+ const mdl = pick("MDL") ?? pick("MODEL");
+ description = [mfg, mdl].filter(Boolean).join(" ") || null;
+ } catch {
+ /* sysfs not readable / attribute absent — path alone is still useful */
+ }
+ return { path: `/dev/usb/${n}`, description };
+ }),
+ );
+ return { printers };
+ });
+
// Assign a device. Validates the chosen driver + config, configures the device
// (fix preconditions + set up Digest-authenticated input push — no manual device-
// web-UI step by the admin), then persists. Fails the save if the device can't be
diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx
index 7e7e116..7194fb7 100644
--- a/apps/web/src/SetupWizard.tsx
+++ b/apps/web/src/SetupWizard.tsx
@@ -29,6 +29,7 @@ import {
type RelayEvent,
type RelaySpec,
type TestResult,
+ fetchUsbPrinters,
} from "./api.js";
import { Modal } from "./ui/Modal.js";
@@ -546,6 +547,28 @@ function DeviceForm({
}
return out;
});
+ // USB printers PRESENT on the box (/dev/usb/lpN + sysfs model) — fetched when a
+ // printer form is on the USB transport, so devicePath becomes a SELECT of real
+ // devices instead of a guessed path (the kernel may pick lp1 — park-buzi did).
+ const [usbPrinters, setUsbPrinters] = useState<{ path: string; description: string | null }[] | null>(null);
+ const usbTransport = isPrinter && String(config.transport ?? "tcp-ip") === "usb";
+ useEffect(() => {
+ if (!usbTransport) return;
+ let alive = true;
+ fetchUsbPrinters()
+ .then((r) => {
+ if (!alive) return;
+ setUsbPrinters(r.printers);
+ // Fresh form with no explicit path yet → preselect the first REAL device.
+ if (r.printers.length > 0) {
+ setConfig((c) => (c.devicePath == null ? { ...c, devicePath: r.printers[0]!.path } : c));
+ }
+ })
+ .catch(() => alive && setUsbPrinters([]));
+ return () => {
+ alive = false;
+ };
+ }, [usbTransport]);
// Controllers: the unified relay map. Each relay reacts to an EVENT — entry/exit/both
// (pulse a barrier) or radarAlert (drive an alert lamp). Alert relays carry a trigger
// input + blink cadence; barriers carry no input wiring (that lives in `inputs` below).
@@ -890,7 +913,32 @@ function DeviceForm({
{f.label}
{f.required ? " *" : ""}
- {f.type === "select" ? (
+ {f.key === "devicePath" && usbPrinters != null && usbPrinters.length > 0 ? (
+ // Real devices found → a select (path + self-reported model). A saved
+ // path that is NOT currently present stays selectable, flagged.
+
+ ) : f.type === "select" ? (
)}
+ {f.key === "devicePath" && usbPrinters != null && usbPrinters.length === 0 && (
+
{t("setup.usbNoneFound")}
+ )}
),
)}
diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts
index f569ae9..8a03149 100644
--- a/apps/web/src/api.ts
+++ b/apps/web/src/api.ts
@@ -604,6 +604,11 @@ export interface AssignBody {
backendIp?: string;
}
+/** USB printers currently visible on the appliance (/dev/usb/lpN + sysfs model). */
+export function fetchUsbPrinters(): Promise<{ printers: { path: string; description: string | null }[] }> {
+ return apiFetch("/api/setup/usb-printers");
+}
+
/** Save + configure the device (preconditions, push setup), then persist. */
export function assignDevice(body: AssignBody): Promise {
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts
index 66b8036..f251708 100644
--- a/apps/web/src/lib/i18n/en.ts
+++ b/apps/web/src/lib/i18n/en.ts
@@ -419,6 +419,8 @@ export const en: Catalog = {
scan: "Scan for controllers",
scanning: "Scanning…",
noControllersFound: "No controllers found on the LAN.",
+ usbNoneFound: "No USB printer found (/dev/usb/lpN) — check cable/power; the path can be typed manually.",
+ usbSavedMissing: "saved — not present now",
use: "Use",
test: "Test connection",
testing: "Testing…",
diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts
index f19033c..1e4e984 100644
--- a/apps/web/src/lib/i18n/sq.ts
+++ b/apps/web/src/lib/i18n/sq.ts
@@ -428,6 +428,8 @@ export const sq = {
scan: "Skano për kontroller",
scanning: "Duke skanuar…",
noControllersFound: "Asnjë kontroller në LAN.",
+ usbNoneFound: "Nuk u gjet asnjë printer USB (/dev/usb/lpN) — kontrollo kabllon/ushqimin; rruga mund të shkruhet me dorë.",
+ usbSavedMissing: "i ruajtur — jo i pranishëm tani",
use: "Përdor",
test: "Testo lidhjen",
testing: "Duke testuar…",
diff --git a/packages/devices/src/drivers/printer-escpos.ts b/packages/devices/src/drivers/printer-escpos.ts
index 25763b9..90b8965 100644
--- a/packages/devices/src/drivers/printer-escpos.ts
+++ b/packages/devices/src/drivers/printer-escpos.ts
@@ -775,7 +775,7 @@ export const transportField: ConfigField = {
default: "tcp-ip",
options: [
{ value: "tcp-ip", label: "Network (raw TCP)" },
- { value: "usb", label: "USB (local /dev/usb/lp0)" },
+ { value: "usb", label: "USB (local printer)" },
],
help: "USB drives a printer plugged into the appliance (usblp); Network drives one on the isolated device VLAN.",
};
@@ -787,5 +787,5 @@ export const devicePathField: ConfigField = {
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.",
+ help: "usblp character device (/dev/usb/lpN). The setup UI lists the printers actually present; the kernel numbers them (lp0, lp1, …) by plug/boot order. Only used when Connection is USB.",
};
diff --git a/wiki/concepts/printer-usb-transport.md b/wiki/concepts/printer-usb-transport.md
index 24e31d3..f5d37ac 100644
--- a/wiki/concepts/printer-usb-transport.md
+++ b/wiki/concepts/printer-usb-transport.md
@@ -121,6 +121,19 @@ completion signal, but Node cannot poll an arbitrary char-device fd without a na
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`
diff --git a/wiki/log.md b/wiki/log.md
index d79e78f..66c5d0f 100644
--- a/wiki/log.md
+++ b/wiki/log.md
@@ -2496,3 +2496,12 @@ close. writeAllUsb now holds back the FINAL byte as its own write — usblp's on
acceptance a completion certificate for everything before it — then drains 300ms for that single
packet before close. Tests updated (+ final-byte-alone assertion). [[printer-usb-transport]] has
the full kernel-level account.
+
+## [2026-07-07] update | USB printing HARDWARE-VERIFIED; wizard lists real /dev/usb devices
+
+Lab retest after the close-cancel fix: full slip + feed + CUT over USB — parity with TCP; the
+transport is done. Follow-up UX (operator had to `ls /dev/usb` to find lp1 on park-buzi): new
+GET /api/setup/usb-printers enumerates /dev/usb/lpN + sysfs ieee1284_id make/model; the wizard's
+devicePath is now a select of PRESENT printers (fresh form preselects the first; a saved-but-
+absent path stays selectable, flagged; none found → free-text + hint). Transport option label no
+longer hardcodes lp0. Details on [[printer-usb-transport]].