8 Commits

Author SHA1 Message Date
julian cd3b534e51 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
2026-07-07 11:35:25 +02:00
julian 011fe5a4c4 fix(devices): USB truncation mode 2 — close() kills the in-flight usblp URB
Build desktop / desktop (push) Successful in 4m16s
CI / check (push) Successful in 43s
Build & push images / images (push) Successful in 2m51s
The chunked-write fix (81bc2e3) still truncated on hardware: the lab
test slip stopped mid-sentence with no feed and no cut (text hidden
until the feed button). Verified against drivers/usb/class/usblp.c:

- write() returns at URB SUBMISSION, not completion;
- only ONE write URB is in flight (the next write EAGAINs until it
  completes);
- usblp_release() — 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. writeAllUsb now writes the payload's FINAL
BYTE alone — its acceptance proves everything before it is physically
in the printer — then drains 300 ms for that single packet before the
caller closes. New test pins the final-byte-alone chunking; wiki
printer-usb-transport.md carries the kernel-level account.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 10:50:47 +02:00
julian 6f3f6ca596 fix(fleet): park-lab stack points at server "park-lab"
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 43s
The lab box's Periphery onboarded as park-lab (the earlier park-test
name was from the first, discarded install attempt).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 10:06:47 +02:00
julian 5443b910c6 feat(fleet): add park-lab stack (lab bench, dev tier)
Build desktop / desktop (push) Successful in 4m15s
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 43s
Second [[stack]] block: server park-test (the lab box's Periphery
connect_as), compose files from the dev branch, MOVING TAG=dev (a lab
may float; real booths pin), its own park_lab_* secret refs (per-box
blast radius — never shared with a real booth). park-buzi is untouched
on stage + pinned.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 10:04:08 +02:00
julian a02957034d fix(web): setup allows adding a printer with no controller configured
Second half of the printer/relay decoupling: the category section's
add-button gate ("add a controller first — a printer points at one of
its relays") blocked every non-access category while zero controllers
existed — hit on the lab bench (USB printer test, no relays on hand).
Printers don't bind (role + failoverRank route jobs), so the gate now
exempts them like the form's requirement already does.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 09:54:59 +02:00
julian ee61c24bb9 docs(wiki): Periphery v2.2.0 --user installer defaults root_directory=/etc/komodo
Lab box (park-test) crash-looped: panic writing the agent key to
/etc/komodo/keys/periphery.key (Permission denied). Gotcha #9 was framed
as a hand-config hazard; v2.2.0's installer now writes the system-style
default even with --user. §7a: verify root_directory after every
install + sed fix + reset-failed/restart; user-unit vs sudo note; the
onboarding key survives a pre-connect crash.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 09:34:00 +02:00
julian 3a186d29df docs(wiki): runbook §5c uses gpasswd -d — deluser rejects hyphenated users
Build & push images / images (push) Successful in 3m14s
CI / check (push) Successful in 43s
Demoting the operator on park-buzi failed with "sanitize_string: invalid
characters in 'park-operator'" — Ubuntu's perl adduser/deluser tooling
rejects the hyphenated username. §5c now prescribes gpasswd -d for
sudo/lxd/lpadmin (shadow-suite, no perl sanitize) and documents that
group removal lands at NEXT login: the auto-login operator session keeps
its old memberships until reboot/relog, so verify `groups` from inside
the session afterwards.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 08:55:51 +02:00
julian 827445d514 deploy(park-buzi): pin TAG=stage-f9887c2 (supersedes d905dd1)
Carries since the deployed d905dd1: reports dashboard (occupancy curve,
hour×dow heatmap, stay histogram, fraud KPIs), USB printer chunked-write
fix (barcode + cut over usblp), driver rename cashino→escpos (migration
0023 rewrites device rows on boot), setup wizard printer-binding fix,
composer published-versions sidebar + increment-unit guards + currency-
scaled examples, lab fee breakdown, UI-wide "25 Qer" date standard,
camera health-check log bucketing, seed-admin role self-heal + signed
ledger event, Z-report label wording.

Post-deploy on-site: switch the ICS printer's driver to the generic
ESC/POS entry if still on rongta; USB print test (barcode + cut).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 08:55:51 +02:00
11 changed files with 286 additions and 32 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
+56 -3
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";
@@ -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>
), ),
)} )}
+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…",
+37 -1
View File
@@ -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);
}); });
+38 -15
View File
@@ -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.",
}; };
+30
View File
@@ -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`
+21 -6
View File
@@ -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
View File
@@ -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]].