Compare commits
10 Commits
dev
...
fe3b12a60d
| Author | SHA1 | Date | |
|---|---|---|---|
| fe3b12a60d | |||
| 88f9c53fda | |||
| e8cb057082 | |||
| 4a02e5fed3 | |||
| 552d87d75b | |||
| 7e21cf057e | |||
| 4fd175e0e4 | |||
| 535244209a | |||
| 0845e87ddd | |||
| 2d9bb15d4c |
+45
-3
@@ -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
|
||||||
|
|||||||
@@ -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"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 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(/ /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),
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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) | — |
|
||||||
|
|
||||||
|
|||||||
@@ -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]].
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]]
|
||||||
@@ -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.**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|||||||
@@ -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]]
|
||||||
@@ -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]]
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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]].
|
||||||
|
|||||||
Reference in New Issue
Block a user