10 Commits

Author SHA1 Message Date
julian fe3b12a60d bump(resources): park-buzi, park-2 and park-lab to stage-88f9c53 (K200L status parser fix)
Build & push images / images (push) Successful in 2m58s
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-09 13:00:14 +02:00
julian 88f9c53fda fix(devices): K200L status parser reads a fault's Yes, which the board wraps in <FONT color=#ff0000>
Build & push images / images (push) Successful in 2m55s
First live run on park-lab with the cover open reported "unexpected status page
(missing coverOpen, paperEnd, offline)" — exactly the three fault cells. The board
writes a fault as <FONT color=#ff0000>Yes</FONT> and a clear row as a bare padded
No; the parser accepted only tag-free cells. Cell text is now read with inner tags
stripped (row-anchored match). Tests pin the verbatim captured markup plus other
shapes. Live after the fix: degraded "cover open, paper out, printer off-line";
cover closed → ready.

Wiki: the markup on the K200L page; Periphery "not loaded after reboot" (unit never
enabled → `systemctl --user enable --now periphery`) as a §7a gotcha in the
provisioning runbook; log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-09 12:57:58 +02:00
julian e8cb057082 bump(resources): update TAG to stage-4a02e5f for all booth synchronizations
Build & push images / images (push) Successful in 2m47s
2026-09-09 12:41:15 +02:00
julian 4a02e5fed3 docs(wiki): site device installation — the field runbook for what to know before the booth
Build & push images / images (push) Successful in 2m50s
Per device (Dingtian relay board, DT-008 readers, Hikvision G3H camera, radar,
K200L / Rongta / Cashino printers): factory address and login, the tool needed,
what the wizard configures by itself on assign vs what is done by hand on the
device, how to verify, and the traps already paid for (unauthenticated Dingtian
CGI + session_en brick, duplicate reader IP, reader settings lost on reset, sub
stream mandatory, Alarm Server after assign + Vehicle target filter, close the
camera web UI, radar idle level, "Test" only probes a printer). Bring list, the
10.0.10.x address plan beside each factory address, the on-site order of work,
and the gaps still unrecorded. Linked from appliance-provisioning and the K200L
page; log entries for the day.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-09 12:34:59 +02:00
julian 552d87d75b feat(devices): K200L printer driver — live cover/paper status from the J-Speed LAN board
The park-buzi printer is a K200L (Xprinter/ICS XP-K200L; its LAN board and USB
descriptor call it "POS-80"). Its board serves the Rongta's five-row status table
under /prt_status.htm — but the reply carries no HTTP status line or headers, which
node:http rejects, so the Rongta driver could never read it and the unit was filed
in July as "no status page → generic driver" (reachability only).

New `k200l` driver (printer-k200l.ts): prints through the generic ESC/POS device
(same bytes, TCP 9100 or usblp) and reads the page over a raw socket, tolerant of
both the headerless and a proper HTTP reply. Mapping mirrors the Rongta: board
unreachable → offline; page not understood → degraded, never ready; any fault →
degraded naming it; USB → reachability floor. The Rongta driver is untouched.
Tests replay the captured headerless page (devices suite 76). Live against the
lab unit: ready; with the cover open the board reports cover open, paper out,
off-line.

Wiki: new k200l-printer entity (names, network setup from factory
192.168.123.100, board quirks, status page, what it means for park-buzi — over
USB the app never saw cover/paper state at all), cross-links on the Rongta,
status-monitoring, USB-transport and WSL-networking pages (parking-net pinned to
eth1 while the LAN NIC is eth0), index.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-09 12:34:59 +02:00
julian 7e21cf057e chore(komodo): park-lab stack for the printer bench; wiki: cover-open bug bench result
Build & push images / images (push) Successful in 3m3s
park-lab re-added to resources.toml for the reproduction box: copied from park-2 then
corrected — the copy carried park-2's review outbox (collector URL, booth-2 id, booth-2's
token), which would have fed the training pool under a booth's identity; removed, the
outbox is off on the bench. Pinned to the booth's stage-2d9bb15, comments say what the
lab is for.

Wiki, printer-usb-transport: the failing printer identified (USB 1fc9:2016 "POS-80", NXP
controller, no brand in the descriptor); attached to WSL via usbipd and cover-cycled — no
disconnect, no re-enumeration, so the stale /dev/usb bind-mount hypothesis is falsified
for this unit; the next discriminator is the monitor's offline detail text on park-buzi
(EBUSY / open timeout / EIO). WSL caveat: Microsoft's kernel lacks CONFIG_USB_PRINTER.
fleet-deployment-komodo: park-lab row updated. Log: both entries of the day.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-09 10:19:18 +02:00
julian 4fd175e0e4 docs(wiki): subscription recurring billing + party ledger — design only
Subscription page: the one-window model can't express prepaid/postpaid, calendar or
anniversary anchoring, grace or an expiry notice; and renewal is OFF-BOOK today (a PUT
that appends no payment — the same hole closed for the first sale on 2026-06-20).
Designed: plan billing rule, per-day pricing so both anchors share one formula, open-
ended agreement, subscription_periods where each period is a ledger charge and a renewal
= paying the next period, one subscriptionAccess() gate function, expiry notice derived
not stored.

