feat(setup): USB printer discovery — pick a real /dev/usb device
Build desktop / desktop (push) Successful in 4m21s
CI / check (push) Successful in 50s
Build & push images / images (push) Successful in 2m54s

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
This commit is contained in:
2026-07-07 11:35:25 +02:00
parent 011fe5a4c4
commit cd3b534e51
8 changed files with 118 additions and 3 deletions
+33
View File
@@ -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 // Assign a device. Validates the chosen driver + config, configures the device
// (fix preconditions + set up Digest-authenticated input push — no manual 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 // web-UI step by the admin), then persists. Fails the save if the device can't be
+52 -1
View File
@@ -29,6 +29,7 @@ import {
type RelayEvent, type RelayEvent,
type RelaySpec, type RelaySpec,
type TestResult, type TestResult,
fetchUsbPrinters,
} from "./api.js"; } from "./api.js";
import { Modal } from "./ui/Modal.js"; import { Modal } from "./ui/Modal.js";
@@ -546,6 +547,28 @@ function DeviceForm({
} }
return out; 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 // 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 // (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). // input + blink cadence; barriers carry no input wiring (that lives in `inputs` below).
@@ -890,7 +913,32 @@ function DeviceForm({
{f.label} {f.label}
{f.required ? " *" : ""} {f.required ? " *" : ""}
</label> </label>
{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.
<select
className="select"
value={String(config.devicePath ?? (f.default as string | undefined) ?? "")}
onChange={(e) => {
const v = e.target.value;
setConfig((c) => ({ ...c, devicePath: v }));
resetStatus();
}}
>
{(() => {
const cur = String(config.devicePath ?? (f.default as string | undefined) ?? "");
const missing = cur && !usbPrinters.some((u) => u.path === cur);
return [
...(missing ? [{ path: cur, description: t("setup.usbSavedMissing") }] : []),
...usbPrinters,
].map((u) => (
<option key={u.path} value={u.path}>
{u.description ? `${u.path} — ${u.description}` : u.path}
</option>
));
})()}
</select>
) : f.type === "select" ? (
<select <select
className="select" className="select"
value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")} value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")}
@@ -945,6 +993,9 @@ function DeviceForm({
}} }}
/> />
)} )}
{f.key === "devicePath" && usbPrinters != null && usbPrinters.length === 0 && (
<p className="hint mt-1">{t("setup.usbNoneFound")}</p>
)}
</div> </div>
), ),
)} )}
+5
View File
@@ -604,6 +604,11 @@ export interface AssignBody {
backendIp?: string; 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. */ /** Save + configure the device (preconditions, push setup), then persist. */
export function assignDevice(body: AssignBody): Promise<AssignResult> { export function assignDevice(body: AssignBody): Promise<AssignResult> {
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) }); return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
+2
View File
@@ -419,6 +419,8 @@ export const en: Catalog = {
scan: "Scan for controllers", scan: "Scan for controllers",
scanning: "Scanning…", scanning: "Scanning…",
noControllersFound: "No controllers found on the LAN.", 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", use: "Use",
test: "Test connection", test: "Test connection",
testing: "Testing…", testing: "Testing…",
+2
View File
@@ -428,6 +428,8 @@ export const sq = {
scan: "Skano për kontroller", scan: "Skano për kontroller",
scanning: "Duke skanuar…", scanning: "Duke skanuar…",
noControllersFound: "Asnjë kontroller në LAN.", 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", use: "Përdor",
test: "Testo lidhjen", test: "Testo lidhjen",
testing: "Duke testuar…", testing: "Duke testuar…",
@@ -775,7 +775,7 @@ export const transportField: ConfigField = {
default: "tcp-ip", default: "tcp-ip",
options: [ options: [
{ value: "tcp-ip", label: "Network (raw TCP)" }, { 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.", 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", type: "string",
required: false, required: false,
default: "/dev/usb/lp0", 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.",
}; };
+13
View File
@@ -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 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.) 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` > 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), > 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` > so on NETWORK the honest driver is **cashino** (reachability-only monitoring); under `rongta`
+9
View File
@@ -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 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 packet before close. Tests updated (+ final-byte-alone assertion). [[printer-usb-transport]] has
the full kernel-level account. 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]].