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 }; } /** 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 `label * Yes|No` 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 = /]*>\s*]*>([\s\S]*?)<\/TD>\s*]*>([\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 { 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), };