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. // // 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 { await this.healthCheck(); } async disconnect(): Promise { 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 { 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 { await sendTo(this.#transport, renderTicket(data), this.#timeout); stubLog(this.driverId, `printed ticket ${data.ticketId}`); } async printReport(report: PrintReport): Promise { await sendTo(this.#transport, renderReport(report), this.#timeout); stubLog( this.driverId, `printed report "${report.title}" (${report.lines.length} lines)`, ); } async printSubscriptionCard(data: SubscriptionCardData): Promise { await sendTo(this.#transport, renderSubscriptionCard(data), this.#timeout); stubLog(this.driverId, `printed subscription card ${data.code}`); } async printReceipt(data: ReceiptData): Promise { await sendTo(this.#transport, renderReceipt(data), this.#timeout); stubLog( this.driverId, `printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`, ); } async printWindowChargeNotice(data: WindowChargeNoticeData): Promise { 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), };