552d87d75b
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
180 lines
6.9 KiB
TypeScript
180 lines
6.9 KiB
TypeScript
import type {
|
|
DeviceHealth,
|
|
PrinterDevice,
|
|
PrintReport,
|
|
ReceiptData,
|
|
SubscriptionCardData,
|
|
TicketData,
|
|
WindowChargeNoticeData,
|
|
} from "../interfaces.js";
|
|
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
|
import { hostField, portField, stubLog } from "./common.js";
|
|
import {
|
|
devicePathField,
|
|
probeTo,
|
|
renderReceipt,
|
|
renderReport,
|
|
renderSubscriptionCard,
|
|
renderTicket,
|
|
renderWindowChargeNotice,
|
|
sendTo,
|
|
transportField,
|
|
transportFromConfig,
|
|
type Transport,
|
|
} from "./printer-escpos.js";
|
|
|
|
// GENERIC ESC/POS 80mm thermal printer driver (network OR USB) — any clone that
|
|
// PRINTS the shared ESC/POS byte stream (see ./printer-escpos.ts) but serves no
|
|
// Rongta-style decoded status page (/prn_stat.htm). Verified fits: Cashino (the
|
|
// first unit we drove — the driver carried its name until 2026-07-06), ICS/Xprinter
|
|
// XP-K200L. Tickets, reports and subscription cards render identically to the
|
|
// Rongta, over either transport; what these clones can NOT do is report paper-out /
|
|
// cover-open / cutter faults in a form we trust.
|
|
//
|
|
// TRANSPORT: a single `config.transport` ("tcp-ip" | "usb") picks the wire; the
|
|
// driver resolves it ONCE into a Transport and every print/probe stays transport-
|
|
// blind (see transportFromConfig/sendTo/probeTo). USB writes the same bytes to a
|
|
// 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
|
|
// booth footer shows this printer as "ready" when it's reachable and "offline"
|
|
// when it isn't, and never a wrong paper/cover verdict it cannot actually sense.
|
|
// (Reusing the Rongta driver made it scrape a status page these clones don't
|
|
// serve, producing the bogus "degraded" feedback this driver fixes.)
|
|
//
|
|
// No auth on the print socket — like the other field devices it lives on the
|
|
// isolated device VLAN. Roles + failover work exactly as for the Rongta
|
|
// (entry-dispenser / booth-receipt + failoverRank); the server owns selection.
|
|
// See wiki/concepts/printer-status-monitoring.md and printer-roles-failover.md.
|
|
|
|
class GenericEscposPrinter implements PrinterDevice {
|
|
readonly driverId = "escpos";
|
|
readonly #transport: Transport;
|
|
readonly #timeout: number;
|
|
|
|
constructor(config: DeviceConfig) {
|
|
this.#transport = transportFromConfig(config);
|
|
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
|
|
}
|
|
|
|
async connect(): Promise<void> {
|
|
await this.healthCheck();
|
|
}
|
|
|
|
async disconnect(): Promise<void> {
|
|
stubLog(this.driverId, "disconnect");
|
|
}
|
|
|
|
/**
|
|
* Reachability only — a connect probe (TCP) or char-device open probe (USB) of
|
|
* the print path. These clones have no trustworthy status protocol, so this is the
|
|
* floor and the ceiling of what we report: reachable → ready, unreachable →
|
|
* offline. Deliberately NO readStatus(): the monitor uses this for the
|
|
* traffic-light, never a guessed paper/cover state.
|
|
*/
|
|
async healthCheck(): Promise<DeviceHealth> {
|
|
try {
|
|
await probeTo(this.#transport, this.#timeout);
|
|
return { status: "ready" };
|
|
} catch (err) {
|
|
return { status: "offline", detail: (err as Error).message };
|
|
}
|
|
}
|
|
|
|
async printTicket(data: TicketData): Promise<void> {
|
|
await sendTo(this.#transport, renderTicket(data), this.#timeout);
|
|
stubLog(this.driverId, `printed ticket ${data.ticketId}`);
|
|
}
|
|
|
|
async printReport(report: PrintReport): Promise<void> {
|
|
await sendTo(this.#transport, renderReport(report), this.#timeout);
|
|
stubLog(
|
|
this.driverId,
|
|
`printed report "${report.title}" (${report.lines.length} lines)`,
|
|
);
|
|
}
|
|
|
|
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
|
|
await sendTo(this.#transport, renderSubscriptionCard(data), this.#timeout);
|
|
stubLog(this.driverId, `printed subscription card ${data.code}`);
|
|
}
|
|
|
|
async printReceipt(data: ReceiptData): Promise<void> {
|
|
await sendTo(this.#transport, renderReceipt(data), this.#timeout);
|
|
stubLog(
|
|
this.driverId,
|
|
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
|
|
);
|
|
}
|
|
|
|
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
|
|
await sendTo(this.#transport, renderWindowChargeNotice(data), this.#timeout);
|
|
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
|
|
}
|
|
}
|
|
|
|
const roleField: ConfigField = {
|
|
key: "role",
|
|
label: "Role",
|
|
type: "select",
|
|
required: true,
|
|
default: "entry-dispenser",
|
|
options: [
|
|
{
|
|
value: "entry-dispenser",
|
|
label: "Entry dispenser (outside / at the lane)",
|
|
},
|
|
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" },
|
|
{ value: "wash-desk", label: "Wash desk printer (Car Wash till slips)" },
|
|
],
|
|
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline. The wash desk's Z-reports and vouchers print on the wash desk printer, falling back to the booth printer.",
|
|
};
|
|
|
|
const rankField: ConfigField = {
|
|
key: "failoverRank",
|
|
label: "Failover rank",
|
|
type: "number",
|
|
required: false,
|
|
default: 0,
|
|
help: "Higher = tried first within the same role. The booth printer also backs up the entry dispenser.",
|
|
};
|
|
|
|
export const escposDriver: PrinterDriver = {
|
|
// Renamed from id "cashino" (the first clone we drove) on 2026-07-06 — the vendor
|
|
// name was misleading in the setup UI once other clones (ICS/Xprinter XP-K200L)
|
|
// used it. Stored configs with driverId "cashino" still resolve via the registry
|
|
// alias + are rewritten by migration 0023.
|
|
id: "escpos",
|
|
category: "printer",
|
|
label: "Generic ESC/POS 80mm printer (Cashino, ICS/Xprinter…)",
|
|
description:
|
|
"Generic ESC/POS 80mm thermal printer over raw TCP (port 9100) OR local USB /dev/usb/lp0 — Cashino, ICS/Xprinter XP-K200L, and similar clones. Prints like the Rongta but has no status page — monitored by reachability only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
|
|
transports: ["tcp-ip", "usb"],
|
|
configFields: [
|
|
transportField,
|
|
devicePathField,
|
|
// host/port are TCP-only; not required because a USB printer needs neither.
|
|
{ ...hostField, required: false, help: `${hostField.help} Leave blank for a USB printer.` },
|
|
{
|
|
...portField(9100),
|
|
required: false,
|
|
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100). TCP only.",
|
|
},
|
|
roleField,
|
|
rankField,
|
|
{
|
|
key: "timeoutMs",
|
|
label: "Timeout (ms)",
|
|
type: "number",
|
|
required: false,
|
|
default: 3000,
|
|
},
|
|
],
|
|
create: (c) => new GenericEscposPrinter(c),
|
|
};
|