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
+3 -3
View File
@@ -5,7 +5,7 @@ import { registry } from "../registry.js";
import { dingtianDriver } from "./access-dingtian.js";
import { stubAccessDriver } from "./access-stub.js";
import { dahuaDriver, hikvisionDriver } from "./camera.js";
import { cashinoDriver } from "./printer-cashino.js";
import { escposDriver } from "./printer-generic.js";
import { rongtaDriver } from "./printer-rongta.js";
import { dingtianQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
@@ -23,7 +23,7 @@ export function registerBuiltinDrivers(): void {
registry.register(hikvisionDriver);
registry.register(dahuaDriver);
registry.register(rongtaDriver);
registry.register(cashinoDriver);
registry.register(escposDriver);
}
export {
@@ -35,5 +35,5 @@ export {
hikvisionDriver,
dahuaDriver,
rongtaDriver,
cashinoDriver,
escposDriver,
};
@@ -2,19 +2,20 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { cashinoDriver } from "./printer-cashino.js";
import { escposDriver } from "./printer-generic.js";
import { renderTicket } from "./printer-escpos.js";
// End-to-end transport routing through the real driver: a USB-configured Cashino must
// End-to-end transport routing through the real driver: a USB-configured generic
// ESC/POS printer (Cashino / ICS XP-K200L family) must
// resolve to the char-device transport and write the SAME ESC/POS bytes the TCP path
// would. (The TCP path is exercised by the routing/escpos suites and on hardware.)
describe("cashinoDriver — USB transport", () => {
describe("escposDriver (generic ESC/POS) — USB transport", () => {
let dir: string;
let devicePath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "cashino-usb-"));
dir = mkdtempSync(join(tmpdir(), "escpos-usb-"));
devicePath = join(dir, "lp0");
// Stand in for an enumerated usblp node (the kernel creates it; we only open it).
writeFileSync(devicePath, "");
@@ -24,7 +25,7 @@ describe("cashinoDriver — USB transport", () => {
});
it("prints a ticket to the configured USB device path", async () => {
const printer = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
const printer = escposDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
const data = { ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" };
await printer.printTicket(data);
const written = readFileSync(devicePath);
@@ -32,10 +33,10 @@ describe("cashinoDriver — USB transport", () => {
});
it("healthCheck reports ready when the node exists, offline when it doesn't", async () => {
const present = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
const present = escposDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
expect((await present.healthCheck()).status).toBe("ready");
// An absent device node (printer unplugged / not enumerated) → offline.
const absent = cashinoDriver.create({
const absent = escposDriver.create({
transport: "usb",
devicePath: join(dir, "absent-lp0"),
timeoutMs: 1000,
@@ -44,7 +45,7 @@ describe("cashinoDriver — USB transport", () => {
});
it("advertises both transports", () => {
expect(cashinoDriver.transports).toContain("usb");
expect(cashinoDriver.transports).toContain("tcp-ip");
expect(escposDriver.transports).toContain("usb");
expect(escposDriver.transports).toContain("tcp-ip");
});
});
@@ -23,25 +23,26 @@ import {
type Transport,
} from "./printer-escpos.js";
// Cashino 80mm thermal printer driver (network OR USB). The Cashino is an ESC/POS
// clone: it PRINTS identically to the Rongta (same byte stream — see
// ./printer-escpos.ts), so tickets, reports and subscription cards render the same,
// over either transport. What it does NOT have is the Rongta board's decoded status
// web page (/prn_stat.htm). It cannot report paper-out / cover-open / cutter faults
// in a form we trust.
// 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 is the natural USB candidate — reachability-only, no status page to lose.
// 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 the Cashino doesn't
// (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
@@ -49,8 +50,8 @@ import {
// (entry-dispenser / booth-receipt + failoverRank); the server owns selection.
// See wiki/concepts/printer-status-monitoring.md and printer-roles-failover.md.
class CashinoPrinter implements PrinterDevice {
readonly driverId = "cashino";
class GenericEscposPrinter implements PrinterDevice {
readonly driverId = "escpos";
readonly #transport: Transport;
readonly #timeout: number;
@@ -69,7 +70,7 @@ class CashinoPrinter implements PrinterDevice {
/**
* Reachability only — a connect probe (TCP) or char-device open probe (USB) of
* the print path. The Cashino has no trustworthy status protocol, so this is the
* 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.
@@ -140,12 +141,16 @@ const rankField: ConfigField = {
help: "Higher = tried first within the same role. The booth printer also backs up the entry dispenser.",
};
export const cashinoDriver: PrinterDriver = {
id: "cashino",
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: "Cashino 80mm thermal printer",
label: "Generic ESC/POS 80mm printer (Cashino, ICS/Xprinter…)",
description:
"Cashino 80mm thermal printer (ESC/POS over raw TCP port 9100, OR local USB /dev/usb/lp0). 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.",
"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,
@@ -167,5 +172,5 @@ export const cashinoDriver: PrinterDriver = {
default: 3000,
},
],
create: (c) => new CashinoPrinter(c),
create: (c) => new GenericEscposPrinter(c),
};
+1 -1
View File
@@ -18,7 +18,7 @@ export {
hikvisionDriver,
dahuaDriver,
rongtaDriver,
cashinoDriver,
escposDriver,
} from "./drivers/index.js";
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
// Albanian human date/time for printed slips (receipts, tickets, shift Z-report),
+10 -2
View File
@@ -97,6 +97,14 @@ export function isDiscoverable(
return typeof (driver as Partial<DiscoverableDriver>).discover === "function";
}
/** Renamed driver ids: what a STORED config may still say → the current id. Kept
* tiny + permanent so old DB rows, exports, and backups resolve across renames
* (migration 0023 rewrites live rows, but a restored old backup may reintroduce
* the historical id). */
const DRIVER_ID_ALIASES: Record<string, string> = {
cashino: "escpos", // renamed 2026-07-06 — it was always the generic ESC/POS driver
};
class DeviceRegistry {
readonly #drivers = new Map<string, DeviceDriver>();
@@ -114,12 +122,12 @@ class DeviceRegistry {
}
get(id: string): DeviceDriver | undefined {
return this.#drivers.get(id);
return this.#drivers.get(DRIVER_ID_ALIASES[id] ?? id);
}
/** Validate config against a driver's declared fields and build the adapter. */
create(id: string, config: DeviceConfig): Device {
const driver = this.#drivers.get(id);
const driver = this.get(id);
if (!driver) throw new Error(`unknown driver: ${id}`);
for (const field of driver.configFields) {
if (field.required && config[field.key] === undefined) {