Compare commits
8 Commits
f9887c2a76
...
cd3b534e51
| Author | SHA1 | Date | |
|---|---|---|---|
| cd3b534e51 | |||
| 011fe5a4c4 | |||
| 6f3f6ca596 | |||
| 5443b910c6 | |||
| a02957034d | |||
| ee61c24bb9 | |||
| 3a186d29df | |||
| 827445d514 |
@@ -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";
|
||||||
|
|
||||||
@@ -257,8 +258,10 @@ function CategorySection({
|
|||||||
const [formFor, setFormFor] = useState<Assignment | "new" | null>(null);
|
const [formFor, setFormFor] = useState<Assignment | "new" | null>(null);
|
||||||
const [warnings, setWarnings] = useState<string[]>([]);
|
const [warnings, setWarnings] = useState<string[]>([]);
|
||||||
|
|
||||||
// Binding categories need a controller to point at first.
|
// Binding categories need a controller to point at first. Printers do NOT bind
|
||||||
const isBound = category !== "access";
|
// (role + failoverRank route print jobs — see printer-routing.ts), so they are
|
||||||
|
// addable on a controller-less box (e.g. the lab bench testing a USB printer).
|
||||||
|
const isBound = category !== "access" && category !== "printer";
|
||||||
const blockedNoController = isBound && controllers.length === 0;
|
const blockedNoController = isBound && controllers.length === 0;
|
||||||
const editing = formFor && formFor !== "new" ? formFor : undefined;
|
const editing = formFor && formFor !== "new" ? formFor : undefined;
|
||||||
|
|
||||||
@@ -544,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).
|
||||||
@@ -888,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) ?? "")}
|
||||||
@@ -943,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…",
|
||||||
|
|||||||
+37
-1
@@ -30,6 +30,42 @@
|
|||||||
# new [[stack]] block per site (unique name, its own per-booth secret refs).
|
# new [[stack]] block per site (unique name, its own per-booth secret refs).
|
||||||
##############################################################################
|
##############################################################################
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
# park-lab — the LAB bench box (hardware/dev testing, no real traffic). Chases
|
||||||
|
# the dev tier: compose files from `dev`, MOVING image tag `dev` (labs may
|
||||||
|
# float; real booths pin). Secrets are its own park_lab_* refs — per-box blast
|
||||||
|
# radius, never shared with a real booth even in the lab.
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
[[stack]]
|
||||||
|
name = "park-lab"
|
||||||
|
[stack.config]
|
||||||
|
server = "park-lab"
|
||||||
|
git_provider = "git.infra.msai.al"
|
||||||
|
git_account = "komodo"
|
||||||
|
repo = "mca/parking_solution"
|
||||||
|
branch = "dev"
|
||||||
|
file_paths = [
|
||||||
|
"docker-compose.yml",
|
||||||
|
"docker-compose.prod.yml"
|
||||||
|
]
|
||||||
|
registry_provider = "git.infra.msai.al"
|
||||||
|
registry_account = "komodo"
|
||||||
|
environment = """
|
||||||
|
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||||
|
# Lab tier: the MOVING dev tag — redeploy pulls the latest dev build. Pin to a
|
||||||
|
# dev-<sha> only when reproducing a specific state.
|
||||||
|
TAG=dev
|
||||||
|
COOKIE_SECURE=0
|
||||||
|
VISION_ENABLED=1
|
||||||
|
WS_ALLOWED_ORIGINS=
|
||||||
|
JWT_SECRET=[[park_lab_jwt_secret]]
|
||||||
|
EVENT_SIGNING_KEY=[[park_lab_event_signing_key]]
|
||||||
|
BACKUP_KEY=[[park_lab_backup_key]]
|
||||||
|
"""
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
[[stack]]
|
[[stack]]
|
||||||
name = "park-buzi"
|
name = "park-buzi"
|
||||||
[stack.config]
|
[stack.config]
|
||||||
@@ -49,7 +85,7 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
|
|||||||
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||||
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||||
# exists as the pointer; we deploy the sha, not the mover.
|
# exists as the pointer; we deploy the sha, not the mover.
|
||||||
TAG=stage-14638c2
|
TAG=stage-f9887c2
|
||||||
COOKIE_SECURE=0
|
COOKIE_SECURE=0
|
||||||
VISION_ENABLED=1
|
VISION_ENABLED=1
|
||||||
WS_ALLOWED_ORIGINS=
|
WS_ALLOWED_ORIGINS=
|
||||||
|
|||||||
@@ -185,11 +185,12 @@ describe("stamp (Albanian date format)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("writeAllUsb — the char device's partial writes / EAGAIN (field bug 2026-07-06)", () => {
|
describe("writeAllUsb — partial writes / EAGAIN / close-cancel (field bugs 2026-07-06/07)", () => {
|
||||||
// A NONBLOCK usblp fd accepts only what fits the printer's USB buffer per write.
|
// A NONBLOCK usblp fd accepts only what fits the printer's USB buffer per write,
|
||||||
// The old single-write path dropped everything past the first buffer — the ICS
|
// write() returns at URB submission, and close() KILLS the in-flight URB — so the
|
||||||
// XP-K200L printed the ticket's text head but lost the barcode and the cut. A
|
// loop must deliver every byte AND certify delivery before the caller may close
|
||||||
// regular file can't reproduce that, so these drive the loop with a fake handle.
|
// (final byte written alone; its acceptance proves all prior bytes landed). A
|
||||||
|
// regular file can't reproduce any of that, so these drive a fake handle.
|
||||||
|
|
||||||
/** Accepts at most `cap` bytes per call; records everything accepted in order. */
|
/** Accepts at most `cap` bytes per call; records everything accepted in order. */
|
||||||
function slowHandle(cap: number) {
|
function slowHandle(cap: number) {
|
||||||
@@ -207,10 +208,18 @@ describe("writeAllUsb — the char device's partial writes / EAGAIN (field bug 2
|
|||||||
it("delivers the WHOLE payload across many short writes (barcode + cut included)", async () => {
|
it("delivers the WHOLE payload across many short writes (barcode + cut included)", async () => {
|
||||||
const payload = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" });
|
const payload = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" });
|
||||||
const h = slowHandle(100); // way smaller than the job → many partial writes
|
const h = slowHandle(100); // way smaller than the job → many partial writes
|
||||||
await writeAllUsb(h, payload, Date.now() + 2000);
|
await writeAllUsb(h, payload, Date.now() + 2000, 5);
|
||||||
expect(Buffer.concat(h.chunks).equals(payload)).toBe(true);
|
expect(Buffer.concat(h.chunks).equals(payload)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("the FINAL byte is written alone — the delivery certificate before close", async () => {
|
||||||
|
const payload = Buffer.from("x".repeat(5000)); // > one 4K chunk
|
||||||
|
const h = slowHandle(100_000); // accepts anything → chunking is ours, not the cap's
|
||||||
|
await writeAllUsb(h, payload, Date.now() + 2000, 5);
|
||||||
|
expect(Buffer.concat(h.chunks).equals(payload)).toBe(true);
|
||||||
|
expect(h.chunks.at(-1)!.length).toBe(1); // usblp: its acceptance proves the rest landed
|
||||||
|
});
|
||||||
|
|
||||||
it("retries EAGAIN (buffer full) until the kernel accepts the rest", async () => {
|
it("retries EAGAIN (buffer full) until the kernel accepts the rest", async () => {
|
||||||
const payload = Buffer.from("x".repeat(300));
|
const payload = Buffer.from("x".repeat(300));
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
@@ -228,7 +237,7 @@ describe("writeAllUsb — the char device's partial writes / EAGAIN (field bug 2
|
|||||||
return Promise.resolve({ bytesWritten: n });
|
return Promise.resolve({ bytesWritten: n });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await writeAllUsb(h, payload, Date.now() + 2000);
|
await writeAllUsb(h, payload, Date.now() + 2000, 5);
|
||||||
expect(Buffer.concat(accepted).equals(payload)).toBe(true);
|
expect(Buffer.concat(accepted).equals(payload)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -604,6 +604,12 @@ function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
|
|||||||
* so jobs are pushed in chunks safely under that. */
|
* so jobs are pushed in chunks safely under that. */
|
||||||
const USB_WRITE_CHUNK = 4096;
|
const USB_WRITE_CHUNK = 4096;
|
||||||
|
|
||||||
|
/** Pause after the FINAL byte's write is accepted, before close. Its acceptance
|
||||||
|
* proves everything before it is physically in the printer (see writeAllUsb); this
|
||||||
|
* covers the one-byte URB still in flight — a single bulk packet the printer ACKs
|
||||||
|
* immediately (it just freed buffer space by ACKing the previous chunk). */
|
||||||
|
const USB_DRAIN_MS = 300;
|
||||||
|
|
||||||
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
/** The slice of FileHandle the USB write loop needs (injectable for tests — a real
|
/** The slice of FileHandle the USB write loop needs (injectable for tests — a real
|
||||||
@@ -613,31 +619,45 @@ export interface UsbWriteHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Push the WHOLE payload through a non-blocking usblp fd. On O_NONBLOCK the kernel
|
* Push the WHOLE payload through a non-blocking usblp fd AND ensure the printer has
|
||||||
* takes only what fits the printer's USB buffer and returns a SHORT write (or EAGAIN
|
* physically received it before the caller may close. TWO field-verified truncation
|
||||||
* when full) — a single fire-and-forget write() silently drops the tail of any job
|
* modes on the ICS XP-K200L (same symptom: text head prints, barcode/feed/CUT tail
|
||||||
* bigger than one buffer. That was a real field bug (2026-07-06, ICS XP-K200L over
|
* lost; TCP fine):
|
||||||
* USB): the text head printed, but the barcode mid-payload and the CUT at the end
|
*
|
||||||
* were in the dropped tail — "prints, but no barcode and no cut", while the same
|
* 1. SHORT WRITES (2026-07-06): a single fire-and-forget write() only delivers what
|
||||||
* bytes over TCP were fine. So: loop until every byte is accepted, retrying EAGAIN
|
* the kernel accepts. Fix: chunked loop, retry EAGAIN, until all bytes accepted.
|
||||||
* and zero-byte writes with a short pause, bounded by the caller's deadline.
|
* 2. CLOSE CANCELS THE LAST TRANSFER (2026-07-07, lab bench): per usblp.c, write()
|
||||||
|
* returns at URB *submission*, only ONE write URB is in flight at a time, and
|
||||||
|
* usblp_release() (our close) KILLS in-flight URBs. The printer consumes bulk
|
||||||
|
* data at PRINT speed (tiny internal buffer), so closing right after the last
|
||||||
|
* accepted write cancels the still-transferring tail — which is exactly where
|
||||||
|
* the feed + GS V cut live ("have to press the feed button to see the text").
|
||||||
|
*
|
||||||
|
* The delivery guarantee follows from usblp's one-URB rule: ACCEPTANCE OF WRITE N
|
||||||
|
* PROVES WRITE N−1 FULLY COMPLETED (the driver EAGAINs until the previous URB's
|
||||||
|
* completion). So the payload is pushed as chunks, then its FINAL BYTE alone: when
|
||||||
|
* that 1-byte write is accepted, every byte before it is physically in the printer.
|
||||||
|
* A short drain pause then covers the lone final-byte URB (one bulk packet), and
|
||||||
|
* close is safe. `drainMs` is parameterised only for tests.
|
||||||
*/
|
*/
|
||||||
export async function writeAllUsb(
|
export async function writeAllUsb(
|
||||||
handle: UsbWriteHandle,
|
handle: UsbWriteHandle,
|
||||||
payload: Buffer,
|
payload: Buffer,
|
||||||
deadlineMs: number,
|
deadlineMs: number,
|
||||||
|
drainMs: number = USB_DRAIN_MS,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (payload.length === 0) return;
|
||||||
|
const lastByteAt = payload.length - 1;
|
||||||
let off = 0;
|
let off = 0;
|
||||||
while (off < payload.length) {
|
while (off < payload.length) {
|
||||||
if (Date.now() > deadlineMs) {
|
if (Date.now() > deadlineMs) {
|
||||||
throw new Error(`usb write timeout (${off}/${payload.length} bytes accepted)`);
|
throw new Error(`usb write timeout (${off}/${payload.length} bytes accepted)`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { bytesWritten } = await handle.write(
|
// Never let the final byte ride a bigger chunk: it is written ALONE so its
|
||||||
payload,
|
// acceptance certifies delivery of everything before it (see doc above).
|
||||||
off,
|
const len = off === lastByteAt ? 1 : Math.min(USB_WRITE_CHUNK, lastByteAt - off);
|
||||||
Math.min(USB_WRITE_CHUNK, payload.length - off),
|
const { bytesWritten } = await handle.write(payload, off, len);
|
||||||
);
|
|
||||||
off += bytesWritten;
|
off += bytesWritten;
|
||||||
if (bytesWritten === 0) await delay(5); // buffer full, no error — breathe
|
if (bytesWritten === 0) await delay(5); // buffer full, no error — breathe
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -648,6 +668,9 @@ export async function writeAllUsb(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// All bytes accepted; only the 1-byte final URB can still be in flight. Give it a
|
||||||
|
// moment to land before the caller closes (close would cancel it).
|
||||||
|
await delay(drainMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp
|
/** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp
|
||||||
@@ -752,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.",
|
||||||
};
|
};
|
||||||
@@ -764,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.",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -104,6 +104,36 @@ deadline with a `(N/M bytes accepted)` diagnostic. Driven by fake-handle tests (
|
|||||||
EAGAIN interleave, wedged-printer timeout, non-EAGAIN passthrough) since a real file can't
|
EAGAIN interleave, wedged-printer timeout, non-EAGAIN passthrough) since a real file can't
|
||||||
reproduce the char device's behaviour.
|
reproduce the char device's behaviour.
|
||||||
|
|
||||||
|
**Second truncation mode — close() cancels the in-flight transfer (lab bench, 2026-07-07).** The
|
||||||
|
chunked loop alone STILL truncated on hardware (test slip stopped mid-sentence, no feed, no cut —
|
||||||
|
"press the feed button to see the text"). Verified against `drivers/usb/class/usblp.c`: `write()`
|
||||||
|
returns at URB *submission* (not completion), only ONE write URB is in flight at a time (the next
|
||||||
|
write EAGAINs until it completes), and `usblp_release()` — i.e. our `close()` — **kills in-flight
|
||||||
|
URBs**. The printer drains bulk data at PRINT speed (tiny internal buffer on these clones), so
|
||||||
|
closing right after the last accepted write cancels the still-transferring tail — exactly where
|
||||||
|
the feed + `GS V` cut bytes live. Kernel-accepted ≠ printer-received.
|
||||||
|
|
||||||
|
Fix: the one-URB rule makes acceptance of write N a **completion certificate for write N−1**. So
|
||||||
|
`writeAllUsb` now writes the payload's FINAL BYTE alone: when that 1-byte write is accepted, every
|
||||||
|
byte before it is physically in the printer; a short drain pause (`USB_DRAIN_MS` 300 ms) covers
|
||||||
|
the lone final-byte packet, then close is safe. (usblp also implements `poll(POLLOUT)` as the true
|
||||||
|
completion signal, but Node cannot poll an arbitrary char-device fd without a native dep — the
|
||||||
|
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`
|
> 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`
|
||||||
|
|||||||
@@ -216,17 +216,24 @@ adversary). Create a dedicated **admin** (real password, sudo, NO auto-login) an
|
|||||||
```bash
|
```bash
|
||||||
sudo adduser admin && sudo usermod -aG sudo admin
|
sudo adduser admin && sudo usermod -aG sudo admin
|
||||||
# VERIFY in a second session: log in as admin → `sudo whoami` prints root — BEFORE the next step:
|
# VERIFY in a second session: log in as admin → `sudo whoami` prints root — BEFORE the next step:
|
||||||
sudo deluser <operator> sudo # demote the auto-login operator
|
sudo gpasswd -d <operator> sudo # demote the auto-login operator
|
||||||
groups <operator> # confirm: no 'sudo'
|
groups <operator> # confirm: no 'sudo'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> Use **`gpasswd -d`**, not `deluser <user> <group>`: on this Ubuntu the perl adduser tooling
|
||||||
|
> rejects hyphenated usernames (`sanitize_string: invalid characters in 'park-operator'` —
|
||||||
|
> VERIFIED on park-buzi 2026-07-06). And group removal applies at **next login** — the auto-login
|
||||||
|
> operator session keeps its old memberships until the box reboots (or the session relogs);
|
||||||
|
> re-verify `groups` from inside the operator session afterwards.
|
||||||
|
|
||||||
⚠ Order matters: confirm the new admin's sudo works **before** demoting the operator, or you lock
|
⚠ Order matters: confirm the new admin's sudo works **before** demoting the operator, or you lock
|
||||||
yourself out. Keep auto-login on the OPERATOR, not admin. **Leave root password disabled** (Ubuntu
|
yourself out. Keep auto-login on the OPERATOR, not admin. **Leave root password disabled** (Ubuntu
|
||||||
default) — `admin`+sudo IS the root path; enabling root adds risk, no gain.
|
default) — `admin`+sudo IS the root path; enabling root adds risk, no gain.
|
||||||
|
|
||||||
> Strip latent escalation groups from the operator: **`sudo deluser <operator> lxd`** (lxd group =
|
> Strip latent escalation groups from the operator: **`sudo gpasswd -d <operator> lxd`** (lxd group
|
||||||
> launch a privileged container that mounts host `/` as root — undoes the no-sudo hardening) and
|
> = launch a privileged container that mounts host `/` as root — undoes the no-sudo hardening) and
|
||||||
> `lpadmin` (printer admin, unneeded). And NEVER add the operator to `docker` (also root-equivalent).
|
> `sudo gpasswd -d <operator> lpadmin` (printer admin, unneeded). And NEVER add the operator to
|
||||||
|
> `docker` (also root-equivalent).
|
||||||
|
|
||||||
## 5b. Further hardening (TODO — not yet done)
|
## 5b. Further hardening (TODO — not yet done)
|
||||||
|
|
||||||
@@ -280,8 +287,16 @@ sudo loginctl enable-linger admin # so the user service starts at boot witho
|
|||||||
NOT `:9120` — Core's container port `9120` is exposed-not-published; the agent reaches it through
|
NOT `:9120` — Core's container port `9120` is exposed-not-published; the agent reaches it through
|
||||||
the proxy. (Gotcha #7 below.)
|
the proxy. (Gotcha #7 below.)
|
||||||
- Config lands at `~/.config/komodo/periphery.config.toml`. The key field is **`core_address`**
|
- Config lands at `~/.config/komodo/periphery.config.toml`. The key field is **`core_address`**
|
||||||
(singular); `root_directory` must be a path `admin` can write (user-mode default is fine — a
|
(singular); `root_directory` must be a path `admin` can write. **⚠ VERIFY THIS after install —
|
||||||
`/etc/komodo` default from a system install would `Permission denied` for the user service).
|
Periphery v2.2.0's installer writes `root_directory = "/etc/komodo"` even with `--user`**
|
||||||
|
(bit the lab box 2026-07-07: panic `Failed to write private key pem to "/etc/komodo/keys/
|
||||||
|
periphery.key" … Permission denied`, crash-loop until systemd gives up). Fix + restart:
|
||||||
|
```bash
|
||||||
|
sed -i 's|^root_directory = .*|root_directory = "'"$HOME"'/.komodo"|' ~/.config/komodo/periphery.config.toml
|
||||||
|
systemctl --user reset-failed periphery && systemctl --user restart periphery
|
||||||
|
```
|
||||||
|
NB `sudo systemctl restart periphery` says *unit not found* — it's a USER unit; always
|
||||||
|
`systemctl --user …`. The onboarding key survives a pre-connect crash (unused until first dial).
|
||||||
|
|
||||||
Verify: `systemctl --user status periphery` → active; the server **`park-buzi`** appears and goes
|
Verify: `systemctl --user status periphery` → active; the server **`park-buzi`** appears and goes
|
||||||
**OK/green** in Core → Servers. Then **delete the onboarding key**.
|
**OK/green** in Core → Servers. Then **delete the onboarding key**.
|
||||||
|
|||||||
+46
@@ -2459,3 +2459,49 @@ stays attributable in the chain. Best-effort: no build/key → loud warning, see
|
|||||||
(verified both paths on a scratch DB). [[appliance-provisioning]] gained §7e: FORCE=1 reset
|
(verified both paths on a scratch DB). [[appliance-provisioning]] gained §7e: FORCE=1 reset
|
||||||
commands (interactive preferred — keeps the password out of shell history), sessions-not-revoked
|
commands (interactive preferred — keeps the password out of shell history), sessions-not-revoked
|
||||||
caveat + JWT_SECRET rotation for suspected theft, role-row self-heal note added to §7d.
|
caveat + JWT_SECRET rotation for suspected theft, role-row self-heal note added to §7d.
|
||||||
|
|
||||||
|
## [2026-07-06] update | Runbook §5c: gpasswd -d, not deluser (hyphenated-username perl bug)
|
||||||
|
|
||||||
|
Demoting the operator on park-buzi hit `sanitize_string: invalid characters in 'park-operator'` —
|
||||||
|
Ubuntu's perl adduser/deluser tooling rejects the hyphenated username. [[appliance-provisioning]]
|
||||||
|
§5c now uses `gpasswd -d <operator> sudo|lxd|lpadmin` (shadow-suite, no perl sanitize) and notes
|
||||||
|
that group removal applies at NEXT login — the auto-login operator session keeps old memberships
|
||||||
|
until reboot/relog, so verify `groups` from inside the session afterwards.
|
||||||
|
|
||||||
|
## [2026-07-07] update | Periphery v2.2.0 --user installer writes /etc/komodo root_directory
|
||||||
|
|
||||||
|
Lab test box (park-test): periphery crash-looped at startup — panic writing the private key to
|
||||||
|
/etc/komodo/keys/periphery.key, Permission denied. Gotcha #9 was documented as a hand-config
|
||||||
|
hazard, but v2.2.0's installer now DEFAULTS root_directory to /etc/komodo even with --user (the
|
||||||
|
June park-buzi install defaulted under $HOME). [[appliance-provisioning]] §7a now says: verify
|
||||||
|
root_directory after every install + the sed one-liner fix; also notes `sudo systemctl restart
|
||||||
|
periphery` → "unit not found" (user unit) and that the single-use onboarding key survives a
|
||||||
|
pre-connect crash.
|
||||||
|
|
||||||
|
## [2026-07-07] update | Setup: printers addable with NO controller configured
|
||||||
|
|
||||||
|
Lab bench (USB printer test, no relays on hand) hit a SECOND printer/relay coupling the
|
||||||
|
2026-07-06 fix missed: the category section's add-button gate ("Shto fillimisht një kontroller…")
|
||||||
|
blocks every non-access category while zero controllers exist. Printers are now exempt there too
|
||||||
|
— the binding fix removed the requirement inside the form; this removes the gate in front of it.
|
||||||
|
A controller-less box can configure + test a printer.
|
||||||
|
|
||||||
|
## [2026-07-07] update | USB truncation, mode 2: close() kills the in-flight usblp URB
|
||||||
|
|
||||||
|
Lab hardware test of the chunked-write fix STILL truncated (slip stopped mid-sentence, no cut,
|
||||||
|
text hidden until the feed button). Root cause verified against kernel usblp.c: write() returns at
|
||||||
|
URB submission; one URB in flight; usblp_release (close) kills it; the printer drains at print
|
||||||
|
speed — so the accepted-but-untransferred tail (incl. feed+cut, always the last bytes) died at
|
||||||
|
close. writeAllUsb now holds back the FINAL byte as its own write — usblp's one-URB rule makes its
|
||||||
|
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]].
|
||||||
|
|||||||
Reference in New Issue
Block a user