88f9c53fda
Build & push images / images (push) Successful in 2m55s
First live run on park-lab with the cover open reported "unexpected status page (missing coverOpen, paperEnd, offline)" — exactly the three fault cells. The board writes a fault as <FONT color=#ff0000>Yes</FONT> and a clear row as a bare padded No; the parser accepted only tag-free cells. Cell text is now read with inner tags stripped (row-anchored match). Tests pin the verbatim captured markup plus other shapes. Live after the fix: degraded "cover open, paper out, printer off-line"; cover closed → ready. Wiki: the markup on the K200L page; Periphery "not loaded after reboot" (unit never enabled → `systemctl --user enable --now periphery`) as a §7a gotcha in the provisioning runbook; log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
263 lines
11 KiB
TypeScript
263 lines
11 KiB
TypeScript
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),
|
||
};
|