From 552d87d75ba4a8e1831e63df1748b52d01da896b Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Wed, 9 Sep 2026 12:34:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(devices):=20K200L=20printer=20driver=20?= =?UTF-8?q?=E2=80=94=20live=20cover/paper=20status=20from=20the=20J-Speed?= =?UTF-8?q?=20LAN=20board?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The park-buzi printer is a K200L (Xprinter/ICS XP-K200L; its LAN board and USB descriptor call it "POS-80"). Its board serves the Rongta's five-row status table under /prt_status.htm — but the reply carries no HTTP status line or headers, which node:http rejects, so the Rongta driver could never read it and the unit was filed in July as "no status page → generic driver" (reachability only). New `k200l` driver (printer-k200l.ts): prints through the generic ESC/POS device (same bytes, TCP 9100 or usblp) and reads the page over a raw socket, tolerant of both the headerless and a proper HTTP reply. Mapping mirrors the Rongta: board unreachable → offline; page not understood → degraded, never ready; any fault → degraded naming it; USB → reachability floor. The Rongta driver is untouched. Tests replay the captured headerless page (devices suite 76). Live against the lab unit: ready; with the cover open the board reports cover open, paper out, off-line. Wiki: new k200l-printer entity (names, network setup from factory 192.168.123.100, board quirks, status page, what it means for park-buzi — over USB the app never saw cover/paper state at all), cross-links on the Rongta, status-monitoring, USB-transport and WSL-networking pages (parking-net pinned to eth1 while the LAN NIC is eth0), index. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU --- packages/devices/src/drivers/index.ts | 3 + .../devices/src/drivers/printer-generic.ts | 2 + .../devices/src/drivers/printer-k200l.test.ts | 210 +++++++++++++++ packages/devices/src/drivers/printer-k200l.ts | 250 ++++++++++++++++++ packages/devices/src/index.ts | 1 + wiki/concepts/printer-status-monitoring.md | 16 +- wiki/concepts/printer-usb-transport.md | 48 +++- wiki/concepts/wsl-dev-networking.md | 11 +- wiki/entities/k200l-printer.md | 101 +++++++ wiki/entities/rongta-printer.md | 6 +- wiki/index.md | 2 + 11 files changed, 645 insertions(+), 5 deletions(-) create mode 100644 packages/devices/src/drivers/printer-k200l.test.ts create mode 100644 packages/devices/src/drivers/printer-k200l.ts create mode 100644 wiki/entities/k200l-printer.md diff --git a/packages/devices/src/drivers/index.ts b/packages/devices/src/drivers/index.ts index ba31cf1..26a2ff2 100644 --- a/packages/devices/src/drivers/index.ts +++ b/packages/devices/src/drivers/index.ts @@ -6,6 +6,7 @@ import { dingtianDriver } from "./access-dingtian.js"; import { stubAccessDriver } from "./access-stub.js"; import { dahuaDriver, hikvisionDriver } from "./camera.js"; import { escposDriver } from "./printer-generic.js"; +import { k200lDriver } from "./printer-k200l.js"; import { rongtaDriver } from "./printer-rongta.js"; import { dingtianQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js"; @@ -23,6 +24,7 @@ export function registerBuiltinDrivers(): void { registry.register(hikvisionDriver); registry.register(dahuaDriver); registry.register(rongtaDriver); + registry.register(k200lDriver); registry.register(escposDriver); } @@ -35,5 +37,6 @@ export { hikvisionDriver, dahuaDriver, rongtaDriver, + k200lDriver, escposDriver, }; diff --git a/packages/devices/src/drivers/printer-generic.ts b/packages/devices/src/drivers/printer-generic.ts index 8a5d4bc..a4cc712 100644 --- a/packages/devices/src/drivers/printer-generic.ts +++ b/packages/devices/src/drivers/printer-generic.ts @@ -37,6 +37,8 @@ import { // 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. // +// (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 // (no readStatus). The device monitor then falls back to the generic // `healthCheck()` — a plain TCP reachability PING of the print socket. So the diff --git a/packages/devices/src/drivers/printer-k200l.test.ts b/packages/devices/src/drivers/printer-k200l.test.ts new file mode 100644 index 0000000..4476b00 --- /dev/null +++ b/packages/devices/src/drivers/printer-k200l.test.ts @@ -0,0 +1,210 @@ +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> = {}): string { + const v = (k: string) => `${flags[k] ?? "No"} `; + return [ + '', + "Printer Status", + "", + '
', + "", + ``, + ``, + ``, + ``, + `
Cover Is Open${v("cover")}
Cutter Error${v("cutter")}
Paper End${v("paperEnd")}
Paper Near End${v("nearEnd")}
Printer Off-Line${v("offline")}
`, + '
', + ].join("\r\n"); +} + +const INDEX = + "Ethernet port configuration
Mac Address00-D8-23-5C-58-8C
"; + +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("x"); + expect(r.status).toBe(200); + expect(r.body).toBe("x"); + }); + 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\nnope"); + expect(r.status).toBe(404); + expect(r.body).toBe("nope"); + }); +}); + +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({}); + }); +}); + +describe("k200lDriver.readStatus over TCP", () => { + let server: Server | undefined; + const sockets = new Set(); + + /** 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 { + 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((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((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((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((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"); + }); +}); diff --git a/packages/devices/src/drivers/printer-k200l.ts b/packages/devices/src/drivers/printer-k200l.ts new file mode 100644 index 0000000..a1998aa --- /dev/null +++ b/packages/devices/src/drivers/printer-k200l.ts @@ -0,0 +1,250 @@ +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>; + +/** Label text on the status page (space-normalised, lowercased) → our key. */ +const STATUS_FIELDS: Record = { + "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 = { + 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 { + 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 }; +} + +/** + * Parse the status table into boolean flags. Each fault is a `label + * Yes|No` pair (the board pads the value with spaces). 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 = /]*>([^<]*?)<\/TD>\s*]*>([^<]*?)<\/TD>/gi; + let m: RegExpExecArray | null; + while ((m = rowRe.exec(html))) { + if (m[1] === undefined || m[2] === undefined) continue; + const label = m[1].replace(/ /gi, " ").replace(/\s+/g, " ").trim().toLowerCase(); + const value = m[2].replace(/ /gi, " ").trim().toLowerCase(); + const key = STATUS_FIELDS[label]; + 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 { + await this.#print.connect(); + } + + async disconnect(): Promise { + await this.#print.disconnect(); + stubLog(this.driverId, "disconnect"); + } + + healthCheck(): Promise { + return this.#print.healthCheck(); + } + + printTicket(data: TicketData): Promise { + return this.#print.printTicket(data); + } + + printReport(report: PrintReport): Promise { + return this.#print.printReport(report); + } + + printSubscriptionCard(data: SubscriptionCardData): Promise { + return this.#print.printSubscriptionCard(data); + } + + printReceipt(data: ReceiptData): Promise { + return this.#print.printReceipt(data); + } + + printWindowChargeNotice(data: WindowChargeNoticeData): Promise { + 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 { + 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), +}; diff --git a/packages/devices/src/index.ts b/packages/devices/src/index.ts index edf73be..405c772 100644 --- a/packages/devices/src/index.ts +++ b/packages/devices/src/index.ts @@ -18,6 +18,7 @@ export { hikvisionDriver, dahuaDriver, rongtaDriver, + k200lDriver, escposDriver, } from "./drivers/index.js"; export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js"; diff --git a/wiki/concepts/printer-status-monitoring.md b/wiki/concepts/printer-status-monitoring.md index c416fa2..96351e0 100644 --- a/wiki/concepts/printer-status-monitoring.md +++ b/wiki/concepts/printer-status-monitoring.md @@ -2,7 +2,7 @@ type: concept tags: [parking, printer, device, monitoring, reliability] sources: [] -updated: 2026-06-14 +updated: 2026-09-09 --- # 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. - 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]]). + +## 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]]. diff --git a/wiki/concepts/printer-usb-transport.md b/wiki/concepts/printer-usb-transport.md index 5d079c4..922f35d 100644 --- a/wiki/concepts/printer-usb-transport.md +++ b/wiki/concepts/printer-usb-transport.md @@ -2,7 +2,7 @@ type: concept tags: [parking, device, printer, transport, usb, escpos, provisioning] sources: [] -updated: 2026-08-30 +updated: 2026-09-09 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 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` > 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` @@ -189,6 +194,44 @@ itself is redone. > = `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//`, 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: ```bash @@ -212,7 +255,8 @@ whether the Bus/Device number changes. 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. -**Printer identity — IDENTIFIED 2026-09-09.** The failing unit is on the dev bench: USB +**Printer identity — IDENTIFIED 2026-09-09.** The failing unit is on the dev bench: a **K200L** +(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 diff --git a/wiki/concepts/wsl-dev-networking.md b/wiki/concepts/wsl-dev-networking.md index 4b8ae79..ac6ef17 100644 --- a/wiki/concepts/wsl-dev-networking.md +++ b/wiki/concepts/wsl-dev-networking.md @@ -2,7 +2,7 @@ type: reference tags: [parking, dev-environment, networking, wsl, troubleshooting] sources: [] -updated: 2026-06-15 +updated: 2026-09-09 --- # 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]] 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 Production is a **dedicated hardened Linux appliance** ([[disk-os-hardening]]), so the WSL story diff --git a/wiki/entities/k200l-printer.md b/wiki/entities/k200l-printer.md new file mode 100644 index 0000000..216aa11 --- /dev/null +++ b/wiki/entities/k200l-printer.md @@ -0,0 +1,101 @@ +--- +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 +``` + +**Same rows, same `labelYes|No` 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]] diff --git a/wiki/entities/rongta-printer.md b/wiki/entities/rongta-printer.md index 14be49a..8311d64 100644 --- a/wiki/entities/rongta-printer.md +++ b/wiki/entities/rongta-printer.md @@ -2,7 +2,7 @@ type: entity tags: [parking, hardware, printer, device] sources: [] -updated: 2026-06-19 +updated: 2026-09-09 --- # 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 the entry ticket if the outside dispenser is offline. This unit is a **Rongta** (`rongta` 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 diff --git a/wiki/index.md b/wiki/index.md index c8cc21d..35b34d1 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -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`). - [[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. +- [[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). ## 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-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. +- [[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-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.