feat(setup): USB printer discovery — pick a real /dev/usb device
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:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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) });
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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.",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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]].
|
||||||
|
|||||||
Reference in New Issue
Block a user