feat(devices): K200L printer driver — live cover/paper status from the J-Speed LAN board
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
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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({});
|
||||
});
|
||||
});
|
||||
|
||||
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,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<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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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). 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[^>]*>([^<]*?)<\/TD>\s*<TD[^>]*>([^<]*?)<\/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<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,
|
||||
dahuaDriver,
|
||||
rongtaDriver,
|
||||
k200lDriver,
|
||||
escposDriver,
|
||||
} from "./drivers/index.js";
|
||||
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
|
||||
|
||||
Reference in New Issue
Block a user