From cd3b534e5174d19a7ec017febdcbcde0ac0363e0 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Tue, 7 Jul 2026 11:35:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(setup):=20USB=20printer=20discovery=20?= =?UTF-8?q?=E2=80=94=20pick=20a=20real=20/dev/usb=20device?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/server/src/routes/setup.ts | 33 ++++++++++++ apps/web/src/SetupWizard.tsx | 53 ++++++++++++++++++- apps/web/src/api.ts | 5 ++ apps/web/src/lib/i18n/en.ts | 2 + apps/web/src/lib/i18n/sq.ts | 2 + .../devices/src/drivers/printer-escpos.ts | 4 +- wiki/concepts/printer-usb-transport.md | 13 +++++ wiki/log.md | 9 ++++ 8 files changed, 118 insertions(+), 3 deletions(-) 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" ? (