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
// (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
+52 -1
View File
@@ -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 ? " *" : ""}
</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
className="select"
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>
),
)}
+5
View File
@@ -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<AssignResult> {
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",
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…",
+2
View File
@@ -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…",