Files
parking_solution/packages/devices/src/drivers/printer-k200l.ts
T
julian 88f9c53fda
Build & push images / images (push) Successful in 2m55s
fix(devices): K200L status parser reads a fault's Yes, which the board wraps in <FONT color=#ff0000>
First live run on park-lab with the cover open reported "unexpected status page
(missing coverOpen, paperEnd, offline)" — exactly the three fault cells. The board
writes a fault as <FONT color=#ff0000>Yes</FONT> and a clear row as a bare padded
No; the parser accepted only tag-free cells. Cell text is now read with inner tags
stripped (row-anchored match). Tests pin the verbatim captured markup plus other
shapes. Live after the fix: degraded "cover open, paper out, printer off-line";
cover closed → ready.

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

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-09 12:57:58 +02:00

263 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { connect as netConnect } from "node:net";
import type {
DeviceHealth,
MonitorableDevice,
PrinterDevice,
PrinterStatus,
PrintReport,
ReceiptData,
SubscriptionCardData,
TicketData,
WindowChargeNoticeData,
} from "../interfaces.js";
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
import { stubLog } from "./common.js";
import { transportFromConfig } from "./printer-escpos.js";
import { escposDriver } from "./printer-generic.js";
// K200L 80mm ESC/POS thermal printer (Xprinter / ICS "XP-K200L" family; label:
// "THERMAL RECEIPT PRINTER Model:K200L, Interface: USB+LAN, Command Support: ESC/POS").
// Its LAN board is the "J-Speed Ethernet Interface Module" (web UI "Ethernet WebConfig
// 1.02") and calls the printer "POS-80" — over USB it enumerates as 1fc9:2016
// "Printer POS-80". Identified on the lab bench 2026-09-09: it is the park-buzi unit.
// See wiki/entities/k200l-printer.md.
//
// PRINTING is the shared ESC/POS path (delegated to the generic driver — same bytes,
// same TCP-9100 / usblp transports). What this driver ADDS is live status: the board
// serves a status page, /prt_status.htm, with the same five decoded Yes/No rows the
// Rongta board serves under /prn_stat.htm (cover open, cutter error, paper end, paper
// near end, off-line). So over TCP the operator gets a real paper/cover verdict —
// the generic driver deliberately can't (reachability only), and the Rongta driver
// can't read THIS board either: its reply carries NO status line and NO headers
// (HTTP/0.9 style — the body starts at byte 0), which Node's http client rejects
// ("Parse Error: Expected HTTP/"). Hence the raw-socket fetch below, tolerant of both
// shapes. Over USB there is no page; status degrades to the reachability floor.
//
// Board facts worth knowing (all verified on the bench): factory address
// 192.168.123.100, DHCP off; web configurator on port 80 (Information / Configuration
// / Printer Status / Printer Test); the frameset reloads its frames every 1–3 s and
// the status page every 5 s, and the embedded HTTP server is tiny — leave the browser
// closed while the monitor polls, or connects will intermittently time out.
/** The fault flags the status page reports (a subset of PrinterStatus). */
type StatusFlag = "coverOpen" | "cutterError" | "paperEnd" | "paperNearEnd" | "offline";
type StatusFlags = Partial<Record<StatusFlag, boolean>>;
/** Label text on the status page (space-normalised, lowercased) → our key. */
const STATUS_FIELDS: Record<string, StatusFlag> = {
"cover is open": "coverOpen",
"cutter error": "cutterError",
"paper end": "paperEnd",
"paper near end": "paperNearEnd",
"printer off-line": "offline",
};
const EXPECTED: readonly StatusFlag[] = ["coverOpen", "cutterError", "paperEnd", "paperNearEnd", "offline"];
const LABELS: Record<StatusFlag, string> = {
paperEnd: "paper out",
coverOpen: "cover open",
cutterError: "cutter error",
offline: "printer off-line",
paperNearEnd: "paper low",
};
/** The board's status page. */
export const K200L_STATUS_PATH = "/prt_status.htm";
/**
* GET `path` over a raw TCP socket and return whatever the board sent, verbatim,
* once it closes the connection (HTTP/1.0 semantics — the board closes after the
* reply). No HTTP parsing here: this board answers without a status line, which
* node:http refuses to parse.
*/
export function fetchRaw(host: string, port: number, path: string, timeoutMs: number): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let settled = false;
const sock = netConnect({ host, port });
const timer = setTimeout(() => {
finish(() => reject(new Error("status page timeout")));
sock.destroy();
}, timeoutMs);
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn();
};
sock.setNoDelay(true);
sock.on("connect", () => {
sock.write(`GET ${path} HTTP/1.0\r\nHost: ${host}\r\nConnection: close\r\n\r\n`);
});
sock.on("data", (c: Buffer) => chunks.push(c));
sock.on("error", (err) => finish(() => reject(err)));
sock.on("close", () => finish(() => resolve(Buffer.concat(chunks).toString("latin1"))));
});
}
/**
* Split a raw reply into its HTTP status and body. A reply that starts with a status
* line is real HTTP (status + headers, body after the blank line); anything else is
* the HTTP/0.9-style reply this board sends — the whole thing IS the body, status 200.
*/
export function parseRawReply(raw: string): { status: number; body: string } {
const m = /^HTTP\/\d\.\d\s+(\d{3})[^\r\n]*\r?\n/.exec(raw);
if (!m) return { status: 200, body: raw };
const sep = raw.search(/\r?\n\r?\n/);
const body = sep === -1 ? "" : raw.slice(sep).replace(/^\r?\n\r?\n/, "");
return { status: Number(m[1]), body };
}
/** A cell's visible text: inner tags stripped (the board wraps a "Yes" in markup the
* "No" cells don't carry), entities and padding normalised, lowercased. */
function cellText(inner: string): string {
return inner
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/gi, " ")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
/**
* Parse the status table into boolean flags. Each fault is a `<TD>label</TD>
* <TD>Yes|No</TD>` pair (the board pads the value with spaces, and may wrap a fault's
* "Yes" in its own tags — 2026-09-09, seen live as "missing coverOpen, paperEnd,
* offline" with the cover open, i.e. exactly the Yes cells). Returns only the
* recognised fields; a missing field stays undefined so the caller can detect an
* unexpected page (fail safe, not a false "ok").
*/
export function parseStatusPage(html: string): StatusFlags {
const out: StatusFlags = {};
const rowRe = /<TR[^>]*>\s*<TD[^>]*>([\s\S]*?)<\/TD>\s*<TD[^>]*>([\s\S]*?)<\/TD>/gi;
let m: RegExpExecArray | null;
while ((m = rowRe.exec(html))) {
if (m[1] === undefined || m[2] === undefined) continue;
const key = STATUS_FIELDS[cellText(m[1])];
const value = cellText(m[2]);
if (key && (value === "yes" || value === "no")) out[key] = value === "yes";
}
return out;
}
class K200lPrinter implements PrinterDevice, MonitorableDevice {
readonly driverId = "k200l";
/** The print path — the generic ESC/POS device built from the SAME config. */
readonly #print: PrinterDevice;
readonly #tcp: boolean;
readonly #host: string;
readonly #httpPort: number;
readonly #timeout: number;
constructor(config: DeviceConfig) {
this.#print = escposDriver.create(config) as PrinterDevice;
this.#tcp = transportFromConfig(config).kind === "tcp";
this.#host = config.host ? String(config.host) : "";
this.#httpPort = config.httpPort ? Number(config.httpPort) : 80;
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
}
async connect(): Promise<void> {
await this.#print.connect();
}
async disconnect(): Promise<void> {
await this.#print.disconnect();
stubLog(this.driverId, "disconnect");
}
healthCheck(): Promise<DeviceHealth> {
return this.#print.healthCheck();
}
printTicket(data: TicketData): Promise<void> {
return this.#print.printTicket(data);
}
printReport(report: PrintReport): Promise<void> {
return this.#print.printReport(report);
}
printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
return this.#print.printSubscriptionCard(data);
}
printReceipt(data: ReceiptData): Promise<void> {
return this.#print.printReceipt(data);
}
printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
return this.#print.printWindowChargeNotice(data);
}
/**
* Live operator-actionable status from the board's own page.
* - USB: no page — reachability floor only (ready/offline, never a guessed state);
* - board unreachable / timeout → offline (the same signal as a dead printer);
* - page reachable but not the status table (wrong path, index served, non-200) →
* degraded ("unexpected status page") — never "ready" off a page we didn't read;
* - any fault flag true → degraded, with the faults named; otherwise → ready.
*/
async readStatus(): Promise<PrinterStatus> {
const checkedAt = new Date().toISOString();
if (!this.#tcp) {
const h = await this.#print.healthCheck();
return { status: h.status === "ready" ? "ready" : "offline", detail: h.detail, checkedAt };
}
let raw: string;
try {
raw = await fetchRaw(this.#host, this.#httpPort, K200L_STATUS_PATH, this.#timeout);
} catch (err) {
return { status: "offline", detail: (err as Error).message, checkedAt };
}
const { status, body } = parseRawReply(raw);
if (status !== 200) {
return { status: "degraded", detail: `unexpected status page (${K200L_STATUS_PATH}: HTTP ${status})`, checkedAt };
}
const flags = parseStatusPage(body);
const missing = EXPECTED.filter((k) => flags[k] === undefined);
if (missing.length > 0) {
return {
status: "degraded",
detail: `unexpected status page (${K200L_STATUS_PATH}: missing ${missing.join(", ")})`,
checkedAt,
};
}
const faults = EXPECTED.filter((k) => flags[k] === true);
return {
status: faults.length > 0 ? "degraded" : "ready",
...flags,
detail: faults.length > 0 ? faults.map((f) => LABELS[f]).join(", ") : undefined,
checkedAt,
};
}
}
const httpPortField: ConfigField = {
key: "httpPort",
label: "Status web port",
type: "port",
required: false,
default: 80,
help: "The board's web configurator port; the status page /prt_status.htm is read from it for live monitoring (default 80). TCP only.",
};
/** The generic driver's fields (transport, device path, host, port, role, rank,
* timeout) plus the status-page port, placed right after the print port. */
function k200lFields(): ConfigField[] {
const out = [...escposDriver.configFields];
const i = out.findIndex((f) => f.key === "port");
out.splice(i === -1 ? out.length : i + 1, 0, httpPortField);
return out;
}
export const k200lDriver: PrinterDriver = {
id: "k200l",
category: "printer",
label: "K200L 80mm thermal printer (Xprinter / ICS, USB+LAN)",
description:
"Xprinter / ICS K200L (XP-K200L) 80mm ESC/POS printer; its LAN board reports itself as 'POS-80' (web configurator at 192.168.123.100:80 from the factory, DHCP off). Prints over raw TCP (port 9100) OR local USB /dev/usb/lp0 — the same bytes as the generic ESC/POS driver. Over TCP the board's /prt_status.htm page gives live paper / cover / cutter / off-line status; over USB there is no page, so it is monitored by reachability only. No auth on the print socket or the web UI — isolate the VLAN.",
transports: ["tcp-ip", "usb"],
configFields: k200lFields(),
create: (c) => new K200lPrinter(c),
};