refactor(devices): rename driver "cashino" → "escpos" (generic ESC/POS)

The reachability-only clone driver carried its first unit's vendor name,
which read as misleading in the setup UI once other clones (ICS/Xprinter
XP-K200L, verified 2026-07-06: no /prn_stat.htm) used it. It was always
the generic ESC/POS driver — now named so:

- printer-cashino.ts → printer-generic.ts; GenericEscposPrinter;
  id "escpos", label "Generic ESC/POS 80mm printer (Cashino,
  ICS/Xprinter…)".
- Migration 0023 rewrites stored devices.driver_id rows.
- The registry keeps a PERMANENT cashino→escpos alias so restored
  pre-rename backups still resolve instead of "unknown driver".

Prose mentions of the Cashino as physical hardware stay — it's a real,
verified-fit printer; only the driver identity stopped being vendor-named.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-07-06 12:36:14 +02:00
parent 81bc2e357c
commit fcea992e1e
7 changed files with 58 additions and 31 deletions
@@ -0,0 +1,176 @@
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<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)" },
],
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.",
};
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),
};