New decision page party-ledger: a counterparty sub-ledger for who-owes-whom across
modules — parties + signed charge / settlement / write_off events, balance derived never
stored, aging + statements + CSV; lands postpaid subscriptions, hotel guest-nights,
fleet washes on account, supplier/utility bills. Sub-ledger only: no bookkeeping, a
statement is not a fiscal invoice, parties per appliance. validation-sponsorship's
sponsor table marked superseded; open-questions #17; index.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-09 10:19:09 +02:00
julian 535244209a docs(wiki): seed-admin on a booth — container-name pattern, prompting form, idempotence, re-seed after a reset
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 15:06:19 +02:00
julian 0845e87ddd bump(resources): park-2 and wash-collector to stage-2d9bb15
Build & push images / images (push) Successful in 2m48s
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 14:57:47 +02:00
julian 2d9bb15d4c fix(db): reset-db categorises the Car Wash tables and role_jobs — the drift guard refused every reset on a booth carrying them
Build & push images / images (push) Successful in 2m47s
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 14:37:49 +02:00
22 changed files with 1401 additions and 25 deletions
+45 -3
View File
@@ -49,7 +49,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-8fa66c9 TAG=stage-88f9c53
COOKIE_SECURE=0 COOKIE_SECURE=0
# Venue modules this site is ENTITLED to (vendor decision; the site admin activates within # Venue modules this site is ENTITLED to (vendor decision; the site admin activates within
# this set in Setup → Site). Unset = every registered module. See wiki/decisions/venue-modules.md. # this set in Setup → Site). Unset = every registered module. See wiki/decisions/venue-modules.md.
@@ -85,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-dbbb051 TAG=stage-88f9c53
COOKIE_SECURE=0 COOKIE_SECURE=0
# Venue modules this site is ENTITLED to (vendor decision; the site admin activates within # Venue modules this site is ENTITLED to (vendor decision; the site admin activates within
# this set in Setup → Site). Unset = every registered module. See wiki/decisions/venue-modules.md. # this set in Setup → Site). Unset = every registered module. See wiki/decisions/venue-modules.md.
@@ -110,6 +110,48 @@ EVENT_SIGNING_KEY=[[park_2_event_signing_key]]
BACKUP_KEY=[[park_2_backup_key]] BACKUP_KEY=[[park_2_backup_key]]
""" """
##############################################################################
# Stack — the LAB BENCH (not a booth): a spare Linux box with the field printer and
# whatever device is under investigation, so a booth bug can be reproduced on the booth's
# exact image before touching a real site. Same compose files + pinned TAG as the staging
# booths. No review outbox (the lab is not a site — it must never feed the training pool
# under a booth's identity). Its own secrets. See wiki/decisions/fleet-deployment-komodo.md.
##############################################################################
[[stack]]
name = "park-lab"
[stack.config]
server = "park-lab"
git_provider = "git.infra.msai.al"
git_account = "komodo"
repo = "mca/parking_solution"
branch = "stage"
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: pinned to the SAME stage-<sha> as the booth whose bug is being reproduced (bump
# alongside it). A lab may float, but a reproduction must run the booth's exact image.
TAG=stage-88f9c53
COOKIE_SECURE=0
# Entitled to Car Wash too, so the wash-desk printer role and till can be exercised on the bench.
MODULES_ENTITLED=parking,carwash
# NO review outbox on the lab (CARWASH_REVIEW_URL/BOOTH_ID/TOKEN deliberately unset): the
# collector's training pool is per-booth, and the bench is not a booth.
VISION_ENABLED=1
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
# platform-ws.ts, since the native WS plugin has no page context to auto-attach
# one). Linux may also send http://tauri.localhost. See routes/ws.ts anti-CSWSH check.
WS_ALLOWED_ORIGINS=tauri://localhost,http://tauri.localhost
JWT_SECRET=[[park_lab_jwt_secret]]
EVENT_SIGNING_KEY=[[park_lab_event_signing_key]]
BACKUP_KEY=[[park_lab_backup_key]]
"""
############################################################################## ##############################################################################
# Stack — the Car Wash REVIEW COLLECTOR on the reviewer's host (art-docker-station), # Stack — the Car Wash REVIEW COLLECTOR on the reviewer's host (art-docker-station),
# NOT a booth. Same repo/branch/TAG promotion as the booths, but its file_paths name # NOT a booth. Same repo/branch/TAG promotion as the booths, but its file_paths name
@@ -134,7 +176,7 @@ registry_account = "komodo"
environment = """ environment = """
REGISTRY=git.infra.msai.al/mca/parking_solution REGISTRY=git.infra.msai.al/mca/parking_solution
# Pinned like the booths: bump to the stage-<sha> that carries the collector. # Pinned like the booths: bump to the stage-<sha> that carries the collector.
TAG=stage-f7a262a TAG=stage-2d9bb15
# The host's NETBIRD address (an IP: Docker port bindings take no hostname) — the ingest port # The host's NETBIRD address (an IP: Docker port bindings take no hostname) — the ingest port
# is published on the overlay only. Booths reach it by its Netbird DNS name. # is published on the overlay only. Booths reach it by its Netbird DNS name.
COLLECTOR_BIND=100.75.184.156 COLLECTOR_BIND=100.75.184.156
+12 -1
View File
@@ -51,6 +51,10 @@ const CATEGORIES = {
"subscription_credentials", "subscription_credentials",
"subscriptions", "subscriptions",
"blocklist", "blocklist",
// Car Wash (venue module): orders are money history (settled against ledger events);
// the review outbox is a delivery queue of crops + choices — both go with the ledger.
"carwash_review_outbox",
"carwash_orders",
], ],
config: [ config: [
"site_config", "site_config",
@@ -65,8 +69,15 @@ const CATEGORIES = {
// whose user is gone grants nothing. // whose user is gone grants nothing.
"validation_program_users", "validation_program_users",
"validation_programs", "validation_programs",
// Car Wash master data: prices reference categories + services (child first); the
// module's site-level config (pay-at, vision threshold) is config like site_config.
"carwash_prices",
"carwash_categories",
"carwash_services",
"carwash_config",
], ],
users: ["sessions", "role_permissions", "users", "roles"], // role_jobs = which jobs a role follows (child of roles).
users: ["sessions", "role_permissions", "role_jobs", "users", "roles"],
diagnostics: ["app_logs"], diagnostics: ["app_logs"],
}; };
+3
View File
@@ -6,6 +6,7 @@ import { dingtianDriver } from "./access-dingtian.js";
import { stubAccessDriver } from "./access-stub.js"; import { stubAccessDriver } from "./access-stub.js";
import { dahuaDriver, hikvisionDriver } from "./camera.js"; import { dahuaDriver, hikvisionDriver } from "./camera.js";
import { escposDriver } from "./printer-generic.js"; import { escposDriver } from "./printer-generic.js";
import { k200lDriver } from "./printer-k200l.js";
import { rongtaDriver } from "./printer-rongta.js"; import { rongtaDriver } from "./printer-rongta.js";
import { dingtianQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js"; import { dingtianQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
@@ -23,6 +24,7 @@ export function registerBuiltinDrivers(): void {
registry.register(hikvisionDriver); registry.register(hikvisionDriver);
registry.register(dahuaDriver); registry.register(dahuaDriver);
registry.register(rongtaDriver); registry.register(rongtaDriver);
registry.register(k200lDriver);
registry.register(escposDriver); registry.register(escposDriver);
} }
@@ -35,5 +37,6 @@ export {
hikvisionDriver, hikvisionDriver,
dahuaDriver, dahuaDriver,
rongtaDriver, rongtaDriver,
k200lDriver,
escposDriver, escposDriver,
}; };
@@ -37,6 +37,8 @@ import {
// local usblp char device (/dev/usb/lp0); TCP writes to the raw print socket. This // local usblp char device (/dev/usb/lp0); TCP writes to the raw print socket. This
// clone family is the natural USB candidate — reachability-only, no page to lose. // clone family is the natural USB candidate — reachability-only, no page to lose.
// //
// (The K200L / XP-K200L is the exception: its LAN board DOES serve a status page,
// /prt_status.htm — use the `k200l` driver for it over TCP; see printer-k200l.ts.)
// Therefore this driver deliberately does NOT implement MonitorableDevice // Therefore this driver deliberately does NOT implement MonitorableDevice
// (no readStatus). The device monitor then falls back to the generic // (no readStatus). The device monitor then falls back to the generic
// `healthCheck()` — a plain TCP reachability PING of the print socket. So the // `healthCheck()` — a plain TCP reachability PING of the print socket. So the
@@ -0,0 +1,230 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createServer, type Server } from "node:net";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseRawReply, parseStatusPage, k200lDriver } from "./printer-k200l.js";
import { renderTicket } from "./printer-escpos.js";
import type { MonitorableDevice, PrinterDevice } from "../interfaces.js";
// The K200L (Xprinter / ICS; J-Speed 'POS-80' LAN board) driver. Its status page was captured verbatim
// from the unit on the lab bench, 2026-09-09: uppercase tags, values padded with
// spaces, and — the part that matters — the board's reply has NO status line and NO
// headers (the body starts at byte 0). The tests replay exactly that over a raw
// socket, plus a proper-HTTP variant, so the driver is proven against both.
/** The board's status table, as sent (CRLF, uppercase, padded values). */
function boardPage(flags: Partial<Record<string, "Yes" | "No">> = {}): string {
const v = (k: string) => `${flags[k] ?? "No"} `;
return [
'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">',
"<HTML><HEAD><TITLE>Printer Status</TITLE>",
"<META http-equiv=refresh content=\"5;url='prt_status.htm'\"></HEAD>",
'<BODY><FORM id=Form1 action="prt_status.htm" method="get">',
"<TABLE id=Table3 cellPadding=3 border=0><TBODY>",
`<TR><TD>Cover Is Open</TD><TD style="width: 23px">${v("cover")}</TD></TR>`,
`<TR><TD>Cutter Error</TD><TD style="width: 23px">${v("cutter")}</TD></TR>`,
`<TR><TD>Paper End</TD><TD style="width: 23px">${v("paperEnd")}</TD></TR>`,
`<TR><TD>Paper Near End</TD><TD style="width: 23px">${v("nearEnd")}</TD></TR>`,
`<TR><TD>Printer Off-Line</TD><TD style="width: 23px">${v("offline")}</TD></TR></TBODY></TABLE>`,
'<INPUT type=submit value="Print Test Page" name=page_p2></FORM></BODY></HTML>',
].join("\r\n");
}
const INDEX =
"<HTML><HEAD><TITLE>Ethernet port configuration</TITLE></HEAD><BODY><TABLE><TR><TD>Mac Address</TD><TD>00-D8-23-5C-58-8C</TD></TR></TABLE></BODY></HTML>";
type Reply = { body: string; status?: number; raw?: boolean };
describe("parseRawReply", () => {
it("treats a reply without a status line as HTTP/0.9: the whole reply is the body", () => {
const r = parseRawReply("<!DOCTYPE HTML><HTML>x</HTML>");
expect(r.status).toBe(200);
expect(r.body).toBe("<!DOCTYPE HTML><HTML>x</HTML>");
});
it("splits a real HTTP reply into status and body", () => {
const r = parseRawReply("HTTP/1.0 404 Not Found\r\nContent-Type: text/html\r\n\r\n<b>nope</b>");
expect(r.status).toBe(404);
expect(r.body).toBe("<b>nope</b>");
});
});
describe("parseStatusPage", () => {
it("reads the board's padded, uppercase table", () => {
const f = parseStatusPage(boardPage({ cover: "Yes", nearEnd: "Yes" }));
expect(f).toEqual({ coverOpen: true, cutterError: false, paperEnd: false, paperNearEnd: true, offline: false });
});
it("leaves unknown pages empty rather than guessing", () => {
expect(parseStatusPage(INDEX)).toEqual({});
});
it("reads a fault's Yes, which the board wraps in <FONT color=#ff0000> (captured live, cover open)", () => {
// Verbatim from the unit on 2026-09-09 with the cover open: the three fault cells carry
// markup the No cells don't — the first parser rejected them ("missing coverOpen,
// paperEnd, offline" on the booth) while the No cells parsed.
const page = boardPage()
.replace("Cover Is Open</TD><TD style=\"width: 23px\">No ", "Cover Is Open</TD><TD style=\"width: 23px\"><FONT color=#ff0000>Yes</FONT> ")
.replace("Paper End</TD><TD style=\"width: 23px\">No ", "Paper End</TD><TD style=\"width: 23px\"><FONT color=#ff0000>Yes</FONT> ")
.replace("Printer Off-Line</TD><TD style=\"width: 23px\">No ", "Printer Off-Line</TD><TD style=\"width: 23px\"><FONT color=#ff0000>Yes</FONT> ");
expect(parseStatusPage(page)).toEqual({ coverOpen: true, cutterError: false, paperEnd: true, paperNearEnd: false, offline: true });
});
it("tolerates other markup shapes around a value", () => {
const page = boardPage()
.replace("Paper End</TD><TD style=\"width: 23px\">No ", "Paper End</TD><TD style=\"width: 23px\"><B><FONT color=\"#ff0000\">Yes </FONT></B>")
.replace("Printer Off-Line</TD><TD style=\"width: 23px\">No ", "Printer Off-Line</TD>\r\n<TD style=\"width: 23px\">\r\n<font>Yes</font>\r\n");
expect(parseStatusPage(page)).toEqual({ coverOpen: false, cutterError: false, paperEnd: true, paperNearEnd: false, offline: true });
});
it("reads labels wrapped in markup too", () => {
const page = boardPage({ nearEnd: "Yes" }).replace("<TD>Paper Near End</TD>", "<TD><B>Paper&nbsp;Near End</B></TD>");
expect(parseStatusPage(page).paperNearEnd).toBe(true);
});
});
describe("k200lDriver.readStatus over TCP", () => {
let server: Server | undefined;
const sockets = new Set<import("node:net").Socket>();
/** A raw TCP server that answers like the board (no status line) unless the reply
* says otherwise, and closes after the reply (HTTP/1.0). */
async function serve(reply: (path: string) => Reply): Promise<number> {
server = createServer((sock) => {
sockets.add(sock);
sock.on("close", () => sockets.delete(sock));
sock.once("data", (d) => {
const path = /^GET (\S+)/.exec(d.toString())?.[1] ?? "";
const r = reply(path);
if (r.raw === false) {
sock.end(`HTTP/1.0 ${r.status ?? 200} OK\r\nContent-Type: text/html\r\n\r\n${r.body}`);
} else {
sock.end(r.body);
}
});
});
await new Promise<void>((r) => server!.listen(0, "127.0.0.1", () => r()));
const addr = server.address();
if (!addr || typeof addr === "string") throw new Error("no port");
return addr.port;
}
afterEach(async () => {
for (const s of sockets) s.destroy();
sockets.clear();
if (server) await new Promise<void>((r) => server!.close(() => r()));
server = undefined;
});
function device(httpPort: number): MonitorableDevice & { driverId: string } {
return k200lDriver.create({ transport: "tcp-ip", host: "127.0.0.1", port: 9100, httpPort, timeoutMs: 1000 }) as unknown as MonitorableDevice & {
driverId: string;
};
}
it("reads the headerless reply: cover open + paper out + off-line → degraded, flags set", async () => {
const port = await serve((p) => (p === "/prt_status.htm" ? { body: boardPage({ cover: "Yes", paperEnd: "Yes", offline: "Yes" }) } : { body: INDEX }));
const dev = device(port);
expect(dev.driverId).toBe("k200l");
const s = await dev.readStatus();
expect(s.status).toBe("degraded");
expect(s.coverOpen).toBe(true);
expect(s.paperEnd).toBe(true);
expect(s.offline).toBe(true);
expect(s.cutterError).toBe(false);
expect(s.paperNearEnd).toBe(false);
expect(s.detail).toBe("cover open, paper out, printer off-line");
});
it("healthy printer → ready, every flag false", async () => {
const port = await serve(() => ({ body: boardPage() }));
const s = await device(port).readStatus();
expect(s.status).toBe("ready");
expect(s.coverOpen).toBe(false);
expect(s.detail).toBeUndefined();
});
it("paper near end alone → degraded 'paper low' (still prints, warn to reload)", async () => {
const port = await serve(() => ({ body: boardPage({ nearEnd: "Yes" }) }));
const s = await device(port).readStatus();
expect(s.status).toBe("degraded");
expect(s.paperNearEnd).toBe(true);
expect(s.detail).toBe("paper low");
});
it("also understands a proper HTTP reply (a board firmware that sends headers)", async () => {
const port = await serve(() => ({ body: boardPage({ cutter: "Yes" }), raw: false }));
const s = await device(port).readStatus();
expect(s.status).toBe("degraded");
expect(s.detail).toBe("cutter error");
});
it("a page without the status rows (the index) → degraded 'unexpected status page', never ready", async () => {
const port = await serve(() => ({ body: INDEX }));
const s = await device(port).readStatus();
expect(s.status).toBe("degraded");
expect(s.detail).toContain("unexpected status page");
expect(s.detail).toContain("missing");
});
it("a non-200 reply → degraded naming the code, never ready", async () => {
const port = await serve(() => ({ body: "", status: 404, raw: false }));
const s = await device(port).readStatus();
expect(s.status).toBe("degraded");
expect(s.detail).toContain("HTTP 404");
});
it("board unreachable (connection refused) → offline", async () => {
const port = await serve(() => ({ body: "" }));
await new Promise<void>((r) => server!.close(() => r()));
server = undefined;
const s = await device(port).readStatus();
expect(s.status).toBe("offline");
expect(s.detail).toMatch(/ECONNREFUSED/);
});
it("a board that accepts but never answers → offline 'status page timeout'", async () => {
server = createServer((sock) => {
sockets.add(sock); // hold the socket open, say nothing
sock.on("close", () => sockets.delete(sock));
});
await new Promise<void>((r) => server!.listen(0, "127.0.0.1", () => r()));
const addr = server.address();
if (!addr || typeof addr === "string") throw new Error("no port");
const dev = k200lDriver.create({ transport: "tcp-ip", host: "127.0.0.1", port: 9100, httpPort: addr.port, timeoutMs: 200 }) as unknown as MonitorableDevice;
const s = await dev.readStatus();
expect(s.status).toBe("offline");
expect(s.detail).toBe("status page timeout");
});
});
describe("k200lDriver — printing and USB are the generic ESC/POS path", () => {
let dir: string;
let devicePath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "k200l-usb-"));
devicePath = join(dir, "lp0");
writeFileSync(devicePath, "");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("prints the same ticket bytes the generic driver would, to the USB node", async () => {
const printer = k200lDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 }) as PrinterDevice;
const data = { ticketId: "12345678901", issuedAt: "2026-09-09T10:00:00.000Z" };
await printer.printTicket(data);
expect(readFileSync(devicePath).equals(renderTicket(data))).toBe(true);
});
it("over USB readStatus is the reachability floor: ready when the node opens, offline when absent", async () => {
const present = k200lDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 }) as unknown as MonitorableDevice;
expect((await present.readStatus()).status).toBe("ready");
const absent = k200lDriver.create({ transport: "usb", devicePath: join(dir, "absent"), timeoutMs: 1000 }) as unknown as MonitorableDevice;
expect((await absent.readStatus()).status).toBe("offline");
});
it("advertises both transports and exposes the status-page port after the print port", () => {
expect(k200lDriver.transports).toEqual(["tcp-ip", "usb"]);
const keys = k200lDriver.configFields.map((f) => f.key);
expect(keys.indexOf("httpPort")).toBe(keys.indexOf("port") + 1);
expect(keys).toContain("role");
});
});
@@ -0,0 +1,262 @@
import { connect as netConnect } from "node:net";
import type {
DeviceHealth,
MonitorableDevice,
PrinterDevice,
PrinterStatus,
PrintReport,
ReceiptData,
SubscriptionCardData,
TicketData,
WindowChargeNoticeData,
} from "../interfaces.js";
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
import { stubLog } from "./common.js";
import { transportFromConfig } from "./printer-escpos.js";
import { escposDriver } from "./printer-generic.js";
// K200L 80mm ESC/POS thermal printer (Xprinter / ICS "XP-K200L" family; label:
// "THERMAL RECEIPT PRINTER Model:K200L, Interface: USB+LAN, Command Support: ESC/POS").
// Its LAN board is the "J-Speed Ethernet Interface Module" (web UI "Ethernet WebConfig
// 1.02") and calls the printer "POS-80" — over USB it enumerates as 1fc9:2016
// "Printer POS-80". Identified on the lab bench 2026-09-09: it is the park-buzi unit.
// See wiki/entities/k200l-printer.md.
//
// PRINTING is the shared ESC/POS path (delegated to the generic driver — same bytes,
// same TCP-9100 / usblp transports). What this driver ADDS is live status: the board
// serves a status page, /prt_status.htm, with the same five decoded Yes/No rows the
// Rongta board serves under /prn_stat.htm (cover open, cutter error, paper end, paper
// near end, off-line). So over TCP the operator gets a real paper/cover verdict —
// the generic driver deliberately can't (reachability only), and the Rongta driver
// can't read THIS board either: its reply carries NO status line and NO headers
// (HTTP/0.9 style — the body starts at byte 0), which Node's http client rejects
// ("Parse Error: Expected HTTP/"). Hence the raw-socket fetch below, tolerant of both
// shapes. Over USB there is no page; status degrades to the reachability floor.
//
// Board facts worth knowing (all verified on the bench): factory address
// 192.168.123.100, DHCP off; web configurator on port 80 (Information / Configuration
// / Printer Status / Printer Test); the frameset reloads its frames every 1–3 s and
// the status page every 5 s, and the embedded HTTP server is tiny — leave the browser
// closed while the monitor polls, or connects will intermittently time out.
/** The fault flags the status page reports (a subset of PrinterStatus). */
type StatusFlag = "coverOpen" | "cutterError" | "paperEnd" | "paperNearEnd" | "offline";
type StatusFlags = Partial<Record<StatusFlag, boolean>>;
/** Label text on the status page (space-normalised, lowercased) → our key. */
const STATUS_FIELDS: Record<string, StatusFlag> = {
"cover is open": "coverOpen",
"cutter error": "cutterError",
"paper end": "paperEnd",
"paper near end": "paperNearEnd",
"printer off-line": "offline",
};
const EXPECTED: readonly StatusFlag[] = ["coverOpen", "cutterError", "paperEnd", "paperNearEnd", "offline"];
const LABELS: Record<StatusFlag, string> = {
paperEnd: "paper out",
coverOpen: "cover open",
cutterError: "cutter error",
offline: "printer off-line",
paperNearEnd: "paper low",
};
/** The board's status page. */
export const K200L_STATUS_PATH = "/prt_status.htm";
/**
* GET `path` over a raw TCP socket and return whatever the board sent, verbatim,
* once it closes the connection (HTTP/1.0 semantics — the board closes after the
* reply). No HTTP parsing here: this board answers without a status line, which
* node:http refuses to parse.
*/
export function fetchRaw(host: string, port: number, path: string, timeoutMs: number): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let settled = false;
const sock = netConnect({ host, port });
const timer = setTimeout(() => {
finish(() => reject(new Error("status page timeout")));
sock.destroy();
}, timeoutMs);
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn();
};
sock.setNoDelay(true);
sock.on("connect", () => {
sock.write(`GET ${path} HTTP/1.0\r\nHost: ${host}\r\nConnection: close\r\n\r\n`);
});
sock.on("data", (c: Buffer) => chunks.push(c));
sock.on("error", (err) => finish(() => reject(err)));
sock.on("close", () => finish(() => resolve(Buffer.concat(chunks).toString("latin1"))));
});
}
/**
* Split a raw reply into its HTTP status and body. A reply that starts with a status
* line is real HTTP (status + headers, body after the blank line); anything else is
* the HTTP/0.9-style reply this board sends — the whole thing IS the body, status 200.
*/
export function parseRawReply(raw: string): { status: number; body: string } {
const m = /^HTTP\/\d\.\d\s+(\d{3})[^\r\n]*\r?\n/.exec(raw);
if (!m) return { status: 200, body: raw };
const sep = raw.search(/\r?\n\r?\n/);
const body = sep === -1 ? "" : raw.slice(sep).replace(/^\r?\n\r?\n/, "");
return { status: Number(m[1]), body };
}
/** A cell's visible text: inner tags stripped (the board wraps a "Yes" in markup the
* "No" cells don't carry), entities and padding normalised, lowercased. */
function cellText(inner: string): string {
return inner
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/gi, " ")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
/**
* Parse the status table into boolean flags. Each fault is a `<TD>label</TD>
* <TD>Yes|No</TD>` pair (the board pads the value with spaces, and may wrap a fault's
* "Yes" in its own tags — 2026-09-09, seen live as "missing coverOpen, paperEnd,
* offline" with the cover open, i.e. exactly the Yes cells). Returns only the
* recognised fields; a missing field stays undefined so the caller can detect an
* unexpected page (fail safe, not a false "ok").
*/
export function parseStatusPage(html: string): StatusFlags {
const out: StatusFlags = {};
const rowRe = /<TR[^>]*>\s*<TD[^>]*>([\s\S]*?)<\/TD>\s*<TD[^>]*>([\s\S]*?)<\/TD>/gi;
let m: RegExpExecArray | null;
while ((m = rowRe.exec(html))) {
if (m[1] === undefined || m[2] === undefined) continue;
const key = STATUS_FIELDS[cellText(m[1])];
const value = cellText(m[2]);
if (key && (value === "yes" || value === "no")) out[key] = value === "yes";
}
return out;
}
class K200lPrinter implements PrinterDevice, MonitorableDevice {
readonly driverId = "k200l";
/** The print path — the generic ESC/POS device built from the SAME config. */
readonly #print: PrinterDevice;
readonly #tcp: boolean;
readonly #host: string;
readonly #httpPort: number;
readonly #timeout: number;
constructor(config: DeviceConfig) {
this.#print = escposDriver.create(config) as PrinterDevice;
this.#tcp = transportFromConfig(config).kind === "tcp";
this.#host = config.host ? String(config.host) : "";
this.#httpPort = config.httpPort ? Number(config.httpPort) : 80;
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
}
async connect(): Promise<void> {
await this.#print.connect();
}
async disconnect(): Promise<void> {
await this.#print.disconnect();
stubLog(this.driverId, "disconnect");
}
healthCheck(): Promise<DeviceHealth> {
return this.#print.healthCheck();
}
printTicket(data: TicketData): Promise<void> {
return this.#print.printTicket(data);
}
printReport(report: PrintReport): Promise<void> {
return this.#print.printReport(report);
}
printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
return this.#print.printSubscriptionCard(data);
}
printReceipt(data: ReceiptData): Promise<void> {
return this.#print.printReceipt(data);
}
printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
return this.#print.printWindowChargeNotice(data);
}
/**
* Live operator-actionable status from the board's own page.
* - USB: no page — reachability floor only (ready/offline, never a guessed state);
* - board unreachable / timeout → offline (the same signal as a dead printer);
* - page reachable but not the status table (wrong path, index served, non-200) →
* degraded ("unexpected status page") — never "ready" off a page we didn't read;
* - any fault flag true → degraded, with the faults named; otherwise → ready.
*/
async readStatus(): Promise<PrinterStatus> {
const checkedAt = new Date().toISOString();
if (!this.#tcp) {
const h = await this.#print.healthCheck();
return { status: h.status === "ready" ? "ready" : "offline", detail: h.detail, checkedAt };
}
let raw: string;
try {
raw = await fetchRaw(this.#host, this.#httpPort, K200L_STATUS_PATH, this.#timeout);
} catch (err) {
return { status: "offline", detail: (err as Error).message, checkedAt };
}
const { status, body } = parseRawReply(raw);
if (status !== 200) {
return { status: "degraded", detail: `unexpected status page (${K200L_STATUS_PATH}: HTTP ${status})`, checkedAt };
}
const flags = parseStatusPage(body);
const missing = EXPECTED.filter((k) => flags[k] === undefined);
if (missing.length > 0) {
return {
status: "degraded",
detail: `unexpected status page (${K200L_STATUS_PATH}: missing ${missing.join(", ")})`,
checkedAt,
};
}
const faults = EXPECTED.filter((k) => flags[k] === true);
return {
status: faults.length > 0 ? "degraded" : "ready",
...flags,
detail: faults.length > 0 ? faults.map((f) => LABELS[f]).join(", ") : undefined,
checkedAt,
};
}
}
const httpPortField: ConfigField = {
key: "httpPort",
label: "Status web port",
type: "port",
required: false,
default: 80,
help: "The board's web configurator port; the status page /prt_status.htm is read from it for live monitoring (default 80). TCP only.",
};
/** The generic driver's fields (transport, device path, host, port, role, rank,
* timeout) plus the status-page port, placed right after the print port. */
function k200lFields(): ConfigField[] {
const out = [...escposDriver.configFields];
const i = out.findIndex((f) => f.key === "port");
out.splice(i === -1 ? out.length : i + 1, 0, httpPortField);
return out;
}
export const k200lDriver: PrinterDriver = {
id: "k200l",
category: "printer",
label: "K200L 80mm thermal printer (Xprinter / ICS, USB+LAN)",
description:
"Xprinter / ICS K200L (XP-K200L) 80mm ESC/POS printer; its LAN board reports itself as 'POS-80' (web configurator at 192.168.123.100:80 from the factory, DHCP off). Prints over raw TCP (port 9100) OR local USB /dev/usb/lp0 — the same bytes as the generic ESC/POS driver. Over TCP the board's /prt_status.htm page gives live paper / cover / cutter / off-line status; over USB there is no page, so it is monitored by reachability only. No auth on the print socket or the web UI — isolate the VLAN.",
transports: ["tcp-ip", "usb"],
configFields: k200lFields(),
create: (c) => new K200lPrinter(c),
};
+1
View File
@@ -18,6 +18,7 @@ export {
hikvisionDriver, hikvisionDriver,
dahuaDriver, dahuaDriver,
rongtaDriver, rongtaDriver,
k200lDriver,
escposDriver, escposDriver,
} from "./drivers/index.js"; } from "./drivers/index.js";
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js"; export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
+3 -3
View File
@@ -70,9 +70,9 @@ RESET_ALLOWED=1 DATABASE_URL=/path node packages/db/scripts/reset-db.mjs --all
| Flag | Wipes | Keeps | | Flag | Wipes | Keeps |
| --- | --- | --- | | --- | --- | --- |
| `--financial` | `ledger_events` (entry/exit/payment/void/shift/cash/anomaly), `device_events`, `snapshots`, subscription **instances** + credentials/plates, `blocklist` | users, devices, config, tariffs, subscription **plans** | | `--financial` | `ledger_events` (entry/exit/payment/void/shift/cash/anomaly), `device_events`, `snapshots`, subscription **instances** + credentials/plates, `blocklist`, `carwash_orders`, `carwash_review_outbox` | users, devices, config, tariffs, subscription **plans**, Car Wash master data |
| `--config` | `site_config`, `devices`, `setup_state` (→ re-runs first-run setup), tariffs + versions + **drafts**, subscription plans | everything else | | `--config` | `site_config`, `devices`, `setup_state` (→ re-runs first-run setup), tariffs + versions + **drafts**, subscription plans, validation programs, `carwash_prices/categories/services/config` | everything else |
| `--users` | `users`, `roles`, `role_permissions`, auth `sessions` | everything else | | `--users` | `users`, `roles`, `role_permissions`, `role_jobs`, auth `sessions` | everything else |
| `--diagnostics` | `app_logs` (the unsigned [[app-logs]] store behind `/setup/logs`) | everything else | | `--diagnostics` | `app_logs` (the unsigned [[app-logs]] store behind `/setup/logs`) | everything else |
| `--all` | every table (blank slate) | — | | `--all` | every table (blank slate) | — |
+15 -1
View File
@@ -2,7 +2,7 @@
type: concept type: concept
tags: [parking, printer, device, monitoring, reliability] tags: [parking, printer, device, monitoring, reliability]
sources: [] sources: []
updated: 2026-06-14 updated: 2026-09-09
--- ---
# Printer status monitoring # Printer status monitoring
@@ -90,3 +90,17 @@ reads. Full repo typechecks.
Yes/No and degrade on anything unexpected, so this is a confidence check, not a blocker. Yes/No and degrade on anything unexpected, so this is a confidence check, not a blocker.
- Tying a `degraded`/`offline` entry-dispenser into [[printer-roles-failover]] failover and the - Tying a `degraded`/`offline` entry-dispenser into [[printer-roles-failover]] failover and the
(not-yet-built) entry flow's all-printers-down policy ([[device-input-flow]]). (not-yet-built) entry flow's all-printers-down policy ([[device-input-flow]]).
## K200L — a second status-page board, and a fetch that node:http can't do (2026-09-09)
The park-buzi printer turned out to be a **K200L** (Xprinter/ICS XP-K200L; LAN board "J-Speed
Ethernet WebConfig 1.02", self-named "POS-80"). Its board serves the **same five decoded Yes/No
rows** as the Rongta page — under **`/prt_status.htm`**. Two things kept it invisible until now:
the Rongta driver only knew `/prn_stat.htm` (so in July the unit was filed as "no status page →
generic driver"), and the board's reply carries **no HTTP status line or headers** (HTTP/0.9
style), which `node:http` rejects outright and `curl` shows as an empty `000`. The new **`k200l`**
driver (`printer-k200l.ts`) prints through the generic ESC/POS path and reads the page over a raw
TCP socket, tolerant of both reply shapes; mapping is the Rongta one (unreachable → offline; page
not understood → degraded, never ready; any fault → degraded naming it; else ready; USB →
reachability floor). Bench-verified: cover open → amber "cover open, paper out, printer off-line".
Tests replay the captured headerless reply. Full device notes: [[k200l-printer]].
+73 -4
View File
@@ -2,7 +2,7 @@
type: concept type: concept
tags: [parking, device, printer, transport, usb, escpos, provisioning] tags: [parking, device, printer, transport, usb, escpos, provisioning]
sources: [] sources: []
updated: 2026-08-30 updated: 2026-09-09
status: settled status: settled
--- ---
@@ -134,6 +134,11 @@ preselecting the first present device; a saved-but-unplugged path stays selectab
"saved — not present now"; zero devices found falls back to the free-text path + a check-the-cable "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. hint. The transport option label no longer hardcodes lp0.
> **Superseded 2026-09-09:** the XP-K200L DOES serve a status page — the same table as the Rongta,
> at **`/prt_status.htm`**, without HTTP headers. It now has its own **`k200l`** driver (raw-socket
> fetch; LAN = live cover/paper status, USB = reachability floor). See [[k200l-printer]]. The note
> below is kept for the record.
>
> 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`
@@ -174,7 +179,60 @@ itself is redone.
target on a policy-driven restart, the container can come back up still bound to the pre-incident target on a policy-driven restart, the container can come back up still bound to the pre-incident
view. This matches the exact reported asymmetry (reboot doesn't fix it; explicit restart does). view. This matches the exact reported asymmetry (reboot doesn't fix it; explicit restart does).
**Not yet confirmed on hardware** — this is the leading theory, not a verified root cause. To > **Bench result 2026-09-09 — the re-enumeration hypothesis is FALSIFIED for this unit.** The
> failing printer (`1fc9:2016` "POS-80", now on the dev bench, attached to WSL via usbipd) was
> cover-cycled while `dmesg -w` and `lsusb` were watched: **nothing** — no disconnect, no
> re-enumeration, same bus/device number (a real drop would have shown as a vhci detach, since
> Windows sees the bus first). So the device node does NOT change when the cover opens, and the
> container `/dev/usb` bind-mount cannot be going stale for that reason. The failure is in how
> `usblp` / the app's open-probe reacts to the printer's **error state** (cover-open status),
> not in the device node. Next discriminator is the **exact `detail` text** the monitor logged
> on park-buzi at the offline transition (`docker logs <stack>-server-1 | grep
> 'device-monitor:.*-> offline'`): `EBUSY` = a handle is held inside the server process (usblp
> allows ONE opener — candidate: the `withTimeout` open-leak or a close that never returned;
> fits "docker restart fixes"), `usb open timeout` = `open()` itself blocks in the kernel, `EIO`
> = `usblp_open`'s bidirectional read submit failed (printer endpoint state). The theory below
> is kept for the record.
> **Lab reproduction FAILED to reproduce (2026-09-09, later the same day).** The same printer
> unit on the `park-lab` box (a real Linux host, the booth's exact image `stage-2d9bb15`, the
> prod compose with the `/dev/usb` bind-mount, the dev DB snapshot with the USB printer added as
> `booth-receipt`, cards printed via the subscription "Reprint card" path): paper out → open
> cover → load roll → close cover → reprint — **no error, status never stuck offline.** So the
> printer, the app's USB transport and the compose wiring are cleared in isolation. What is left
> is park-buzi's own environment (kernel/USB stack, the physical USB port/hub/cable/power at the
> booth) and/or that container's *history* (weeks of uptime before the first failure — a leaked
> handle needs a prior timeout to exist; a fresh container has none).
>
> **Status: park-buzi closed (staff shortage), everything shut down — evidence pending.** The
> evidence is on the booth's DISK and survives shutdown/reboot: Docker keeps the container log
> under `/var/lib/docker/containers/<id>/`, the kernel journal is persistent. **The day the box
> powers on again (or lands on the bench), pull these FIRST, before deploying anything:**
>
> ```bash
> # 1. the app's own record: the exact error text at every offline/ready transition
> docker logs park-buzi-server-1 2>&1 | grep -E "device-monitor:.*printer.*-> (offline|ready)"
> # 2. what the HOST kernel saw around those times (usblp errors, resets, disconnects)
> sudo journalctl -k --since "-30 days" | grep -i -E "usblp|usb 1-|usb 2-|disconnect|reset"
> # 3. the physical path: hub or direct port? (and note which PSU feeds the printer)
> lsusb -t
> ```
>
> Reading (1): `EBUSY` = a handle stuck inside the server process (usblp allows ONE opener;
> fits "container restart fixes it") → look at the `withTimeout` leak below; `usb open timeout`
> = `open()` blocks in the kernel; `EIO` = `usblp_open`'s bidirectional read submit failed
> (printer/link state). Reading (2): any `USB disconnect` / `reset` / `usblp1: removed` at the
> transition times means the LINK dropped at the booth (cable/port/hub/power) even though the
> unit never dropped on the bench.
>
> **Follow-ups that need no booth (proposed, not built):** (a) `withTimeout` in
> `printer-escpos.ts` abandons the FileHandle when an open/write times out — close it when the
> underlying promise eventually settles, so a timeout can never leave the node held; (b) make
> the monitor self-document the next occurrence: after N consecutive offline polls on a USB
> printer, log the errno, `ls -la /dev/usb`, and who holds the node, so the next failure anywhere
> in the fleet carries its own diagnosis without a person at the booth.
**Not confirmed on hardware — and now contradicted by the bench (above).** The original plan to
confirm at the next occurrence, BEFORE restarting anything: confirm at the next occurrence, BEFORE restarting anything:
```bash ```bash
# host: # host:
@@ -197,8 +255,19 @@ whether the Bus/Device number changes.
passthrough + a udev rule pinning a stable symlink name — reintroduces the renumbering fragility passthrough + a udev rule pinning a stable symlink name — reintroduces the renumbering fragility
the directory bind-mount was chosen to avoid, so only worth doing alongside (1)/(2), not instead. the directory bind-mount was chosen to avoid, so only worth doing alongside (1)/(2), not instead.
**Open sub-question — printer identity.** The park-buzi unit shows as "Generic (unknown)" in the **Printer identity — IDENTIFIED 2026-09-09.** The failing unit is on the dev bench: a **K200L**
app; not yet identified by vendor/product ID. Lab reproduction uses a **RONGTA** unit instead (not (Xprinter/ICS XP-K200L family — see [[k200l-printer]] for the LAN setup and its status page): USB
`1fc9:2016`, product string **"Printer POS-80"** (0x1fc9 = NXP, the printer's USB controller chip;
"POS-80" is the generic 80 mm ESC/POS designation — no brand in the descriptor, which is why the app
shows "Generic"). Seen via `usbipd list` on the Windows host (busid 8-1). **Dev-bench caveat:** the
stock Microsoft WSL2 kernel (6.6.87.2) has `CONFIG_USB_PRINTER` **not set** — usbip/vhci is there,
so the printer can be attached and seen by `lsusb`, but no `usblp` → no `/dev/usb/lpN` → the app's
USB transport and the container's `/dev/usb` bind-mount cannot be exercised without a custom WSL
kernel (`.wslconfig` `kernel=`) built with `CONFIG_USB_PRINTER=y`. Also, through usbip the
cover-open disconnect is seen by *Windows* first (usbipd detaches; `--auto-attach` re-exports), so
the bench only shows *whether* the device drops off the bus, not the host-kernel/container
staleness itself. Previously: the park-buzi unit showed as "Generic (unknown)" in the app; not
identified by vendor/product ID. Lab reproduction uses a **RONGTA** unit instead (not
the same hardware), so the lab cannot currently reproduce the park-buzi symptom directly — only the same hardware), so the lab cannot currently reproduce the park-buzi symptom directly — only
validate the general re-enumeration mechanism. Commands to identify the real park-buzi printer next validate the general re-enumeration mechanism. Commands to identify the real park-buzi printer next
time it's reachable via SSH: `lsusb`, `udevadm info -q property -n /dev/usb/lp1`, `udevadm info -a time it's reachable via SSH: `lsusb`, `udevadm info -q property -n /dev/usb/lp1`, `udevadm info -a
+209
View File
@@ -0,0 +1,209 @@
---
type: reference
tags: [parking, runbook, installation, devices, network, field]
sources: []
updated: 2026-09-09
---
# Site device installation — know it BEFORE you are standing in the booth
A field runbook: for every device we deploy, its **factory address and credentials**, the **tool**
you need, **what the app configures by itself** at assign time versus **what must be done by hand on
the device**, and the **traps already paid for** on park-buzi and the lab. The [[appliance-provisioning]]
runbook covers the booth PC (OS, disk, Docker, Periphery); this page covers everything plugged into
it. Written 2026-09-09 after an evening lost to a printer whose factory address nobody had written
down ([[k200l-printer]]).
Rule of thumb that explains most of this page: **every field device ships on its own private
subnet with DHCP off, and none of them announce themselves.** You bring a laptop that can take a
second static address, you put it on the device's factory subnet, you move the device to the site
plan, and only then does anything else see it.
## Before leaving the office
**Bring**
- A laptop with an Ethernet port and the right to add a **second static IPv4 address** to it
(Windows: adapter → IPv4 → Advanced → add). Under WSL, remember the source-address bug
([[wsl-dev-networking]]): after adding a temporary address, `ping` may work while HTTP times out.
- The Dingtian reader tool **`QRCode_v1_6_5.exe`** (Windows) — the only way to set a DT-008's IP,
server target, prefixes and symbologies. A browser is enough for everything else.
- Patch cables, a USB A–B cable (printers), the site's **address plan** (below) filled in, the
app **admin** password, and a fresh Komodo **onboarding key** if the booth PC is new
([[appliance-provisioning]] §7a).
- The **serials** if already known: DT-008 `cjihao` (on the reader's label / in the tool), camera
MAC/serial, printer model (label on the bottom — the K200L's own web UI calls it "POS-80").
**Address plan** — the device VLAN ([[network-isolation]]) is `10.0.10.0/24` on both sites so far,
**every device static, DHCP off everywhere**. The convention from park-buzi / park-lab:
| Address | Device | Factory address it came from |
| --- | --- | --- |
| 10.0.10.1 | VLAN gateway (switch/router) | — |
| 10.0.10.5 | [[dingtian-relay]] board (barriers + inputs) | `192.168.1.100` |
| 10.0.10.7 / .8 | [[dingtian-dt008-reader]] entry / exit (**unique IP each**) | `192.168.1.99` |
| 10.0.10.9 | entry-dispenser printer (park-buzi: Cashino KP-300H) | *not recorded — fill in* |
| 10.0.10.10 | booth-receipt printer (park-buzi: [[rongta-printer]]) | *not recorded — fill in* |
| 10.0.10.11 / .7 (lab) | [[k200l-printer]] | `192.168.123.100` |
| 10.0.10.12 / .13 | [[lpr-camera]] entry / exit (Hikvision DS-2CD1047G3H-LIU) | `192.168.1.64` (Hikvision default; needs activation) |
| 10.0.10.203 | the booth PC on the device VLAN (**the `backendIp` every device pushes to**) | — |
Fill the real numbers into the site record before you drive; the wizard asks for the booth's push
address once and writes it into the Dingtian and the cameras.
## 1. Dingtian relay board (DT-R004 family) — barriers, button, radar
**Factory:** IP `192.168.1.100`, web UI on port 80, login `admin` / `admin`, UDP `60000` (binary) /
`60001` (string), multicast discovery `224.0.2.11:60000`. See [[dingtian-relay]].
**By hand, on the device (browser at its factory address):** set the site IP / mask / gateway in
the Network page and reboot. That is the only thing you *must* do by hand. Optional but recommended:
in the web UI **disable the UDP2 "string" protocol** (a password-less relay-fire path); the app's
harden step tries to disable it and **warns if the device refused** — then do it here.
**Wiring:** button on **I1** (NO contact to GND, idles HIGH, pulls LOW on press); radar dry contact
on **I2**; barrier operator's open input on **relay 1** (entry) and **relay 2** (exit); a spare relay
for the entry button lamp ([[button-light-indicator]]). One board can carry both barriers; two
distant barriers = two boards ([[entry-exit-points]]).
**What the wizard does on assign** ([[first-run-setup]], [[device-input-flow]]): finds the board by
multicast ("Scan for controllers" — laptop/booth must share the L2 segment), checks and clears
`input_link_relay` (factory default auto-fires a relay from its input — the app must decide, not the
board), sets a random **relay password** (UDP binary), disables every other control channel, rotates
the web login, and writes the **input push** URL + per-device Digest credentials so button/radar
edges reach the booth PC. You enter the relay map (which relay is entry/exit/both) and the inputs
(button → its relay; radar → `presence`, `activeLow` if it idles opposite the button —
[[hikvision-radar]]).
**Traps**
- The HTTP CGI API is **unauthenticated** on this firmware; `admin/admin` gates only the web page.
Never enable `session_en` — it bricks the config API and only a **factory reset** recovers. The
VLAN is the boundary, not the login ([[dingtian-relay]] §Hardening).
- A relay password mismatch shows as **"offline despite ping"**: the status query is answered only
with the right password. Re-assign / re-enter the relay password in the device form.
- "Relay test" in Setup pulses real hardware and signs a ledger event — use it to prove wiring,
once per relay.
## 2. Dingtian DT-008 QR + RFID readers
**Factory:** IP `192.168.1.99`; no web UI — everything is set with **`QRCode_v1_6_5.exe`** over the
network. See [[dingtian-dt008-reader]].
**By hand, in the tool, per reader:**
1. **Unique device IP** (`.7` entry, `.8` exit). Two readers on one IP was the 2026-06-18
"wrong barrier" incident — scans land on the wrong device row.
2. **Server IP** = the booth PC (`10.0.10.203`), **server port** = the booth's HTTP port (80 behind
the prod proxy); "server language" can stay whatever it is (php/jsp/asp/aspx/cgi are all
served — the reader GETs `/qa/mcardsea.<ext>`).
3. **Output prefixes:** `QRCode Output Prefix` = `Q:`, `Card Output Prefix` = `K:` (channel
tagging — a printed clone of a card cannot pass as the card).
4. **Card Input format = `6H`** (defines the UID shape enrolled; changing it later orphans every
card).
5. **Symbologies: QR + Code128 only**, minimum decode length ≥ 10, checksums on — otherwise low
sun through the striped arm produces phantom 6-digit reads (park-buzi, July).
6. Note the **serial (`cjihao`)** — the wizard binds the reader by serial, not by IP.
**In the wizard:** add the reader with its serial, bind it to the controller relay it sits at
(direction is inherited from the relay). **Verify:** scan a card — the server log shows
`READ serial=… → device=… verdict=… dir=…`; the reader beeps **twice** on accept, once on refuse,
and only after the server's reply (no reply = no beep, the scan still happened).
**Trap:** a factory reset or a swapped unit silently loses items 3–5. Re-apply all of them.
## 3. Hikvision camera (DS-2CD1047G3H-LIU, AcuSense) — ANPR + snapshots
**Factory:** `192.168.1.64`, **inactive** until a password is set on first boot (browser at that
address or the SADP tool); after activation the login is `admin` / the password you chose. Site
convention so far: `admin` / `admin123` on the first units (change per site and record it). See
[[lpr-camera]].
**By hand, on the camera:**
1. Activate, set the site IP, disable DHCP. Time: NTP off-site is unavailable — the app re-syncs
the camera clock from the booth at every offline→ready edge ([[clock-integrity]]).
2. **Streams:** the snapshot the app pulls MUST come from the **sub stream** (`102`) — the main
stream's ISAPI snapshot returns **503 instantly, always, on this model**. Set the sub stream to
the highest resolution the camera allows.
3. **Event push:** Event → Motion Detection with the AcuSense **Detection Target = Vehicle** filter
ON, "Notify Surveillance Center" on, then Alarm Settings → **Alarm Server** →
`http://10.0.10.203/api/devices/hikvision/<deviceId>/event`. The `deviceId` exists only after
the wizard assign, so: **assign first, then come back to the camera**. Digest user/password if
the firmware allows it (the wizard shows them).
4. "Enable Hikvision-CGI" is a different legacy surface — **not** needed for ISAPI.
5. **Close the web UI / live view when done.** The camera has few connection slots; a browser left
open makes every snapshot pull 503 "Device Busy" ([[lpr-camera]] §503).
**In the wizard:** driver `hikvision`, host, `admin` password, channel 1, **stream = Sub**, ANPR on,
bind to the relay at that barrier, `alarmPushEnabled` on.
**Verify, do not assume:** drive a car through and look at `GET /api/events` (or the log) for an
alarm with `targetType=vehicle`. A camera configured for push that has sent **zero** alarms is
broken on its side: pull its *Diagnose Information*; `Main Db is broken` means a corrupt config
database → **factory reset**, then redo 1–3 (the Vehicle target filter defaults OFF after a reset).
Point the Alarm Server at a dumb HTTP sink on the laptop if you need to see the verbatim body
([[lpr-camera]] §"auto-enter but not auto-exit").
## 4. Radar (vehicle presence at the entry barrier)
A dry-contact sensor into a Dingtian input, nothing on the network. Check with the board's input
status (`00` query → `relays:inputs`) whether it **idles HIGH or LOW**; if it idles opposite the
button, set `activeLow` on that input in the wizard, or the gate inverts (tickets only when the
lane is empty). It is advisory: it gates the button, it never opens anything ([[hikvision-radar]],
[[entry-double-press]]).
## 5. Printers — three models, one byte stream, different status
All print the same ESC/POS bytes over **raw TCP 9100** or **USB (`/dev/usb/lpN`)**; what differs
is whether the app can see paper/cover state ([[printer-status-monitoring]],
[[printer-usb-transport]]). Roles: **entry-dispenser** outside at the lane, **booth-receipt**
inside (receipts, subscription cards, Z-reports, and the backup for entry tickets), **wash-desk**
if the site has a Car Wash ([[printer-roles-failover]]). Higher `failoverRank` = tried first.
| Model | Factory network | Config UI | App driver | Live status |
| --- | --- | --- | --- | --- |
| [[k200l-printer]] (Xprinter/ICS K200L, "POS-80" board; **the park-buzi unit**) | `192.168.123.100/24`, DHCP off | browser, port 80, no login: Configuration → fixed IP → Save → Restart | **`k200l`** | over LAN: cover / paper / cutter / off-line from `/prt_status.htm`; over USB: reachability only |
| [[rongta-printer]] (RP-series) | *not recorded — fill in* | status page `/prn_stat.htm` on port 80 | `rongta` | over LAN: full; USB: reachability |
| Cashino KP-300H | *not recorded — fill in* | *not recorded* | `escpos` (generic) | reachability only, by design — no trustworthy status source |
**Prefer LAN over USB** wherever a cable can reach: the booth sees a real amber "cover open, paper
out" while a roll is changed, and the `usblp` path (udev rule, node renumbering, the park-buzi
"offline after reload" mystery) drops out of the picture. USB needs the appliance's `usblp` +
udev rule ([[printer-usb-transport]] §Provisioning) and the printer shows up as `/dev/usb/lpN`,
numbered by plug order.
**Verify:** the wizard's "Test" only *probes* (opens the port / the device node) — it prints
nothing. Print something real: a subscription with a QR credential auto-prints its card and has a
**Reprint card** button; a payment prints a receipt; a wash till prints slips. Check the Cashino's
barcode with a real ticket (the KP-300H garbled overflowing barcodes until the geometry fix).
## 6. Order of work on site
1. Address plan on paper; VLAN ports patched; booth PC up with its device-VLAN address.
2. Dingtian: factory address → site address (browser) → wire button, radar, barriers.
3. Wizard: **controllers first** (the relay map + inputs); "Relay test" each barrier.
4. Readers: tool (IP, server, prefixes, format, symbologies) → wizard (serial, bind) → scan test.
5. Cameras: activate → IP → sub stream → wizard assign → Alarm Server + Vehicle target → drive-through
test → close the browser.
6. Printers: site address → wizard (role, rank) → print a card.
7. Walk-through: button + radar → ticket; QR entry then exit; RFID; a subscriber's plate at the
camera; pay at the booth → receipt → exit; paper reload on each printer while watching the
footer.
8. Record in the site record: every IP, serial, camera password, printer model, which relay is
which, photos of the labels. Remove the temporary laptop addresses. Log out of every device UI.
## Gaps to fill next time you hold the hardware
- Factory address and configuration tool of the **Cashino KP-300H** and the **Rongta RP** units
(both still unknown here).
- The exact screens in `QRCode_v1_6_5.exe` for the reader's IP and server target (a screenshot).
- Whether the camera activation was done with SADP or the browser at park-buzi, and the per-site
camera password location.
- Where the **site record** lives (a page per site under `wiki/entities/`? — park-buzi and park-2
have none yet; the Komodo stack env is the closest thing).
Related: [[appliance-provisioning]] · [[first-run-setup]] · [[device-registry]] ·
[[network-isolation]] · [[entry-exit-points]] · [[dingtian-relay]] · [[dingtian-dt008-reader]] ·
[[lpr-camera]] · [[hikvision-radar]] · [[k200l-printer]] · [[rongta-printer]] ·
[[printer-usb-transport]] · [[wsl-dev-networking]]
+9 -1
View File
@@ -2,12 +2,20 @@
type: concept type: concept
tags: [parking, domain, business, pricing, validation, design] tags: [parking, domain, business, pricing, validation, design]
sources: [parksql2017-legacy-schema] sources: [parksql2017-legacy-schema]
updated: 2026-06-17 updated: 2026-09-08
status: open status: open
--- ---
# Validation & Sponsorship — merchant comps, coupons, postpaid B2B # Validation & Sponsorship — merchant comps, coupons, postpaid B2B
> **Sponsor accounts superseded (2026-09-08).** The `sponsors` table sketched below — a
> counterparty with a stored `balance_minor` and a billing period — is now a special case of the
> **[[party-ledger]]** (design): any party (subscriber, hotel, fleet, supplier) with a balance
> *derived* from signed `charge` / settlement / `write_off` events, never a stored column. A
> postpaid sponsor = a party; each comped stay = a `charge` against it; the monthly invoice = its
> statement. The validation *mechanics* (signed validation events on a session) are unchanged
> and built ([[validation-discounts]]).
Builds on [[validation-discounts]] (the signed-event discount mechanism) to add the layer it leaves Builds on [[validation-discounts]] (the signed-event discount mechanism) to add the layer it leaves
open: **a sponsor account and postpaid B2B billing.** The driving case — **a nearby business with a open: **a sponsor account and postpaid B2B billing.** The driving case — **a nearby business with a
postpaid agreement whose customers enter and exit freely, billed to the business monthly.** postpaid agreement whose customers enter and exit freely, billed to the business monthly.**
+10 -1
View File
@@ -2,7 +2,7 @@
type: reference type: reference
tags: [parking, dev-environment, networking, wsl, troubleshooting] tags: [parking, dev-environment, networking, wsl, troubleshooting]
sources: [] sources: []
updated: 2026-06-15 updated: 2026-09-09
--- ---
# WSL2 Dev Networking (for device testing) # WSL2 Dev Networking (for device testing)
@@ -110,6 +110,15 @@ trap:
Verified on hardware (2026-06-15): after the hook, `10.0.10.121` pings and the real [[lpr-camera]] Verified on hardware (2026-06-15): after the hook, `10.0.10.121` pings and the real [[lpr-camera]]
Hikvision driver pulls a snapshot with **no** source-forcing (`localAddress` becomes optional). Hikvision driver pulls a snapshot with **no** source-forcing (`localAddress` becomes optional).
> **It bit again, 2026-09-09 — and the fix was pinned to the wrong NIC.** Configuring the
> [[k200l-printer]] meant adding `192.168.123.101` beside `10.0.10.203` on the mirrored LAN NIC;
> WSL then sourced 10.0.10.x traffic from the 192.168.123 address: `ping` fine, every HTTP
> connect timing out, `ip route get 10.0.10.7` showing `src 192.168.123.101`. `parking-net.service`
> was active but pins **`eth1`**, and the mirrored LAN NIC is **`eth0`** on this box now — so the
> boot fixer was a no-op. Run `deploy/wsl-fix-route-source.sh eth0` (and fix the unit's argument),
> or `curl --interface 10.0.10.203 …` as a one-off. Interface names are not stable across WSL
> reboots/NIC changes; the script accepts the NIC as an argument for exactly this reason.
## On the real appliance: multi-subnet is a deployment config, not a WSL hack ## On the real appliance: multi-subnet is a deployment config, not a WSL hack
Production is a **dedicated hardened Linux appliance** ([[disk-os-hardening]]), so the WSL story Production is a **dedicated hardened Linux appliance** ([[disk-os-hardening]]), so the WSL story
+22
View File
@@ -14,6 +14,8 @@ parking appliance. Written from the **first real provisioning, 2026-06-23** (har
actual hardware, including the firmware-specific workaround. Companion to [[disk-os-hardening]] (the actual hardware, including the firmware-specific workaround. Companion to [[disk-os-hardening]] (the
*why*), [[tpm]] (TPM analysis), [[container-deployment]] (the images), and *why*), [[tpm]] (TPM analysis), [[container-deployment]] (the images), and
[[fleet-deployment-komodo]] (the deploy control plane this runbook's §7 uses). [[fleet-deployment-komodo]] (the deploy control plane this runbook's §7 uses).
**The devices plugged into the booth** (relay board, readers, cameras, radar, printers — factory
addresses, tools, hand steps, traps) have their own field runbook: [[site-device-installation]].
> ⚠ This box is the [[threat-model|outsider-with-the-box]] defence. The load-bearing anti-fraud > ⚠ This box is the [[threat-model|outsider-with-the-box]] defence. The load-bearing anti-fraud
> control is still [[reconciliation]] over the [[append-only-event-chain|signed chain]] — disk > control is still [[reconciliation]] over the [[append-only-event-chain|signed chain]] — disk
@@ -312,6 +314,12 @@ sudo loginctl enable-linger admin # so the user service starts at boot witho
**Verify:** `systemctl --user status periphery` → active; the server **`park-buzi`** appears and **Verify:** `systemctl --user status periphery` → active; the server **`park-buzi`** appears and
goes **OK/green** in Core → Servers. Then **delete the onboarding key**. goes **OK/green** in Core → Servers. Then **delete the onboarding key**.
> **After a reboot: `Unit periphery.service not loaded` (park-lab, 2026-09-09).** The unit
> existed but had never been **enabled**, so nothing started it at boot and `reset-failed` /
> `restart` had nothing to act on. Fix: `systemctl --user daemon-reload && systemctl --user enable
> --now periphery`. Add `enable --now` to the install sequence above whenever the installer's own
> enable did not stick (check with `systemctl --user is-enabled periphery` before leaving).
**➜ Next step is §7b below — the Stack itself is not deployed yet.** A green Server in Core just **➜ Next step is §7b below — the Stack itself is not deployed yet.** A green Server in Core just
means the agent connected; it runs nothing until you add the Registry/Git accounts and deploy. means the agent connected; it runs nothing until you add the Registry/Git accounts and deploy.
@@ -350,6 +358,12 @@ docker exec -it -e ADMIN_USER=admin -e ADMIN_PASS='<strong-pw>' \
park-buzi-server-1 node scripts/seed-admin.mjs park-buzi-server-1 node scripts/seed-admin.mjs
``` ```
The container is named `<stack>-server-1` (compose project = the Komodo stack name: `park-2-server-1`
on park-2; `docker ps` confirms). Leave `ADMIN_USER`/`ADMIN_PASS` off and the script prompts
(Enter = `admin`) — preferred on a shared shell, the password never enters history. Idempotent: an
existing username is left alone unless `FORCE=1` (§7e). After a `--users`/`--all` reset (§7d) run it
again — it recreates the built-in `admin` role row the reset removes.
> **Secrets-on-disk note.** The generated `.env` lands on the booth with **cleartext** secrets > **Secrets-on-disk note.** The generated `.env` lands on the booth with **cleartext** secrets
> (compose needs real values). That's why the disk is LUKS-encrypted (§3–4) and keys are per-booth > (compose needs real values). That's why the disk is LUKS-encrypted (§3–4) and keys are per-booth
> — the encryption is the control, and a single-booth compromise leaks only that booth's key. See > — the encryption is the control, and a single-booth compromise leaks only that booth's key. See
@@ -415,6 +429,14 @@ docker exec -it \
> it) **and** you type the DB filename to confirm (`parking.sqlite`; `--yes` skips that for scripted > it) **and** you type the DB filename to confirm (`parking.sqlite`; `--yes` skips that for scripted
> setup only). It is a **training/demo** tool — never run on a production booth's data. > setup only). It is a **training/demo** tool — never run on a production booth's data.
> **Drift caught 2026-09-07:** the Car Wash module (six `carwash_*` tables) and `role_jobs` had
> landed without a category, so the guard refused every reset on a booth carrying them. Categorised
> now — orders + the review outbox under `--financial`, prices/categories/services/config under
> `--config`, `role_jobs` under `--users` — and verified `--all` on a freshly migrated DB. The script
> ships **inside the server image**, so a booth runs the fixed version only from the next deployed
> tag; until then `docker cp` the file from the repo into the container at
> `/app/node_modules/@parking/db/scripts/reset-db.mjs` and run the same command.
After `--users`/`--all` (users cleared), re-seed the first admin exactly as in §7b After `--users`/`--all` (users cleared), re-seed the first admin exactly as in §7b
(`docker exec … node scripts/seed-admin.mjs`) so someone can log back in. Since 2026-07-06 the seed (`docker exec … node scripts/seed-admin.mjs`) so someone can log back in. Since 2026-07-06 the seed
script **self-heals the built-in `admin` role row** that this reset also wipes — before that fix the script **self-heals the built-in `admin` role row** that this reset also wipes — before that fix the
+1 -1
View File
@@ -198,7 +198,7 @@ Second `[[stack]]` in `komodo/resources.toml`: **`park-lab`** (server = the lab
| Stack | compose branch | image tag | secrets | | Stack | compose branch | image tag | secrets |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| park-lab | `dev` | **moving `dev`** (a lab may float) | `park_lab_*` | | park-lab | `dev` → **`stage` (2026-09-09)** | ~~moving `dev`~~ → **pinned to the booth's `stage-<sha>` under reproduction** (2026-09-09: the lab re-joined the fleet to reproduce the park-buzi printer cover-open bug, so it must run the booth's exact image; **no review outbox** — the bench is not a booth and must never feed the collector under a booth id) | `park_lab_*` |
| park-buzi | `stage` | pinned `stage-<sha>` | `park_buzi_*` | | park-buzi | `stage` | pinned `stage-<sha>` | `park_buzi_*` |
The three knobs are independent per stack — the ResourceSync's own branch only governs where the The three knobs are independent per stack — the ResourceSync's own branch only governs where the
+9 -1
View File
@@ -2,7 +2,7 @@
type: decision type: decision
tags: [parking, decisions, open] tags: [parking, decisions, open]
sources: [parking-system-architecture] sources: [parking-system-architecture]
updated: 2026-09-04 updated: 2026-09-08
status: open status: open
--- ---
@@ -148,3 +148,11 @@ procurement. (See [[parking-system-architecture]] §10.)
already carried `subscription:*` (stale note); roles now remember the jobs they follow and already carried `subscription:*` (stale note); roles now remember the jobs they follow and
a grown job is re-applied with one click, never silently; every role edit is signed as a a grown job is re-applied with one click, never silently; every role edit is signed as a
`config_change`. **Settled** — details on [[venue-modules]] §"Permissions matrix" Status. `config_change`. **Settled** — details on [[venue-modules]] §"Permissions matrix" Status.
17. **Party ledger — receivables & payables across modules.** _(Raised by the user, 2026-09-08.)_
Postpaid [[subscription]]s, hotel guest-nights billed to the hotel, Car Wash fleet deals on
account, and supplier/utility bills all need "who owes whom". Designed as a **counterparty
sub-ledger** — parties + signed `charge` / settlement / `write_off` events, balance derived,
aging + statements, CSV for the accountant — see [[party-ledger]] (design only, not built).
Interacts with #9 (a statement is **not** a fiscal invoice; fiscalisation is off-appliance)
and #8 (one currency per party until FX exists). Also reopened on the subscription page: a
**renewal is currently off-book** (an edit, no `payment`).
+149
View File
@@ -0,0 +1,149 @@
---
type: decision
tags: [parking, decisions, open, finance, ledger, modules, subscriptions, carwash]
sources: []
updated: 2026-09-08
status: open
---
# Party ledger — who owes the site, and whom the site owes
**Design only (2026-09-08). Nothing built.** Captured from a design conversation with the user; to be
refined before any code. The trigger was the [[subscription]] billing redesign: as soon as a
subscription can be **postpaid**, the site is *collecting a debt*, and the user immediately listed
three more debtors/creditors that need the same treatment. So this is not a subscription feature
— it is a **counterparty sub-ledger** that subscriptions, hotels, fleets and suppliers all sit on.
## The problem stated
The user's constraints, verbatim in spirit:
1. **Postpaid subscriptions** — the subscriber pays at the start or end of a month; the site must
see what is unpaid.
2. **Hotels** — occasional daily access for a hotel's guests, billed to the hotel, not the guest.
3. **Car Wash fleet deals** — the wash cleans a company's cars; payment is due per period; the
*site* collects the debt.
4. **Car Wash suppliers and utility bills** — the wash needs to see what it has paid and still owes
its suppliers (detergent, water, electricity).
5. **The admin needs one view of uncollected dues: who owes what to the park.**
Today none of this is modelled. Money exists in exactly two shapes: a signed `payment` at a till
([[shift]], [[append-only-event-chain]]) and a drawer voucher (`cash_in` / `cash_out`). Neither
names a *counterparty*, so "who owes whom" cannot be asked. The earlier
[[validation-sponsorship]] page sketched a `sponsors` table with a stored `balance_minor` for the
postpaid-merchant case; this page **supersedes that sketch** with something general.
## The decision (proposed)
Add **one core concept, once**: a **party** with an **account**, and three signed ledger event
types that move that account. Modules (Parking, Car Wash, later Bar — [[venue-modules]]) append
charges against parties; the core owns the party master data, the balance derivation, the
statement and the aging report. No module keeps its own receivable.
### Party (core master data, module-agnostic)
A party is any legal or natural person the site has money dealings with — a subscriber, a hotel, a
fleet company, a utility, a supplier. Mutable master data (like `subscriptions`), soft-deletable
([[soft-delete]]):
```
parties id, name, contact, taxId?, currency, kind {customer|supplier|both},
creditLimitMinor?, terms {dueDays | calendarDay}, active, deletedAt…
```
A subscriber gets a party row (created with the subscription, or linked to an existing one — a
company with five subscriptions is one party). `creditLimitMinor` lets a desk **refuse on-account
sales** when the party is over its limit; `terms` gives the default due date of a charge.
### Three signed event types (the account never stores a balance)
| Event | Meaning | Payload (signed) | Who appends |
| --- | --- | --- | --- |
| `charge` | an **accrual** — the party now owes (or is owed) | `partyId, direction {receivable\|payable}, amountMinor, currency, source {module, ref}, periodFrom?, periodTo?, dueAt, operator` | a module (subscription period, guest-night, on-account wash, supplier bill) |
| `settlement` | **money moved** against the account | as a **`payment`** at a till (`partyId` + `chargeIds[]` added) for cash/card received; a **`cash_out`** voucher with `partyId` for cash paid out; a `settlement` with `tender: "bank"` and no till for transfers either way | operator at a till / admin for bank |
| `write_off` | admin-signed **reduction with a reason** (waived period, disputed night, goodwill) | `partyId, chargeId, amountMinor, reason, operator` | admin only |
**Balance** per party and currency = Σ charges − Σ settlements − Σ write-offs, derived on read
(cached at most), never stored. **Why signed events and not a mutable `balance` column:** the
[[threat-model]] adversary is the booth/wash operator. A receivable that lives in a mutable row can
be quietly shrunk; a receivable that is a chain of signed events cannot — a statement is
re-derivable and **disputable against the chain**, the same guarantee the shift Z-report gives.
The one fraud-relevant path is the write-off, which is why it is admin-gated and permanent.
Reusing `payment` for money received (rather than inventing a parallel type) keeps the drawer,
the Z-report and the per-till folds ([[shift]] §Tills) working with **zero new summing surface** —
the same reasoning that made a subscription sale a `payment` with a `subscriptionSale` flag
([[subscription]] §Collecting the fee).
### How the four cases land on it
- **Subscriptions** — the billing-period design ([[subscription]] §Recurring billing) stays exactly
as drawn, except a billing period *is* a `charge` against the subscriber's party. Prepaid vs
postpaid is only the due-date rule. Paying a period = a till `payment` referencing the charge.
- **Hotels** — a subscription-like agreement whose **payer is the hotel party**, postpaid, whose
credential is issued per guest for N nights (the existing `"day"` plan). Each guest-night is a
charge line; the hotel receives a monthly **statement of nights**. The guest never pays.
- **Fleet washes** — the wash order gains a **third `payAt` beside `booth` and `bay`: `account`**.
The order is a charge against the fleet party; the wash till's Z-report shows on-account sales
as a separate line, *not* cash. Over the credit limit → the wash desk cannot pick `account`.
- **Suppliers and utilities** — a bill is a **payable** charge against that party (the wash's
detergent supplier, the electricity company). Paying it from the wash till is a `cash_out`
voucher that references the bill (the drawer already folds it); paying by bank is a bank
settlement. The owner sees what is owed, what was paid, and **from which till**.
### The admin view
One report over all parties: name, balance, oldest unpaid charge, **aging buckets** (current,
30, 60, 90+ days), drill-down to a **statement** for a period (every charge, settlement and
write-off, each linked to its signed event). "Uncollected dues" is a filter on it: receivables
with a balance. Payables are the same report with the direction flipped. Everything is a
projection over the ledger, like [[reporting-analytics]].
### Permissions
New core permissions, in the [[venue-modules]] matrix: `finance:read` (statements, aging),
`finance:settle` (record a bank settlement; till settlements ride the existing pay permissions),
`finance:writeoff` (admin), `party:manage` (master data). The wash desk sees only *whether* a
party is on-account-eligible, never the balance.
## Where the line is drawn
This is a **sub-ledger of receivables and payables, not bookkeeping.** No chart of accounts, no
profit-and-loss, no VAT computation, no double-entry general ledger. The accountant gets a **CSV
export** of charges and settlements per party and period. Two flags before anything is built:
- **A statement is not a fiscal invoice.** Fiscal receipts/invoices are already
[[open-questions]] #9 (tax number, sequential numbering, and — in Albania — fiscalisation).
The appliance is [[offline-first]]; fiscal invoicing needs the cloud side
([[cloud-service-saas]]) or an external fiscal device. Statements must be **labelled as
statements** so nobody mistakes them for invoices.
- **Parties are per appliance.** A fleet washing at two sites has two accounts until the
PostgreSQL sync target exists. Consolidation is a cloud-side concern.
Also deliberately **not** built: automatic card charging, dunning sequences, automatic
suspension without a grace period. The operator never types a price ([[subscription]] rule).
## Build order (each step usable on its own)
1. `parties` + the three event types + the balance/aging/statement report and CSV export.
2. Subscription billing periods on top ([[subscription]] §Recurring billing) — the renewal
off-book hole closes here.
3. `payAt: "account"` on Car Wash orders, with the credit-limit gate and the Z-report line.
4. Bills and payables (supplier / utility register; `cash_out` with a bill reference).
## Open
- Does a **guest-night** charge get appended at credential issue (N nights known up front) or per
actual entry? Issue-time matches the hotel's booking; per-entry matches reality. Lean issue-time,
with a void path if the guest never came.
- **Currency**: parties carry one currency; a charge in another is refused until the FX question
([[open-questions]] #8) is settled.
- **Who may create a party** at the wash desk vs. admin only (a fleet deal is a contract, not a
walk-in).
- Should utility bills live in this app at all, or only supplier bills paid from a till? The user
asked for both; the register is cheap, the temptation to grow it into bookkeeping is the risk.
- **Reminders to the party** (statement by email/SMS) are off-appliance — same answer as the
subscription expiry notice: the operator/owner is notified, the contact is theirs to make.
Related: [[subscription]] · [[validation-sponsorship]] (superseded sketch) · [[venue-modules]] ·
[[shift]] · [[append-only-event-chain]] · [[threat-model]] · [[reporting-analytics]]
+107
View File
@@ -0,0 +1,107 @@
---
type: entity
tags: [parking, hardware, printer, escpos, network, usb, status]
sources: []
updated: 2026-09-09
---
# K200L thermal printer (Xprinter / ICS "XP-K200L") — the park-buzi unit
An 80 mm ESC/POS receipt printer, **USB + LAN**, sold under several names. Bottom label:
*"THERMAL RECEIPT PRINTER — Model: K200L — Paper Width: 80mm — Print Speed: 200mm/s — Power
Input: 24V 2.5A — Cash Drawer: 24V 1A — Interface: USB+LAN — Command Support: ESC/POS"*, serial
`BLU2107080238`. Identified on the dev bench 2026-09-09; **it is the printer that "goes offline
after a paper reload" at park-buzi** ([[printer-usb-transport]] §Field bug). The lab's older
"ICS XP-K200L" (10.0.10.11, the 2026-07 USB truncation work) is the same family.
Three names for one device, all seen on the bench:
| Where | What it calls itself |
| --- | --- |
| bottom label | K200L |
| USB descriptor (`lsusb`) | `1fc9:2016 NXP Semiconductors Printer-80` / "Printer POS-80" (0x1fc9 = the NXP controller chip; no brand) |
| LAN board web UI | "J-Speed Ethernet Interface Module", "Ethernet WebConfig Version 1.02", copyright "POS" |
The app driver is **`k200l`** ("K200L 80mm thermal printer (Xprinter / ICS, USB+LAN)",
`packages/devices/src/drivers/printer-k200l.ts`). It prints through the shared generic ESC/POS
path (identical bytes, TCP 9100 or `usblp`) and **adds live status from the LAN board** — see
below. Before 2026-09-09 this unit ran on the generic `escpos` driver (reachability only), which is
why the app could never show its cover/paper state.
## Network setup (the evening that was never written down)
- **Factory address `192.168.123.100/24`, DHCP OFF.** Nothing announces it; the printer just sits
there on a subnet nobody uses. To reach it, give the workstation a second address in
`192.168.123.0/24` (Windows: adapter → IPv4 → Advanced → add `192.168.123.101`), then open
`http://192.168.123.100/`.
- The web configurator (port 80, **no authentication**) is a three-frame page: *Information*
(`ip_info.htm`: MAC, IP, mask, gateway, DHCP on/off, DHCP timeout), *Configuration*
(`ip_config.htm`: DHCP client on/off + timeout, fixed IP / mask / gateway as four octet fields,
**Save**, Restore Default, cancel), *Printer Status* (`prt_status.htm`), *Printer Test*
(`prt_test.htm`), and a **Restart** button in the menu.
- **Set a fixed address on the device VLAN** (park-lab: `10.0.10.7/24`, gateway `10.0.10.1`) →
Save → Restart; then remove the temporary `192.168.123.x` address from the workstation. Keep
DHCP off — the app addresses printers by IP ([[rongta-printer]] §Deployment).
- The self-test page (`prt_test.htm` / the "Print Test Page" button on the status page) prints
the current network settings, so a unit with a forgotten address can be read back that way.
- The board's HTTP server is **tiny**: the frameset reloads its frames every 1–3 s and the status
page every 5 s, and it holds very few connections. **Close the browser tab while the app is
polling**, or connections intermittently time out (seen on the bench: `ping` fine, port open,
every second HTTP connect hanging while the page was open in a browser).
> **WSL gotcha while doing this (2026-09-09):** the dev box then carried BOTH `192.168.123.101`
> and `10.0.10.203` on `eth0`, and WSL sourced 10.0.10.x traffic from the 192.168.123 address —
> the exact [[wsl-dev-networking]] source-address bug, except `parking-net.service` pins `eth1`
> and the mirrored LAN NIC is `eth0` now. Symptom: `ping` works, `curl` times out. Run the fix for
> `eth0`, or drop the temporary address once the printer is moved.
## Live status — the `/prt_status.htm` page
The LAN board serves a five-row table the printer has already decoded from its own sensors:
```
Cover Is Open Yes/No
Cutter Error Yes/No
Paper End Yes/No
Paper Near End Yes/No
Printer Off-Line Yes/No
```
A fault is written as **`<FONT color=#ff0000>Yes</FONT>`** while a clear row is a bare, space-padded
`No` — the first parser only accepted tag-free cells, so with the cover open the booth showed
*"unexpected status page (missing coverOpen, paperEnd, offline)"*: exactly the Yes cells. Fixed
the same day (cell text is read with inner tags stripped); the verbatim markup is pinned in the
driver's tests.
**Same rows, same `<TD>label</TD><TD>Yes|No</TD>` shape as the Rongta board's `/prn_stat.htm`**
([[printer-status-monitoring]]) — only the path differs, which is why nobody found it in July
(the Rongta driver looked for `/prn_stat.htm`, got nothing, and the unit was filed as "serves no
status page → generic driver"). Verified on the bench: cover open → `Cover Is Open Yes`, `Paper End
Yes`, `Printer Off-Line Yes` within a refresh; cover closed → all `No`.
**Quirk that needs its own fetch code:** the board's HTTP reply has **no status line and no
headers** — the body starts at byte 0 (HTTP/0.9 style). Browsers render it; `curl` reports
`000` with an empty body; Node's `http` client rejects it with *"Parse Error: Expected HTTP/, RTSP/
or ICE/"*. So the `k200l` driver reads the page over a **raw TCP socket** (`GET … HTTP/1.0`, read
until the board closes) and accepts both the headerless reply and a proper one. This is the second
reason the K200L has its own driver rather than a path option on the Rongta one.
Status mapping (mirrors the Rongta driver, [[printer-status-monitoring]]): board unreachable /
timeout → **offline**; page reachable but not the table (non-200, the index page) → **degraded
"unexpected status page"**, never ready off a page we didn't read; any Yes → **degraded** naming
the faults ("cover open, paper out, printer off-line"); all No → **ready**. Over **USB** there is
no page: reachability floor only (ready/offline), same as a USB Rongta.
## What this means for the park-buzi bug
At park-buzi this unit ran **over USB** on the generic driver, i.e. monitored by "does
`/dev/usb/lpN` open". A cover-open / paper-out condition **never showed in the app at all** — the
badge stayed green. So the operators' "printer goes offline after reloading paper" was not the
cover state being reported; it was a genuine probe failure whose errno is still unread (site shut
down — [[printer-usb-transport]] §Field bug has the commands to pull first). Running the unit on
**LAN with the `k200l` driver** would give the booth a real amber "cover open / paper out" while
the roll is changed, and removes the `usblp` path from the equation altogether — a strong reason to
cable it to the device VLAN when the site reopens.
Related: [[rongta-printer]] · [[printer-status-monitoring]] · [[printer-usb-transport]] ·
[[printer-roles-failover]] · [[network-isolation]] · [[wsl-dev-networking]] · [[site-device-installation]]
+5 -1
View File
@@ -2,7 +2,7 @@
type: entity type: entity
tags: [parking, hardware, printer, device] tags: [parking, hardware, printer, device]
sources: [] sources: []
updated: 2026-06-19 updated: 2026-09-09
--- ---
# Rongta 80mm thermal printer # Rongta 80mm thermal printer
@@ -55,6 +55,10 @@ many ESC/POS-compatible OEM clones that share its firmware). Driver `rongta` in
- **booth-receipt** — `10.0.10.10`, inside the booth; receipts, AND the backup that prints - **booth-receipt** — `10.0.10.10`, inside the booth; receipts, AND the backup that prints
the entry ticket if the outside dispenser is offline. This unit is a **Rongta** (`rongta` the entry ticket if the outside dispenser is offline. This unit is a **Rongta** (`rongta`
driver, full status-page monitoring). driver, full status-page monitoring).
- **Not a Rongta, its own driver since 2026-09-09:** the **K200L** (Xprinter/ICS XP-K200L family;
LAN board calls itself "POS-80") — the park-buzi unit and the lab's 10.0.10.11 unit. Same
five-row status table under **`/prt_status.htm`** (not `/prn_stat.htm`), served without HTTP
headers, so it has the `k200l` driver with a raw-socket fetch. See [[k200l-printer]].
## Ticket rendering ## Ticket rendering
+120 -6
View File
@@ -2,7 +2,7 @@
type: entity type: entity
tags: [parking, domain, business, subscriptions, identity, pricing] tags: [parking, domain, business, subscriptions, identity, pricing]
sources: [] sources: []
updated: 2026-06-20 updated: 2026-09-08
aliases: [subscription-plan] aliases: [subscription-plan]
status: open status: open
--- ---
@@ -90,6 +90,15 @@ subscription row, one window. The amount the operator should collect is **N × t
`now` ∈ [validFrom, validTo]** — so a 3-month window simply stays valid for three months. `now` ∈ [validFrom, validTo]** — so a 3-month window simply stays valid for three months.
- An explicit **`validTo` override** is still accepted (manual end date) when `months` isn't used. - An explicit **`validTo` override** is still accepted (manual end date) when `months` isn't used.
> ⚠ **Renewal is OFF-BOOK (found 2026-09-08).** "Renewing is just editing the window" means a
> renewal goes through `PUT /api/subscriptions/:id`, which by design **never re-sells and appends
> nothing to the ledger**. The first sale was put on the chain on 2026-06-20 precisely because
> 27,000 ALL had gone off-book; **every renewal since takes the same off-book path** — the
> operator collects the next month's fee and moves `validTo`, with no `payment` event. The
> recurring-billing design below closes this: a renewal becomes *paying the next billing period*,
> a signed `payment`. Until then, a renewal should be taken as a **new sale** (new subscription
> row), not an edit.
### v2 — quantity, plan timeframes (tariff bridge), reserved spots (built 2026-06-20) ### v2 — quantity, plan timeframes (tariff bridge), reserved spots (built 2026-06-20)
Three enhancements driven by real scenarios (migration `0011`): Three enhancements driven by real scenarios (migration `0011`):
@@ -356,6 +365,106 @@ Intended behaviour (to design + build later):
> **Explicitly postponed.** For now this is documentation only — no schema, no enforcement. A > **Explicitly postponed.** For now this is documentation only — no schema, no enforcement. A
> subscription is valid whenever it is active and within `validFrom`/`validTo`, all day. > subscription is valid whenever it is active and within `validFrom`/`validTo`, all day.
## Recurring billing — prepaid / postpaid, calendar or anniversary — DESIGN 2026-09-08
**Design only, nothing built.** Captured from a design conversation with the user (2026-09-08):
"a subscriber should prepay or postpay every month, on the 1st or on the day the subscription
began; a subscription fixed by a daily tariff, e.g. 300 ALL/day; for prepaid, a notice that a
subscription is about to expire so the owner/operator warns the subscriber to pay or lose access."
The financial side of this grew into its own page — the [[party-ledger]] — because a postpaid
subscriber is a *debtor*, and the site has other debtors (hotels, fleets) and creditors
(suppliers). This section is the subscription-shaped part.
### What is wrong with the one-window model
A subscription today is **one coverage window** (`validFrom`/`validTo`) sold once: a hotel model.
There is no recurring agreement, no due date, no grace, no unpaid balance; prepaid vs postpaid is
not expressible, and calendar-anchored billing can only be faked with hand-picked dates. And
renewal is off-book (callout above).
### Split the one row into three concepts
**1. Plan** — the catalog and versioning stay; a plan version gains a **billing rule**:
```
billing: {
mode: "prepaid" | "postpaid",
cycle: "day" | "week" | "month", // how often a period is billed
anchor: "calendar" | "start", // the 1st of the month, or the sale's anniversary
graceDays: number, // access continues this long past due
noticeDays: number // "about to expire" window
}
```
Recurring plans are **priced per day** (`period: "day"`): a calendar month costs
`daysInMonth × 300 ALL`, a partial first month is simply the days left, and **calendar and
anniversary anchoring share one formula** (proration falls out for free). Fixed-price monthly
plans (`period: "month"`) stay for sites that want a flat number. The hotel "N nights" sale is
unchanged (a `"day"` plan over a span, no billing rule).
**2. Agreement** — the `subscriptions` row: holder, credentials, cars, `validFrom`; for a
recurring plan **no `validTo`** (open-ended, ends by revoke/suspend). Fixed spans keep `validTo`.
The holder is (or is linked to) a **party** ([[party-ledger]]) — the payer, which for a hotel is
the hotel, not the guest.
**3. Billing periods** — one row per cycle, and each is a **`charge`** on the party ledger:
```
subscription_periods id, subscriptionId, periodFrom, periodTo,
amountMinor (from the plan version), currency, dueAt,
status {due|paid|overdue|waived}, chargeEventId, paymentEventId?
```
- **Paying a period** = the existing signed **`payment`** with `subscriptionSale: true` plus the
period/charge reference — drawer and Z-report keep working with no new summing
(§Collecting the fee). **Renewal is just paying the next period.** This closes the off-book hole.
- **Waiving** a period is a signed **$0 payment with a reason** — the same rule the Car Wash uses
for a comp ([[venue-modules]]: a comp never opens the barrier, sign the $0 payment) — or a
`write_off` on the party ledger; admin-gated either way.
- The next period is **generated ahead** (prepaid: before the current one ends, so it can be paid
early; postpaid: at period end, due `dueAt`), by a daily tick or lazily on read.
### The gate asks one function
The entry flow stops reading `validTo` for recurring plans and asks
`subscriptionAccess(sub, periods, now) → { ok, reason, accessUntil, daysLeft }`:
- **prepaid** — allowed while `now ≤ paidThrough + graceDays` (the next period must be paid
before it starts, plus grace);
- **postpaid** — allowed while no period is unpaid past `dueAt + graceDays`;
- both collapse to one derived **`accessUntil`** and **`daysLeft`** per subscriber (never stored).
This also answers the long-open **lapsed-mid-stay** question for recurring subs: a period ending
while a car is parked falls into **grace**, so nobody is trapped; only a subscriber still parked
past grace becomes a transient at exit (the tariff-bridge machinery above already prices that).
Revoked/suspended behaviour is unchanged.
### "About to expire" — derived, not stored
One endpoint (e.g. `GET /api/subscriptions/attention`) lists subscribers whose `accessUntil` falls
within the plan's `noticeDays`, those in grace, and those overdue. Surfaced in three places:
1. a **counter on the booth console** ([[booth-console]]);
2. a **badge in the subscriber list**;
3. a **line in the live feed when such a subscriber scans in** — "expires in 3 days" at the moment
the person is at the gate (a slip can print, best-effort like the window-charge notice).
Contacting the subscriber stays with the operator/owner by phone (`contact` field). SMS/email
is off-appliance ([[cloud-service-saas]]) — a separate decision.
### Not built, deliberately
Automatic card charging, invoices, dunning, auto-suspension without grace. **The operator still
never types a price.**
### Build order (after [[party-ledger]] step 1)
1. Billing rule on the plan version + `subscription_periods` (migration); period generation.
2. Pay-period route (signed `payment` + charge reference) and the `subscriptionAccess` gate
function in `subscription-flow.ts`; `PUT` stops moving `validTo` on recurring subs.
3. Attention endpoint + the three UI surfaces.
4. Wiki + [[booth-console]] docs.
## Data model (as-built 2026-06-18) ## Data model (as-built 2026-06-18)
Tables (mutable master data; every *use* still produces a signed `vehicle_entry`/`vehicle_exit`): Tables (mutable master data; every *use* still produces a signed `vehicle_entry`/`vehicle_exit`):
@@ -420,10 +529,15 @@ subscription** (card/QR credential, or a bound plate) — otherwise to the trans
1. **Reader hardware** — confirm the RF reader and QR/optical reader models (procurement; [[bom]], 1. **Reader hardware** — confirm the RF reader and QR/optical reader models (procurement; [[bom]],
[[open-questions]]). [[open-questions]]).
2. **Lapsed-mid-stay & revoked** policy (fall back to transient [[tariff]] vs. refuse) — confirm. 2. **Lapsed-mid-stay & revoked** policy (fall back to transient [[tariff]] vs. refuse) — confirm.
3. ~~**Subscription-fee collection**~~ — **RESOLVED + BUILT 2026-06-20.** Selling a priced 3. ~~**Subscription-fee collection**~~ — **RESOLVED + BUILT 2026-06-20** for the *first* sale
subscription appends a signed `payment` (`subscriptionSale` flag, `priceMinor × months`, (signed `payment`, `subscriptionSale` flag, operator-chosen tender, folds into the
operator-chosen tender) that folds into the drawer/Z-report. Remaining sub-question: should a sale drawer/Z-report). **REOPENED 2026-09-08 for RENEWALS**: a renewal is a `PUT` that appends
be **hard-blocked without an open shift** (it isn't today — it warns instead)? See "Collecting the nothing (see the callout under "Multi-month"). Closed by the recurring-billing design (a renewal
fee". = paying the next period). Remaining sub-question: should a sale be **hard-blocked without an
open shift** (it isn't today — it warns instead)?
4. **Time-of-day access windows** (overnight subscribers) — design + build; boundary-case policy 4. **Time-of-day access windows** (overnight subscribers) — design + build; boundary-case policy
above (see the design note). above (see the design note).
5. **Recurring billing** (prepaid/postpaid, calendar/anniversary anchor, grace, expiry notice) —
**designed 2026-09-08, not built**; see §Recurring billing and [[party-ledger]]. To refine: is
the next period generated by a daily tick or lazily; does a waived period sign a $0 `payment` or
a `write_off` (pick one); whether `noticeDays` is per plan or per site.
+5 -2
View File
@@ -46,6 +46,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware); spare relays drive aux outputs (`setAux`). - [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware); spare relays drive aux outputs (`setAux`).
- [[hikvision-radar]] — vehicle-presence radar on a Dingtian input; the entry presence gate (per-input active-level caveat). - [[hikvision-radar]] — vehicle-presence radar on a Dingtian input; the entry presence gate (per-input active-level caveat).
- [[rongta-printer]] — ✅ CHOSEN 80mm thermal printer; ESC/POS over raw TCP 9100 (or local USB, see [[printer-usb-transport]]); driver written, one unit reachable at 10.0.10.6. - [[rongta-printer]] — ✅ CHOSEN 80mm thermal printer; ESC/POS over raw TCP 9100 (or local USB, see [[printer-usb-transport]]); driver written, one unit reachable at 10.0.10.6.
- [[k200l-printer]] — the park-buzi printer identified (2026-09-09): Xprinter/ICS K200L, USB id 1fc9:2016 "POS-80", J-Speed LAN board at 192.168.123.100 (DHCP off, web config on :80); status page `/prt_status.htm` (Rongta's rows, headerless HTTP) → own `k200l` driver with raw-socket fetch; over USB reachability only, so cover-open never showed at park-buzi.
- [[bom]] — reference bill of materials (barrier, loops, controller, readers, payment, host, network). - [[bom]] — reference bill of materials (barrier, loops, controller, readers, payment, host, network).
## Concepts — foundational forces ## Concepts — foundational forces
@@ -65,6 +66,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter. - [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
- [[device-registry]] — catalog of selectable drivers per category (admin-configurable). - [[device-registry]] — catalog of selectable drivers per category (admin-configurable).
- [[first-run-setup]] — admin adds controllers + binds readers/cameras to relays from the catalog at install. - [[first-run-setup]] — admin adds controllers + binds readers/cameras to relays from the catalog at install.
- [[site-device-installation]] — FIELD RUNBOOK (2026-09-09): per device — factory address + credentials, the tool needed, what the wizard configures itself vs what is done by hand on the device, known traps; address plan, order of work on site, gaps to fill. Dingtian relay, DT-008 readers, Hikvision camera, radar, K200L / Rongta / Cashino printers.
- [[device-input-flow]] — button → device push → backend decides → relay; backend is source of truth. - [[device-input-flow]] — button → device push → backend decides → relay; backend is source of truth.
- [[device-discovery]] — optional driver capability to scan the LAN (no current driver uses it; UHPPOTE was the example). - [[device-discovery]] — optional driver capability to scan the LAN (no current driver uses it; UHPPOTE was the example).
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware. - [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
@@ -102,7 +104,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
- [[site-metadata]] — optional park identity (name, operator, VAT, address, contact) in site_config; feeds the ticket header. - [[site-metadata]] — optional park identity (name, operator, VAT, address, contact) in site_config; feeds the ticket header.
- [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred. - [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred.
- [[validation-discounts]] — BUILT (2026-07-13): in-park merchant (bar/lavazh) users scan-and-validate on their device (signed event, program↔user binding); booth settles NET + prints gross/discount/net; comp/time-credit/fixed/percent, caps, /setup/site panel, /validate screen. - [[validation-discounts]] — BUILT (2026-07-13): in-park merchant (bar/lavazh) users scan-and-validate on their device (signed event, program↔user binding); booth settles NET + prints gross/discount/net; comp/time-credit/fixed/percent, caps, /setup/site panel, /validate screen.
- [[validation-sponsorship]] — design: sponsor accounts + postpaid B2B (customers park free, business billed monthly); not a permit. - [[validation-sponsorship]] — design: postpaid B2B sponsorship (customers park free, business billed monthly); its sponsor-account sketch is superseded by [[party-ledger]].
- [[reporting-analytics]] — revenue/occupancy/stay reports + plate-search, all projections over the signed log. - [[reporting-analytics]] — revenue/occupancy/stay reports + plate-search, all projections over the signed log.
- [[clock-integrity]] — fees depend on the host clock; detect/flag backdating on an offline box. - [[clock-integrity]] — fees depend on the host clock; detect/flag backdating on an offline box.
- [[ticket-encoding]] — transient ticket id (11-digit numeric + Luhn) as Code128; printed at entry, scanned at pay station + exit; barcode geometry must fit paper width (KP-300H overflow); plate-as-ticket alt. - [[ticket-encoding]] — transient ticket id (11-digit numeric + Luhn) as Code128; printed at entry, scanned at pay station + exit; barcode geometry must fit paper width (KP-300H overflow); plate-as-ticket alt.
@@ -110,7 +112,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
- [[device-events]] — unsigned hardware telemetry (relay/printer/camera/reader/input); separate from the signed ledger. - [[device-events]] — unsigned hardware telemetry (relay/printer/camera/reader/input); separate from the signed ledger.
- [[app-logs]] — the third stream: diagnostic logs (backend warn+ pino sink + frontend errors) → app_logs; log:read viewer; pruned by age+row cap. - [[app-logs]] — the third stream: diagnostic logs (backend warn+ pino sink + frontend errors) → app_logs; log:read viewer; pruned by age+row cap.
- [[soft-delete]] — BUILT: accidental admin deletes of master data (users/roles/subs/plans/tariffs) are soft (deleted_at) + recoverable from a recycle bin; auto-purge after N days; signed ledger out of scope. - [[soft-delete]] — BUILT: accidental admin deletes of master data (users/roles/subs/plans/tariffs) are soft (deleted_at) + recoverable from a recycle bin; auto-purge after N days; signed ledger out of scope.
- [[subscription]] — recurring plan (e.g. 10,000 ALL/month); RF/QR or plate identity, car-count + max-concurrent, host-in-loop; short-circuits payment. (Renamed from "permit"; time-of-day windows noted, deferred.) - [[subscription]] — recurring plan (e.g. 10,000 ALL/month); RF/QR or plate identity, car-count + max-concurrent, host-in-loop; short-circuits payment. Plan catalog + tariff bridge built. 2026-09-08: **renewal found off-book**; recurring billing (prepaid/postpaid, calendar/anniversary, grace, expiry notice, billing periods as ledger charges) designed, not built.
- [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness); fast-alpr (MIT, YOLOv9+CCT/ONNX) the evaluated recognizer baseline. - [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness); fast-alpr (MIT, YOLOv9+CCT/ONNX) the evaluated recognizer baseline.
- [[lane-presence-and-anpr-entry]] — camera vehicle detection → (BUILT) advisory lane busy/free booth lights + (BUILT) the ANPR "bridge" (`anpr-entry.ts`): a subscriber's plate read at the lane admits them via the existing gated subscription flow (match-before-emit; subscriber-only). Measured camera limits; rejected the queue-tracking/livestream ideas. - [[lane-presence-and-anpr-entry]] — camera vehicle detection → (BUILT) advisory lane busy/free booth lights + (BUILT) the ANPR "bridge" (`anpr-entry.ts`): a subscriber's plate read at the lane admits them via the existing gated subscription flow (match-before-emit; subscriber-only). Measured camera limits; rejected the queue-tracking/livestream ideas.
- [[vision-service-hardening]] — fix/hardening backlog for `apps/vision/` (2026-07-02 reviews): DoS (body-cap, pixel-bomb, event-loop-blocking inference), unauthenticated + operator-writable model weights, `0.0.0.0` default bind, + correctness/hygiene items. Not yet fixed — the to-do list. - [[vision-service-hardening]] — fix/hardening backlog for `apps/vision/` (2026-07-02 reviews): DoS (body-cap, pixel-bomb, event-loop-blocking inference), unauthenticated + operator-writable model weights, `0.0.0.0` default bind, + correctness/hygiene items. Not yet fixed — the to-do list.
@@ -137,6 +139,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
- [[vision-service-packaging]] — the vision service lives in this monorepo (apps/vision/), separate process, wired into Turbo via a package.json shim; uv-managed Python. - [[vision-service-packaging]] — the vision service lives in this monorepo (apps/vision/), separate process, wired into Turbo via a package.json shim; uv-managed Python.
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events). - [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell. Auto-updater mirrors signed releases to public `mca/public_releases` (source repo is private — field appliances have no Gitea creds). - [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell. Auto-updater mirrors signed releases to public `mca/public_releases` (source repo is private — field appliances have no Gitea creds).
- [[party-ledger]] — 🟡 DESIGN (2026-09-08, not built): counterparty sub-ledger for who-owes-whom across modules — parties + signed `charge` / settlement / `write_off` events, balance derived never stored, aging + statements + CSV; lands postpaid subscriptions, hotel guest-nights, fleet washes on account, supplier/utility bills. Sub-ledger only: no bookkeeping, statements are not fiscal invoices, parties per appliance.
- [[venue-modules]] — 🟡 OPEN: optional per-site modules (Car Wash, Bar/Restaurant) with Parking as a peer module on a venue POS/audit core; manifest registry, entitled ∩ activated enablement (vendor env + site-admin config), validation kept for the Bar (Lavazh station retires with Car Wash), name stays parking-system, vision vehicle-category as an advisory anomaly flag. - [[venue-modules]] — 🟡 OPEN: optional per-site modules (Car Wash, Bar/Restaurant) with Parking as a peer module on a venue POS/audit core; manifest registry, entitled ∩ activated enablement (vendor env + site-admin config), validation kept for the Bar (Lavazh station retires with Car Wash), name stays parking-system, vision vehicle-category as an advisory anomaly flag.
- [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot. - [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot.
- [[fleet-deployment-komodo]] — fleet control plane: Komodo Periphery on each booth, driven by Komodo Core over a NetBird mesh, running the same compose files. Deploys manual + pinned to dev-<sha> (no webhook); secrets Komodo-managed per-booth+unique; booth.sh demoted to break-glass. Threat-model caveats: Periphery is a root agent (mesh-bound only), EVENT_SIGNING_KEY-in-Core is a fraud-root blast radius until ATECC608 signs. komodo/ is infra-as-code. - [[fleet-deployment-komodo]] — fleet control plane: Komodo Periphery on each booth, driven by Komodo Core over a NetBird mesh, running the same compose files. Deploys manual + pinned to dev-<sha> (no webhook); secrets Komodo-managed per-booth+unique; booth.sh demoted to break-glass. Threat-model caveats: Periphery is a root agent (mesh-bound only), EVENT_SIGNING_KEY-in-Core is a fraud-root blast radius until ATECC608 signs. komodo/ is infra-as-code.
+109
View File
@@ -3144,6 +3144,20 @@ run; the Quadro FX 3800 is unusable (cc 1.3), the HD P530 irrelevant, the Xeon E
compose seam drops the GPU reservation; cloud GPU rejected (crops stay on premises). Linked from compose seam drops the GPU reservation; cloud GPU rejected (crops stay on premises). Linked from
[[opencv-anpr-service]], [[vision-review-outbox]], index. User: "No build just yet." [[opencv-anpr-service]], [[vision-review-outbox]], index. User: "No build just yet."
## [2026-09-07] query | How to seed the admin user on a booth
Answered from [[appliance-provisioning]] §7b/§7e (`docker exec … node scripts/seed-admin.mjs`,
`FORCE=1` to reset a password). One gap filled: the container-name pattern (`<stack>-server-1`,
`park-2-server-1` on park-2), the prompting form, idempotence, and re-seeding after a reset.
## [2026-09-07] fix | reset-db drift — Car Wash tables and role_jobs were uncategorised
User asked for "the command to reset everything in the booth pc". The documented command
([[appliance-provisioning]] §7d, `docker exec … reset-db.mjs --all`) would have been refused on
park-2: the drift guard found `carwash_*` (six tables) and `role_jobs` outside every category.
Categorised (orders + review outbox → financial; prices/categories/services/config → config;
role_jobs → users), `--all` verified on a freshly migrated DB. The script ships in the server
image — fixed on the booth from the next deployed tag, or by `docker cp` until then. Table
rows updated on [[local-dev-workflow]].
## [2026-09-07] decision | Desktop v0.2.0 — a minor bump, not a patch ## [2026-09-07] decision | Desktop v0.2.0 — a minor bump, not a patch
User: "Do you think we are ready for version 0.2.0? The actual version is 0.1.7." Yes: v0.1.x User: "Do you think we are ready for version 0.2.0? The actual version is 0.1.7." Yes: v0.1.x
were all shell fixes; the bundled SPA now carries the module registry, Car Wash + per-till were all shell fixes; the bundled SPA now carries the module registry, Car Wash + per-till
@@ -3199,3 +3213,98 @@ variable, deploy the collector before a booth that sends a new package kind, the
neutralisation (security review finding), and why every entry is sent. On neutralisation (security review finding), and why every entry is sent. On
[[vision-service-packaging]]: CI syncs without the alpr extra — numpy in the dev group, cv2 tests [[vision-service-packaging]]: CI syncs without the alpr extra — numpy in the dev group, cv2 tests
importorskip (three red runs on 2026-09-07). importorskip (three red runs on 2026-09-07).
## [2026-09-08] decision | Subscription recurring billing + the party ledger (design only)
User: "an subscriber should prepay or postpay every month, at the 1st or the day it began; fixed
by a daily tariff (300 ALL/day); notify when about to expire … we need a more flexible way." Then:
"the financial aspect is too simple" — postpaid agreements, hotels given daily access for guests,
Car Wash fleet deals paid per period, the wash's supplier/utility bills; the admin needs to see
uncollected dues. Assessed against the code: a subscription is ONE coverage window sold once;
**renewal is off-book** (a `PUT` that never re-sells and appends no `payment` — the same hole
closed for the first sale on 2026-06-20). Designed, not built: (a) [[subscription]] §Recurring
billing — plan billing rule {mode, cycle, anchor, graceDays, noticeDays}, recurring plans priced
per day so calendar and anniversary anchoring share one formula, open-ended agreement, a
`subscription_periods` table where each period is a ledger charge and a renewal = paying the next
period (signed `payment`), one `subscriptionAccess()` gate function (answers lapsed-mid-stay via
grace), expiry notice derived not stored (console counter, list badge, feed line at scan-in);
(b) new [[party-ledger]] decision page — parties + signed `charge` / settlement (`payment` /
`cash_out` / bank) / `write_off`, balance derived never stored (threat model), aging + statements
+ CSV, the four cases (subscriptions, hotels, fleet washes as `payAt: "account"`, supplier bills
as payables), the line drawn (sub-ledger, not bookkeeping; a statement is not a fiscal invoice;
parties per appliance), build order. [[validation-sponsorship]]'s sponsor table marked
superseded; [[open-questions]] #17 added, #3 on the subscription page reopened for renewals;
index updated. Nothing in code changed.
## [2026-09-09] ingest | Printer cover-open bug — bench result falsifies re-enumeration; park-lab rejoins the fleet
The failing park-buzi printer is on the dev bench: identified as USB `1fc9:2016` "Printer POS-80"
(NXP controller, no brand in the descriptor — hence "Generic" in the app). Attached to WSL via
usbipd-win 5.3 (busid 8-1). Cover cycled under `dmesg -w` + `lsusb`: NO disconnect, NO
re-enumeration — the leading hypothesis (cover cuts the USB board → stale container `/dev/usb`
bind-mount) is falsified for this unit; the fault is in how usblp / the open-probe reacts to the
printer's error state. Next discriminator = the monitor's offline `detail` text on park-buzi
(EBUSY vs open-timeout vs EIO). WSL caveat recorded: Microsoft's 6.6.87 kernel has
CONFIG_USB_PRINTER unset — no `/dev/usb/lpN` without a custom kernel, so the user will reproduce
on a Linux box instead. `komodo/resources.toml`: `park-lab` stack re-added for that box — copied
from park-2 then corrected (the copy carried park-2's review outbox + booth-2 token; removed — the
bench must never feed the collector under a booth id); pinned to the booth's stage-<sha>. Pages:
[[printer-usb-transport]] (identity, bench result, WSL caveat), [[fleet-deployment-komodo]]
(park-lab row).
## [2026-09-09] ingest | Printer cover-open bug — lab did NOT reproduce; park-buzi closed, evidence pending
Same printer unit on `park-lab` (real Linux host, booth image stage-2d9bb15, prod compose, dev DB
snapshot with the USB printer as booth-receipt, cards via subscription reprint): paper out → cover
→ reload → reprint — no error. Printer, USB transport and compose wiring cleared in isolation;
what remains is park-buzi's environment (kernel/USB path/power) or the container's history.
park-buzi is shut down (staff shortage). Recorded on [[printer-usb-transport]]: the three
commands to run FIRST when the box next powers on (container log transitions, kernel journal,
`lsusb -t`), how to read each answer, and two proposed no-booth follow-ups (close the
`withTimeout` handle leak; make the monitor self-document consecutive USB offline polls).
Dev-DB snapshot procedure for a lab (online backup → reset-db --financial --diagnostics → clear
backup fields → VACUUM → copy into the volume with chown) used today; not yet on a wiki page.
## [2026-09-09] build | K200L printer identified + `k200l` driver with live status; network setup recorded
The bottom label says **Model K200L** (Xprinter/ICS XP-K200L family, USB+LAN, ESC/POS); the LAN
board ("J-Speed Ethernet WebConfig 1.02") calls it "POS-80", as does the USB descriptor. User
configured it from factory 192.168.123.100 (DHCP off, web UI on :80, no auth) to 10.0.10.7 on the
lab; added as booth printer on `park-lab` on the generic driver → badge stayed green with the cover
open, because the generic driver is reachability-only by design. Found the board's status page:
**`/prt_status.htm`**, the Rongta's five rows exactly, but the reply has NO status line/headers
(node:http: "Parse Error: Expected HTTP/"; curl: 000/empty) — so a Rongta-path option would not
have worked. Per the user ("this is not rongta", "create a new printer"): the Rongta driver is
untouched; new **`printer-k200l.ts`** (`k200l`) delegates printing to the generic ESC/POS device
and reads the page over a raw socket, tolerant of both reply shapes; mapping mirrors the Rongta
(unreachable → offline, page not understood → degraded never ready, faults → degraded named, USB →
floor). Tests replay the captured headerless page (10 new, devices suite 76 green); live probe
against 10.0.10.7 → ready; bench with cover open showed "cover open, paper out, printer off-line".
Consequence for park-buzi: over USB the app never saw cover/paper state at all — the reported
"offline" is a probe failure (errno still to be pulled). Pages: new [[k200l-printer]] (names,
network setup runbook, board quirks, status page, park-buzi implication), [[rongta-printer]],
[[printer-status-monitoring]], [[printer-usb-transport]], [[wsl-dev-networking]] (parking-net
pinned to eth1 while the LAN NIC is eth0 — the source-address bug bit again), index.
## [2026-09-09] query | Field runbook: installing the devices at a site (what to know before the booth)
User: "we need a section about installing these devices in the park sites … I didn't know this
printer has initial IP 192.168.123.100 and a web interface … also dingtian relays and readers,
cashino printers — know beforehand, not struggle on site." New reference page
[[site-device-installation]], synthesised from the entity pages + memory notes: the bring list,
the site address plan (10.0.10.x convention, every device static), then per device — Dingtian
relay (factory 192.168.1.100, admin/admin, what harden() does vs the by-hand IP + UDP2 disable,
wiring I1/I2, unauthenticated CGI, session_en brick), DT-008 readers (192.168.1.99, the
QRCode_v1_6_5.exe tool: unique IP, server target, Q:/K: prefixes, 6H card format, QR+Code128
only, serial binding, re-apply after a reset), Hikvision G3H (192.168.1.64 + activation, SUB
stream mandatory, Alarm Server after assign, Vehicle target filter, close the web UI, corrupt-DB
factory reset), radar (idle level → activeLow), printers (K200L / Rongta / Cashino table; LAN over
USB; "Test" probes, print a card to verify), the on-site order of work, and the gaps still
unrecorded (Cashino/Rongta factory addresses, the reader tool screens, camera activation, where
the site record lives). Linked from [[appliance-provisioning]] and [[k200l-printer]]; indexed.
## [2026-09-09] fix | K200L status parser — a fault's "Yes" is wrapped in <FONT color=#ff0000>
First live run on park-lab (driver `k200l`, LAN, cover open) showed *degraded — unexpected status
page (missing coverOpen, paperEnd, offline)*: precisely the three Yes cells. Captured the raw page
with the cover open: the board writes `<FONT color=#ff0000>Yes</FONT>` for a fault and a bare
padded `No` otherwise; the parser accepted only tag-free cells. Fix: cell text is read with inner
tags stripped (row-anchored regex); tests pin the verbatim markup plus other shapes (devices 79).
Live after the fix: degraded "cover open, paper out, printer off-line"; closed → ready. User:
"we are good using the network with this printer." Also: park-lab Periphery was "not loaded"
after a reboot — the unit had never been enabled; `systemctl --user enable --now periphery`
recorded as a §7a gotcha on [[appliance-provisioning]]. Pages: [[k200l-printer]].