fix(devices): Cashino printer — ping-only driver (no false status) + Albanian role wording
The Cashino 80mm printer reported wrong status: it ran on the `rongta`
driver, whose readStatus() scrapes the Rongta board's /prn_stat.htm status
page — which the Cashino does not serve — yielding a bogus degraded/page-
error verdict while the printer was online and printing fine. Root cause:
the Cashino is an ESC/POS PRINT clone with no trustworthy STATUS mechanism.
Fix: extract the shared ESC/POS rendering + transport (renderTicket/
renderReport/renderSubscriptionCard/sendRaw/probe + CP852 map + code128/
qrCode) from printer-rongta into drivers/printer-escpos.ts, and add a
dedicated `cashino` driver that reuses that print path but is deliberately
NOT MonitorableDevice (no readStatus). isMonitorable() is then false, so the
device monitor falls back to healthCheck() — a plain TCP reachability ping:
reachable -> ready, unreachable -> offline, never a guessed paper/cover
state it cannot sense. Rongta driver unchanged (still scrapes its page,
still monitorable). Register + re-export cashinoDriver.
Verified at runtime (cashino registered, isMonitorable=false, no readStatus,
healthCheck->offline on unreachable) and live: /api/devices/status shows both
printers ready (lane via ping, booth via page). The live entry-dispenser at
10.0.10.9 was switched rongta->cashino in the operator DB (backed up).
Also fix the Albanian device-role chip wording, which read wrong as a
"{category} {role}" label: access mixed "i përzier" -> "hyrje/dalje"
(it means a barrier spanning both directions); printer lane "korsia" ->
"në korsi"; booth "kabina" -> "në kabinë". English tidied to match
(mixed->entry/exit, lane->at lane, booth->at booth).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -44,9 +44,9 @@ export const en: Catalog = {
|
|||||||
entry: "entry",
|
entry: "entry",
|
||||||
exit: "exit",
|
exit: "exit",
|
||||||
both: "entry/exit",
|
both: "entry/exit",
|
||||||
mixed: "mixed",
|
mixed: "entry/exit",
|
||||||
lane: "lane",
|
lane: "at lane",
|
||||||
booth: "booth",
|
booth: "at booth",
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
ready: "ready",
|
ready: "ready",
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ export const sq = {
|
|||||||
entry: "hyrje",
|
entry: "hyrje",
|
||||||
exit: "dalje",
|
exit: "dalje",
|
||||||
both: "hyrje/dalje",
|
both: "hyrje/dalje",
|
||||||
mixed: "i përzier",
|
mixed: "hyrje/dalje",
|
||||||
lane: "korsia",
|
lane: "në korsi",
|
||||||
booth: "kabina",
|
booth: "në kabinë",
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
ready: "gati",
|
ready: "gati",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { registry } from "../registry.js";
|
|||||||
import { dingtianDriver } from "./access-dingtian.js";
|
import { dingtianDriver } from "./access-dingtian.js";
|
||||||
import { stubAccessDriver } from "./access-stub.js";
|
import { stubAccessDriver } from "./access-stub.js";
|
||||||
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
||||||
|
import { cashinoDriver } from "./printer-cashino.js";
|
||||||
import { rongtaDriver } from "./printer-rongta.js";
|
import { rongtaDriver } from "./printer-rongta.js";
|
||||||
import { geeQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
import { geeQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export function registerBuiltinDrivers(): void {
|
|||||||
registry.register(hikvisionDriver);
|
registry.register(hikvisionDriver);
|
||||||
registry.register(dahuaDriver);
|
registry.register(dahuaDriver);
|
||||||
registry.register(rongtaDriver);
|
registry.register(rongtaDriver);
|
||||||
|
registry.register(cashinoDriver);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -33,4 +35,5 @@ export {
|
|||||||
hikvisionDriver,
|
hikvisionDriver,
|
||||||
dahuaDriver,
|
dahuaDriver,
|
||||||
rongtaDriver,
|
rongtaDriver,
|
||||||
|
cashinoDriver,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import type {
|
||||||
|
DeviceHealth,
|
||||||
|
PrinterDevice,
|
||||||
|
PrintReport,
|
||||||
|
SubscriptionCardData,
|
||||||
|
TicketData,
|
||||||
|
} from "../interfaces.js";
|
||||||
|
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
||||||
|
import { hostField, portField, stubLog } from "./common.js";
|
||||||
|
import {
|
||||||
|
probe,
|
||||||
|
renderReport,
|
||||||
|
renderSubscriptionCard,
|
||||||
|
renderTicket,
|
||||||
|
sendRaw,
|
||||||
|
} from "./printer-escpos.js";
|
||||||
|
|
||||||
|
// Cashino 80mm network thermal printer driver. 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. 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.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// 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 CashinoPrinter implements PrinterDevice {
|
||||||
|
readonly driverId = "cashino";
|
||||||
|
readonly #host: string;
|
||||||
|
readonly #port: number;
|
||||||
|
readonly #timeout: number;
|
||||||
|
|
||||||
|
constructor(config: DeviceConfig) {
|
||||||
|
this.#host = String(config.host);
|
||||||
|
this.#port = config.port ? Number(config.port) : 9100;
|
||||||
|
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 TCP connect probe of the raw print socket. The Cashino
|
||||||
|
* has 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 probe(this.#host, this.#port, this.#timeout);
|
||||||
|
return { status: "ready" };
|
||||||
|
} catch (err) {
|
||||||
|
return { status: "offline", detail: (err as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async printTicket(data: TicketData): Promise<void> {
|
||||||
|
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout);
|
||||||
|
stubLog(this.driverId, `printed ticket ${data.ticketId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async printReport(report: PrintReport): Promise<void> {
|
||||||
|
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout);
|
||||||
|
stubLog(
|
||||||
|
this.driverId,
|
||||||
|
`printed report "${report.title}" (${report.lines.length} lines)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
|
||||||
|
await sendRaw(
|
||||||
|
this.#host,
|
||||||
|
this.#port,
|
||||||
|
renderSubscriptionCard(data),
|
||||||
|
this.#timeout,
|
||||||
|
);
|
||||||
|
stubLog(this.driverId, `printed subscription card ${data.code}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 cashinoDriver: PrinterDriver = {
|
||||||
|
id: "cashino",
|
||||||
|
category: "printer",
|
||||||
|
label: "Cashino 80mm thermal printer",
|
||||||
|
description:
|
||||||
|
"Cashino 80mm thermal printer (ESC/POS over raw TCP, port 9100). Prints like the Rongta but has no status page — monitored by reachability ping only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
|
||||||
|
transports: ["tcp-ip"],
|
||||||
|
configFields: [
|
||||||
|
hostField,
|
||||||
|
{
|
||||||
|
...portField(9100),
|
||||||
|
required: false,
|
||||||
|
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100).",
|
||||||
|
},
|
||||||
|
roleField,
|
||||||
|
rankField,
|
||||||
|
{
|
||||||
|
key: "timeoutMs",
|
||||||
|
label: "Timeout (ms)",
|
||||||
|
type: "number",
|
||||||
|
required: false,
|
||||||
|
default: 3000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
create: (c) => new CashinoPrinter(c),
|
||||||
|
};
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { Socket } from "node:net";
|
||||||
|
import type {
|
||||||
|
PrintReport,
|
||||||
|
SubscriptionCardData,
|
||||||
|
TicketData,
|
||||||
|
} from "../interfaces.js";
|
||||||
|
|
||||||
|
// Shared ESC/POS rendering + raw-TCP transport for 80mm thermal printers.
|
||||||
|
// Rongta RP-series, Cashino, and the many OEM clones all speak ESC/POS over a
|
||||||
|
// raw TCP socket on port 9100 (the JetDirect/RAW convention) with no auth on the
|
||||||
|
// print socket — they live on the isolated device VLAN. The BYTE STREAM is
|
||||||
|
// identical across these clones; what differs is live status reporting (the
|
||||||
|
// Rongta board serves a decoded status page; the Cashino does not), so status
|
||||||
|
// stays in each driver while the rendering/transport live here.
|
||||||
|
// See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md.
|
||||||
|
|
||||||
|
// --- ESC/POS command bytes ----------------------------------------------------
|
||||||
|
const ESC = 0x1b;
|
||||||
|
const GS = 0x1d;
|
||||||
|
const LF = 0x0a;
|
||||||
|
|
||||||
|
const INIT = Buffer.from([ESC, 0x40]); // ESC @ — reset to power-on defaults
|
||||||
|
const ALIGN_CENTER = Buffer.from([ESC, 0x61, 0x01]); // ESC a 1
|
||||||
|
const ALIGN_LEFT = Buffer.from([ESC, 0x61, 0x00]); // ESC a 0
|
||||||
|
const BOLD_ON = Buffer.from([ESC, 0x45, 0x01]); // ESC E 1
|
||||||
|
const BOLD_OFF = Buffer.from([ESC, 0x45, 0x00]); // ESC E 0
|
||||||
|
const DOUBLE_ON = Buffer.from([GS, 0x21, 0x11]); // GS ! — double width+height
|
||||||
|
const DOUBLE_OFF = Buffer.from([GS, 0x21, 0x00]);
|
||||||
|
const FEED_AND_CUT = Buffer.from([ESC, 0x64, 0x04, GS, 0x56, 0x42, 0x00]); // feed 4, GS V B 0 partial cut
|
||||||
|
|
||||||
|
// Select code page 852 (Latin-2) for the character set: ESC t n, n=18 (0x12).
|
||||||
|
// CP852 carries the Albanian letters we print (ë, ç, …); without it the printer
|
||||||
|
// would interpret our high bytes as CP437 glyphs. Sent in every print's INIT
|
||||||
|
// preamble. See wiki/concepts/site-metadata.md (i18n / codepage).
|
||||||
|
const SELECT_CP852 = Buffer.from([ESC, 0x74, 0x12]);
|
||||||
|
|
||||||
|
// Minimal Unicode → CP852 byte map for the characters Albanian text actually uses
|
||||||
|
// beyond ASCII. Anything not listed is transliterated to an ASCII fallback (below)
|
||||||
|
// so we never emit a byte that renders as the wrong glyph. Extend as needed.
|
||||||
|
const CP852: Record<string, number> = {
|
||||||
|
ë: 0x89,
|
||||||
|
Ë: 0xeb,
|
||||||
|
ç: 0x87,
|
||||||
|
Ç: 0x80,
|
||||||
|
// common Latin-2 extras that may appear in a park name/address:
|
||||||
|
ä: 0x84,
|
||||||
|
ö: 0x94,
|
||||||
|
ü: 0x81,
|
||||||
|
é: 0x82,
|
||||||
|
á: 0xa0,
|
||||||
|
í: 0xa1,
|
||||||
|
ó: 0xa2,
|
||||||
|
ú: 0xa3,
|
||||||
|
};
|
||||||
|
// ASCII transliteration for any char with no CP852 mapping (last-resort, so an
|
||||||
|
// odd glyph degrades to a readable letter rather than garbage).
|
||||||
|
const ASCII_FALLBACK: Record<string, string> = {
|
||||||
|
ë: "e",
|
||||||
|
Ë: "E",
|
||||||
|
ç: "c",
|
||||||
|
Ç: "C",
|
||||||
|
ä: "a",
|
||||||
|
ö: "o",
|
||||||
|
ü: "u",
|
||||||
|
é: "e",
|
||||||
|
á: "a",
|
||||||
|
í: "i",
|
||||||
|
ó: "o",
|
||||||
|
ú: "u",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Encode one line of text to CP852 bytes + a line feed. ASCII (<0x80) passes
|
||||||
|
* through; mapped chars use their CP852 byte; unmapped non-ASCII falls back to an
|
||||||
|
* ASCII letter. Pair with SELECT_CP852 in the print preamble. */
|
||||||
|
function line(text = ""): Buffer {
|
||||||
|
const out: number[] = [];
|
||||||
|
for (const ch of text) {
|
||||||
|
const code = ch.codePointAt(0) ?? 0;
|
||||||
|
const mapped = CP852[ch];
|
||||||
|
const fallback = ASCII_FALLBACK[ch];
|
||||||
|
if (code < 0x80) {
|
||||||
|
out.push(code);
|
||||||
|
} else if (mapped !== undefined) {
|
||||||
|
out.push(mapped);
|
||||||
|
} else if (fallback !== undefined) {
|
||||||
|
out.push(...Buffer.from(fallback, "ascii"));
|
||||||
|
} else {
|
||||||
|
out.push(0x3f); // "?" — unknown char, never a wrong glyph
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(LF);
|
||||||
|
return Buffer.from(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Scannable symbol (printer-generated, no image rendering) -----------------
|
||||||
|
// The ticket id is the session key (wiki/concepts/ticket-encoding.md). We print it
|
||||||
|
// as a 1D Code128 barcode so ANY legacy laser barcode scanner the booth might have
|
||||||
|
// can read it. The barcode is rendered by the printer board from these ESC/POS
|
||||||
|
// commands — we send the data, the firmware draws the bars (no bitmap, no
|
||||||
|
// dependency). The same code is printed as large human-readable digits below, so
|
||||||
|
// the operator can hand-key it if every reader fails.
|
||||||
|
|
||||||
|
/** GS k — Code128 1D barcode. Height/width set first, then HRI off, then data. */
|
||||||
|
function code128(data: string): Buffer {
|
||||||
|
// Code128 code set B (printable ASCII) — prefix the data with the {B selector.
|
||||||
|
const payload = Buffer.from(`{B${data}`, "ascii");
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([GS, 0x68, 0x64]), // GS h 100 — barcode height = 100 dots (taller = tolerant of scan angle)
|
||||||
|
Buffer.from([GS, 0x77, 0x03]), // GS w 3 — module width = 3 (wider bars for the short-range "Simple" QR/barcode engine; 13-digit Code128 ≈ 495/576 dots, fits 80mm with quiet zones)
|
||||||
|
Buffer.from([GS, 0x48, 0x00]), // GS H 0 — HRI text off (we print the id ourselves)
|
||||||
|
// GS k 73 n <data> — function B form: 73 = Code128, n = data byte length.
|
||||||
|
Buffer.from([GS, 0x6b, 0x49, payload.length]),
|
||||||
|
payload,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2D QR symbol (printer-generated via ESC/POS GS ( k) -----------------------
|
||||||
|
// A true QR for the SUBSCRIPTION card — the subscriber scans it at the reader (which
|
||||||
|
// reads QR + 1D barcode) every entry/exit for the coverage period. The board renders
|
||||||
|
// the QR from these GS ( k commands (no bitmap, no dependency), same approach as
|
||||||
|
// code128. We also print the code as text below as the hand-key fallback.
|
||||||
|
|
||||||
|
/** A QR code via ESC/POS `GS ( k`. `size` = module dot size (1–16; 6 ≈ readable on
|
||||||
|
* 80mm at short range). Error-correction level M (15%) — robust to a smudged print. */
|
||||||
|
function qrCode(data: string, size = 6): Buffer {
|
||||||
|
const bytes = Buffer.from(data, "ascii");
|
||||||
|
// pL/pH encode the data length + 3 (the cn,fn,m header bytes) for function 180.
|
||||||
|
const store = bytes.length + 3;
|
||||||
|
const pL = store & 0xff;
|
||||||
|
const pH = (store >> 8) & 0xff;
|
||||||
|
return Buffer.concat([
|
||||||
|
// fn 165: select QR model — 1d 28 6b 04 00 31 41 <model=50(2)> 00
|
||||||
|
Buffer.from([GS, 0x28, 0x6b, 0x04, 0x00, 0x31, 0x41, 0x32, 0x00]),
|
||||||
|
// fn 167: module size — 1d 28 6b 03 00 31 43 <size>
|
||||||
|
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x43, size]),
|
||||||
|
// fn 169: error correction level — 1d 28 6b 03 00 31 45 <49=M>
|
||||||
|
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x45, 0x31]),
|
||||||
|
// fn 180: store the symbol data — 1d 28 6b pL pH 31 50 30 <data>
|
||||||
|
Buffer.from([GS, 0x28, 0x6b, pL, pH, 0x31, 0x50, 0x30]),
|
||||||
|
bytes,
|
||||||
|
// fn 181: print the stored symbol — 1d 28 6b 03 00 31 51 30
|
||||||
|
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticket/receipt strings — Albanian (the site prints in Albanian for now). Kept in
|
||||||
|
// one place so a real i18n layer (per-locale tables + a t() helper) can replace this
|
||||||
|
// later without touching the render functions. See wiki/concepts/site-metadata.md.
|
||||||
|
const STR = {
|
||||||
|
/** NIUS label prefix; printed only when the park has a NIUS. */
|
||||||
|
nius: (v: string) => `NIUS: ${v}`,
|
||||||
|
/** "Printed at:" — precedes the issue timestamp. */
|
||||||
|
issuedAt: (v: string) => `Printuar më: ${v}`,
|
||||||
|
/** Subscription-card title. */
|
||||||
|
subscription: "ABONIM",
|
||||||
|
/** "Holder: <name>" line on the card. */
|
||||||
|
holder: (name: string) => `Mbajtësi: ${name}`,
|
||||||
|
/** "Valid: <from> – <to>" line on the card. */
|
||||||
|
validity: (from: string, to: string) => `Vlen: ${from} – ${to}`,
|
||||||
|
phone: (v: string) => `TEL: ${v}`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */
|
||||||
|
export function renderReport(report: PrintReport): Buffer {
|
||||||
|
return Buffer.concat([
|
||||||
|
INIT,
|
||||||
|
SELECT_CP852,
|
||||||
|
ALIGN_CENTER,
|
||||||
|
BOLD_ON,
|
||||||
|
line(report.title),
|
||||||
|
BOLD_OFF,
|
||||||
|
ALIGN_LEFT,
|
||||||
|
line(),
|
||||||
|
...report.lines.map((l) => line(l)),
|
||||||
|
FEED_AND_CUT,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the park-identity header from site metadata. Prints the park name large
|
||||||
|
* (or "PARKING" if unset), then operator / NIUS / address lines that are present.
|
||||||
|
* NIUS and the rest only print when set. Non-ASCII renders via CP852 (see line()). */
|
||||||
|
function renderHeader(h: TicketData["header"]): Buffer {
|
||||||
|
const parts: Buffer[] = [
|
||||||
|
ALIGN_CENTER,
|
||||||
|
BOLD_ON,
|
||||||
|
DOUBLE_ON,
|
||||||
|
line(h?.parkName || "PARKING"),
|
||||||
|
DOUBLE_OFF,
|
||||||
|
BOLD_OFF,
|
||||||
|
];
|
||||||
|
if (h?.operatorName) parts.push(line(h.operatorName));
|
||||||
|
if (h?.nius) parts.push(line(STR.nius(h.nius)));
|
||||||
|
if (h?.address) {
|
||||||
|
// Address may be multi-line; print each line centered.
|
||||||
|
for (const ln of h.address.split(/\r?\n/))
|
||||||
|
if (ln.trim()) parts.push(line(ln.trim()));
|
||||||
|
}
|
||||||
|
if (h?.phone) parts.push(line(STR.phone(h.phone)));
|
||||||
|
return Buffer.concat(parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the full ESC/POS byte stream for an entry ticket.
|
||||||
|
* Header (park identity) → 1D Code128 barcode of the ticket id → the id in large
|
||||||
|
* digits → issue time. Code128 is read by ANY legacy 1D barcode scanner the booth
|
||||||
|
* might have; the printed digits are the fallback if every reader fails (operator
|
||||||
|
* hand-keys the all-numeric code). Text is Albanian.
|
||||||
|
* See wiki/concepts/ticket-encoding.md and site-metadata.md. */
|
||||||
|
export function renderTicket(data: TicketData): Buffer {
|
||||||
|
return Buffer.concat([
|
||||||
|
INIT,
|
||||||
|
SELECT_CP852,
|
||||||
|
renderHeader(data.header),
|
||||||
|
line(),
|
||||||
|
// The scannable barcode + the same code in large human-readable digits.
|
||||||
|
code128(data.ticketId),
|
||||||
|
line(),
|
||||||
|
BOLD_ON,
|
||||||
|
DOUBLE_ON,
|
||||||
|
line(data.ticketId),
|
||||||
|
DOUBLE_OFF,
|
||||||
|
BOLD_OFF,
|
||||||
|
line(),
|
||||||
|
line(STR.issuedAt(data.issuedAt)),
|
||||||
|
FEED_AND_CUT,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the ESC/POS byte stream for a SUBSCRIPTION CARD: park header → a scannable
|
||||||
|
* QR of the code → the code in text (hand-key fallback) → holder + validity window.
|
||||||
|
* The subscriber keeps this and scans the QR at the reader every entry/exit. */
|
||||||
|
export function renderSubscriptionCard(data: SubscriptionCardData): Buffer {
|
||||||
|
const parts: Buffer[] = [
|
||||||
|
INIT,
|
||||||
|
SELECT_CP852,
|
||||||
|
renderHeader(data.header),
|
||||||
|
line(),
|
||||||
|
BOLD_ON,
|
||||||
|
line(STR.subscription),
|
||||||
|
BOLD_OFF,
|
||||||
|
line(),
|
||||||
|
ALIGN_CENTER,
|
||||||
|
qrCode(data.code),
|
||||||
|
line(),
|
||||||
|
// The code in text, as the fallback if the QR won't scan.
|
||||||
|
line(data.code),
|
||||||
|
ALIGN_LEFT,
|
||||||
|
line(),
|
||||||
|
];
|
||||||
|
if (data.holderName) parts.push(line(STR.holder(data.holderName)));
|
||||||
|
if (data.validFrom || data.validTo) {
|
||||||
|
parts.push(line(STR.validity(data.validFrom ?? "—", data.validTo ?? "—")));
|
||||||
|
}
|
||||||
|
parts.push(FEED_AND_CUT);
|
||||||
|
return Buffer.concat(parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a TCP socket, write the bytes, wait for flush, then close. */
|
||||||
|
export function sendRaw(
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
payload: Buffer,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const sock = new Socket();
|
||||||
|
let settled = false;
|
||||||
|
const done = (err?: Error) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
sock.destroy();
|
||||||
|
err ? reject(err) : resolve();
|
||||||
|
};
|
||||||
|
sock.setTimeout(timeoutMs);
|
||||||
|
sock.on("timeout", () => done(new Error("timeout")));
|
||||||
|
sock.on("error", done);
|
||||||
|
sock.connect(port, host, () => {
|
||||||
|
sock.write(payload, (err) => (err ? done(err) : done()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TCP connect probe — reachability of the raw print socket. The print socket has
|
||||||
|
* no status protocol we rely on, so this is the floor for any ESC/POS printer:
|
||||||
|
* it answers "is the printer reachable", not "is it out of paper". */
|
||||||
|
export function probe(
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const sock = new Socket();
|
||||||
|
let settled = false;
|
||||||
|
const done = (err?: Error) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
sock.destroy();
|
||||||
|
err ? reject(err) : resolve();
|
||||||
|
};
|
||||||
|
sock.setTimeout(timeoutMs);
|
||||||
|
sock.on("timeout", () => done(new Error("timeout")));
|
||||||
|
sock.on("error", done);
|
||||||
|
sock.connect(port, host, () => done());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- shared driver config fields ----------------------------------------------
|
||||||
|
// Role + failover are identical across ESC/POS printers; defined here so each
|
||||||
|
// driver shares them. See wiki/concepts/printer-roles-failover.md.
|
||||||
|
export type PrinterRole = "entry-dispenser" | "booth-receipt";
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Socket } from "node:net";
|
|
||||||
import { request as httpRequest } from "node:http";
|
import { request as httpRequest } from "node:http";
|
||||||
import type {
|
import type {
|
||||||
Device,
|
Device,
|
||||||
@@ -12,11 +11,21 @@ import type {
|
|||||||
} from "../interfaces.js";
|
} from "../interfaces.js";
|
||||||
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
||||||
import { hostField, portField, stubLog } from "./common.js";
|
import { hostField, portField, stubLog } from "./common.js";
|
||||||
|
import {
|
||||||
|
probe,
|
||||||
|
renderReport,
|
||||||
|
renderSubscriptionCard,
|
||||||
|
renderTicket,
|
||||||
|
sendRaw,
|
||||||
|
} from "./printer-escpos.js";
|
||||||
|
|
||||||
// Rongta 80mm network thermal printer driver. Rongta RP-series printers (and the
|
// Rongta 80mm network thermal printer driver. Rongta RP-series printers (and the
|
||||||
// many OEM clones that share their firmware) speak ESC/POS over a raw TCP socket
|
// many OEM clones that share their firmware) speak ESC/POS over a raw TCP socket
|
||||||
// on port 9100 — the JetDirect/RAW convention. There is no auth on the print
|
// on port 9100 — the JetDirect/RAW convention. The ESC/POS rendering + transport
|
||||||
// socket; like the other field devices it lives on the isolated device VLAN.
|
// are shared with the other ESC/POS clones in ./printer-escpos.ts; what is unique
|
||||||
|
// to Rongta — and lives here — is LIVE STATUS via the board's own status web page.
|
||||||
|
// There is no auth on the print socket; like the other field devices it lives on
|
||||||
|
// the isolated device VLAN.
|
||||||
// See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md.
|
// See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md.
|
||||||
//
|
//
|
||||||
// ROLES + FAILOVER: a lane has more than one printer. Each instance declares a
|
// ROLES + FAILOVER: a lane has more than one printer. Each instance declares a
|
||||||
@@ -27,253 +36,22 @@ import { hostField, portField, stubLog } from "./common.js";
|
|||||||
// itself is role-agnostic; the role/rank live in config and the caller (server)
|
// itself is role-agnostic; the role/rank live in config and the caller (server)
|
||||||
// owns the failover selection. See wiki/concepts/printer-roles-failover.md.
|
// owns the failover selection. See wiki/concepts/printer-roles-failover.md.
|
||||||
|
|
||||||
// --- ESC/POS command bytes ----------------------------------------------------
|
|
||||||
const ESC = 0x1b;
|
|
||||||
const GS = 0x1d;
|
|
||||||
const LF = 0x0a;
|
|
||||||
|
|
||||||
const INIT = Buffer.from([ESC, 0x40]); // ESC @ — reset to power-on defaults
|
|
||||||
const ALIGN_CENTER = Buffer.from([ESC, 0x61, 0x01]); // ESC a 1
|
|
||||||
const ALIGN_LEFT = Buffer.from([ESC, 0x61, 0x00]); // ESC a 0
|
|
||||||
const BOLD_ON = Buffer.from([ESC, 0x45, 0x01]); // ESC E 1
|
|
||||||
const BOLD_OFF = Buffer.from([ESC, 0x45, 0x00]); // ESC E 0
|
|
||||||
const DOUBLE_ON = Buffer.from([GS, 0x21, 0x11]); // GS ! — double width+height
|
|
||||||
const DOUBLE_OFF = Buffer.from([GS, 0x21, 0x00]);
|
|
||||||
const FEED_AND_CUT = Buffer.from([ESC, 0x64, 0x04, GS, 0x56, 0x42, 0x00]); // feed 4, GS V B 0 partial cut
|
|
||||||
|
|
||||||
// Select code page 852 (Latin-2) for the character set: ESC t n, n=18 (0x12).
|
|
||||||
// CP852 carries the Albanian letters we print (ë, ç, …); without it the printer
|
|
||||||
// would interpret our high bytes as CP437 glyphs. Sent in every print's INIT
|
|
||||||
// preamble. See wiki/concepts/site-metadata.md (i18n / codepage).
|
|
||||||
const SELECT_CP852 = Buffer.from([ESC, 0x74, 0x12]);
|
|
||||||
|
|
||||||
// Minimal Unicode → CP852 byte map for the characters Albanian text actually uses
|
|
||||||
// beyond ASCII. Anything not listed is transliterated to an ASCII fallback (below)
|
|
||||||
// so we never emit a byte that renders as the wrong glyph. Extend as needed.
|
|
||||||
const CP852: Record<string, number> = {
|
|
||||||
ë: 0x89, Ë: 0xeb,
|
|
||||||
ç: 0x87, Ç: 0x80,
|
|
||||||
// common Latin-2 extras that may appear in a park name/address:
|
|
||||||
ä: 0x84, ö: 0x94, ü: 0x81, é: 0x82, á: 0xa0, í: 0xa1, ó: 0xa2, ú: 0xa3,
|
|
||||||
};
|
|
||||||
// ASCII transliteration for any char with no CP852 mapping (last-resort, so an
|
|
||||||
// odd glyph degrades to a readable letter rather than garbage).
|
|
||||||
const ASCII_FALLBACK: Record<string, string> = {
|
|
||||||
ë: "e", Ë: "E", ç: "c", Ç: "C", ä: "a", ö: "o", ü: "u",
|
|
||||||
é: "e", á: "a", í: "i", ó: "o", ú: "u",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Encode one line of text to CP852 bytes + a line feed. ASCII (<0x80) passes
|
|
||||||
* through; mapped chars use their CP852 byte; unmapped non-ASCII falls back to an
|
|
||||||
* ASCII letter. Pair with SELECT_CP852 in the print preamble. */
|
|
||||||
function line(text = ""): Buffer {
|
|
||||||
const out: number[] = [];
|
|
||||||
for (const ch of text) {
|
|
||||||
const code = ch.codePointAt(0) ?? 0;
|
|
||||||
const mapped = CP852[ch];
|
|
||||||
const fallback = ASCII_FALLBACK[ch];
|
|
||||||
if (code < 0x80) {
|
|
||||||
out.push(code);
|
|
||||||
} else if (mapped !== undefined) {
|
|
||||||
out.push(mapped);
|
|
||||||
} else if (fallback !== undefined) {
|
|
||||||
out.push(...Buffer.from(fallback, "ascii"));
|
|
||||||
} else {
|
|
||||||
out.push(0x3f); // "?" — unknown char, never a wrong glyph
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.push(LF);
|
|
||||||
return Buffer.from(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Scannable symbol (printer-generated, no image rendering) -----------------
|
|
||||||
// The ticket id is the session key (wiki/concepts/ticket-encoding.md). We print it
|
|
||||||
// as a 1D Code128 barcode so ANY legacy laser barcode scanner the booth might have
|
|
||||||
// can read it. The barcode is rendered by the Rongta board from these ESC/POS
|
|
||||||
// commands — we send the data, the firmware draws the bars (no bitmap, no
|
|
||||||
// dependency). The same code is printed as large human-readable digits below, so
|
|
||||||
// the operator can hand-key it if every reader fails. (A QR for phone scanning may
|
|
||||||
// be added later behind an admin toggle.)
|
|
||||||
|
|
||||||
/** GS k — Code128 1D barcode. Height/width set first, then HRI off, then data. */
|
|
||||||
function code128(data: string): Buffer {
|
|
||||||
// Code128 code set B (printable ASCII) — prefix the data with the {B selector.
|
|
||||||
const payload = Buffer.from(`{B${data}`, "ascii");
|
|
||||||
return Buffer.concat([
|
|
||||||
Buffer.from([GS, 0x68, 0x64]), // GS h 100 — barcode height = 100 dots (taller = tolerant of scan angle)
|
|
||||||
Buffer.from([GS, 0x77, 0x03]), // GS w 3 — module width = 3 (wider bars for the short-range "Simple" QR/barcode engine; 13-digit Code128 ≈ 495/576 dots, fits 80mm with quiet zones)
|
|
||||||
Buffer.from([GS, 0x48, 0x00]), // GS H 0 — HRI text off (we print the id ourselves)
|
|
||||||
// GS k 73 n <data> — function B form: 73 = Code128, n = data byte length.
|
|
||||||
Buffer.from([GS, 0x6b, 0x49, payload.length]),
|
|
||||||
payload,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 2D QR symbol (printer-generated via ESC/POS GS ( k) -----------------------
|
|
||||||
// A true QR for the SUBSCRIPTION card — the subscriber scans it at the reader (which
|
|
||||||
// reads QR + 1D barcode) every entry/exit for the coverage period. The board renders
|
|
||||||
// the QR from these GS ( k commands (no bitmap, no dependency), same approach as
|
|
||||||
// code128. We also print the code as text below as the hand-key fallback. The QR
|
|
||||||
// "model 2" sequence: set model → set module size → set error-correction → store the
|
|
||||||
// data in symbol storage → print it. See ESC/POS GS ( k (function 165/167/169/180/181).
|
|
||||||
|
|
||||||
/** A QR code via ESC/POS `GS ( k`. `size` = module dot size (1–16; 6 ≈ readable on
|
|
||||||
* 80mm at short range). Error-correction level M (15%) — robust to a smudged print. */
|
|
||||||
function qrCode(data: string, size = 6): Buffer {
|
|
||||||
const bytes = Buffer.from(data, "ascii");
|
|
||||||
// pL/pH encode the data length + 3 (the cn,fn,m header bytes) for function 180.
|
|
||||||
const store = bytes.length + 3;
|
|
||||||
const pL = store & 0xff;
|
|
||||||
const pH = (store >> 8) & 0xff;
|
|
||||||
return Buffer.concat([
|
|
||||||
// fn 165: select QR model — 1d 28 6b 04 00 31 41 <model=50(2)> 00
|
|
||||||
Buffer.from([GS, 0x28, 0x6b, 0x04, 0x00, 0x31, 0x41, 0x32, 0x00]),
|
|
||||||
// fn 167: module size — 1d 28 6b 03 00 31 43 <size>
|
|
||||||
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x43, size]),
|
|
||||||
// fn 169: error correction level — 1d 28 6b 03 00 31 45 <49=M>
|
|
||||||
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x45, 0x31]),
|
|
||||||
// fn 180: store the symbol data — 1d 28 6b pL pH 31 50 30 <data>
|
|
||||||
Buffer.from([GS, 0x28, 0x6b, pL, pH, 0x31, 0x50, 0x30]),
|
|
||||||
bytes,
|
|
||||||
// fn 181: print the stored symbol — 1d 28 6b 03 00 31 51 30
|
|
||||||
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ticket/receipt strings — Albanian (the site prints in Albanian for now). Kept in
|
|
||||||
// one place so a real i18n layer (per-locale tables + a t() helper) can replace this
|
|
||||||
// later without touching the render functions. See wiki/concepts/site-metadata.md.
|
|
||||||
const STR = {
|
|
||||||
/** NIUS label prefix; printed only when the park has a NIUS. */
|
|
||||||
nius: (v: string) => `NIUS: ${v}`,
|
|
||||||
/** "Printed at:" — precedes the issue timestamp. */
|
|
||||||
issuedAt: (v: string) => `Printuar më: ${v}`,
|
|
||||||
/** "Lost your ticket? <phone>" footer; printed only when a phone is set. */
|
|
||||||
lostTicket: (phone: string) => `Keni humbur biletën? ${phone}`,
|
|
||||||
/** Subscription-card title. */
|
|
||||||
subscription: "ABONIM",
|
|
||||||
/** "Holder: <name>" line on the card. */
|
|
||||||
holder: (name: string) => `Mbajtësi: ${name}`,
|
|
||||||
/** "Valid: <from> – <to>" line on the card. */
|
|
||||||
validity: (from: string, to: string) => `Vlen: ${from} – ${to}`,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */
|
|
||||||
function renderReport(report: PrintReport): Buffer {
|
|
||||||
return Buffer.concat([
|
|
||||||
INIT,
|
|
||||||
SELECT_CP852,
|
|
||||||
ALIGN_CENTER,
|
|
||||||
BOLD_ON,
|
|
||||||
line(report.title),
|
|
||||||
BOLD_OFF,
|
|
||||||
ALIGN_LEFT,
|
|
||||||
line(),
|
|
||||||
...report.lines.map((l) => line(l)),
|
|
||||||
FEED_AND_CUT,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render the park-identity header from site metadata. Prints the park name large
|
|
||||||
* (or "PARKING" if unset), then operator / NIUS / address lines that are present.
|
|
||||||
* NIUS and the rest only print when set. Non-ASCII renders via CP852 (see line()). */
|
|
||||||
function renderHeader(h: TicketData["header"]): Buffer {
|
|
||||||
const parts: Buffer[] = [ALIGN_CENTER, BOLD_ON, DOUBLE_ON, line(h?.parkName || "PARKING"), DOUBLE_OFF, BOLD_OFF];
|
|
||||||
if (h?.operatorName) parts.push(line(h.operatorName));
|
|
||||||
if (h?.nius) parts.push(line(STR.nius(h.nius)));
|
|
||||||
if (h?.address) {
|
|
||||||
// Address may be multi-line; print each line centered.
|
|
||||||
for (const ln of h.address.split(/\r?\n/)) if (ln.trim()) parts.push(line(ln.trim()));
|
|
||||||
}
|
|
||||||
return Buffer.concat(parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build the full ESC/POS byte stream for an entry ticket.
|
|
||||||
* Header (park identity) → 1D Code128 barcode of the ticket id → the id in large
|
|
||||||
* digits → issue time → optional lost-ticket footer. Code128 is read by ANY legacy
|
|
||||||
* 1D barcode scanner the booth might have; the printed digits are the fallback if
|
|
||||||
* every reader fails (operator hand-keys the all-numeric code). Text is Albanian.
|
|
||||||
* See wiki/concepts/ticket-encoding.md and site-metadata.md. */
|
|
||||||
function renderTicket(data: TicketData): Buffer {
|
|
||||||
return Buffer.concat([
|
|
||||||
INIT,
|
|
||||||
SELECT_CP852,
|
|
||||||
renderHeader(data.header),
|
|
||||||
line(),
|
|
||||||
// The scannable barcode + the same code in large human-readable digits.
|
|
||||||
code128(data.ticketId),
|
|
||||||
line(),
|
|
||||||
BOLD_ON,
|
|
||||||
DOUBLE_ON,
|
|
||||||
line(data.ticketId),
|
|
||||||
DOUBLE_OFF,
|
|
||||||
BOLD_OFF,
|
|
||||||
line(),
|
|
||||||
line(STR.issuedAt(data.issuedAt)),
|
|
||||||
// Contact footer (lost-ticket help) if a phone is set.
|
|
||||||
...(data.header?.phone ? [line(STR.lostTicket(data.header.phone))] : []),
|
|
||||||
FEED_AND_CUT,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build the ESC/POS byte stream for a SUBSCRIPTION CARD: park header → a scannable
|
|
||||||
* QR of the code → the code in text (hand-key fallback) → holder + validity window.
|
|
||||||
* The subscriber keeps this and scans the QR at the reader every entry/exit. */
|
|
||||||
function renderSubscriptionCard(data: SubscriptionCardData): Buffer {
|
|
||||||
const parts: Buffer[] = [
|
|
||||||
INIT,
|
|
||||||
SELECT_CP852,
|
|
||||||
renderHeader(data.header),
|
|
||||||
line(),
|
|
||||||
BOLD_ON,
|
|
||||||
line(STR.subscription),
|
|
||||||
BOLD_OFF,
|
|
||||||
line(),
|
|
||||||
ALIGN_CENTER,
|
|
||||||
qrCode(data.code),
|
|
||||||
line(),
|
|
||||||
// The code in text, as the fallback if the QR won't scan.
|
|
||||||
line(data.code),
|
|
||||||
ALIGN_LEFT,
|
|
||||||
line(),
|
|
||||||
];
|
|
||||||
if (data.holderName) parts.push(line(STR.holder(data.holderName)));
|
|
||||||
if (data.validFrom || data.validTo) {
|
|
||||||
parts.push(line(STR.validity(data.validFrom ?? "—", data.validTo ?? "—")));
|
|
||||||
}
|
|
||||||
parts.push(FEED_AND_CUT);
|
|
||||||
return Buffer.concat(parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open a TCP socket, write the bytes, wait for flush, then close. */
|
|
||||||
function sendRaw(host: string, port: number, payload: Buffer, timeoutMs: number): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const sock = new Socket();
|
|
||||||
let settled = false;
|
|
||||||
const done = (err?: Error) => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
sock.destroy();
|
|
||||||
err ? reject(err) : resolve();
|
|
||||||
};
|
|
||||||
sock.setTimeout(timeoutMs);
|
|
||||||
sock.on("timeout", () => done(new Error("timeout")));
|
|
||||||
sock.on("error", done);
|
|
||||||
sock.connect(port, host, () => {
|
|
||||||
sock.write(payload, (err) => (err ? done(err) : done()));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- live status via the device's own status web page -------------------------
|
// --- live status via the device's own status web page -------------------------
|
||||||
// The Rongta board serves /prn_stat.htm, a small HTML table where the DEVICE has
|
// The Rongta board serves /prn_stat.htm, a small HTML table where the DEVICE has
|
||||||
// already decoded the ESC/POS status bits into labelled Yes/No rows. We scrape
|
// already decoded the ESC/POS status bits into labelled Yes/No rows. We scrape
|
||||||
// that rather than send raw `DLE EOT` ourselves: on this clone the DLE EOT reply
|
// that rather than send raw `DLE EOT` ourselves: on this clone the DLE EOT reply
|
||||||
// bytes don't follow the canonical bit layout (verified on hardware), so trusting
|
// bytes don't follow the canonical bit layout (verified on hardware), so trusting
|
||||||
// the device's own decode is the safe choice. See printer-status-monitoring.md.
|
// the device's own decode is the safe choice. See printer-status-monitoring.md.
|
||||||
|
// A clone that does NOT serve this page (e.g. the Cashino) uses its own driver
|
||||||
|
// with a plain reachability probe — it must not pretend to report paper/cover.
|
||||||
|
|
||||||
/** The fault flags the status page reports (a subset of PrinterStatus). */
|
/** The fault flags the status page reports (a subset of PrinterStatus). */
|
||||||
type StatusFlag = "coverOpen" | "cutterError" | "paperEnd" | "paperNearEnd" | "offline";
|
type StatusFlag =
|
||||||
|
| "coverOpen"
|
||||||
|
| "cutterError"
|
||||||
|
| "paperEnd"
|
||||||
|
| "paperNearEnd"
|
||||||
|
| "offline";
|
||||||
type StatusFlags = Partial<Record<StatusFlag, boolean>>;
|
type StatusFlags = Partial<Record<StatusFlag, boolean>>;
|
||||||
|
|
||||||
/** Label text on the status page (NBSP/space-normalised, lowercased) → our key. */
|
/** Label text on the status page (NBSP/space-normalised, lowercased) → our key. */
|
||||||
@@ -286,10 +64,20 @@ const STATUS_FIELDS: Record<string, StatusFlag> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** GET the status page over HTTP and return the raw HTML. */
|
/** GET the status page over HTTP and return the raw HTML. */
|
||||||
function fetchStatusPage(host: string, httpPort: number, timeoutMs: number): Promise<string> {
|
function fetchStatusPage(
|
||||||
|
host: string,
|
||||||
|
httpPort: number,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = httpRequest(
|
const req = httpRequest(
|
||||||
{ host, port: httpPort, path: "/prn_stat.htm", method: "GET", timeout: timeoutMs },
|
{
|
||||||
|
host,
|
||||||
|
port: httpPort,
|
||||||
|
path: "/prn_stat.htm",
|
||||||
|
method: "GET",
|
||||||
|
timeout: timeoutMs,
|
||||||
|
},
|
||||||
(res) => {
|
(res) => {
|
||||||
let data = "";
|
let data = "";
|
||||||
res.on("data", (c) => (data += c));
|
res.on("data", (c) => (data += c));
|
||||||
@@ -318,8 +106,15 @@ function parseStatusPage(html: string): StatusFlags {
|
|||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = rowRe.exec(html))) {
|
while ((m = rowRe.exec(html))) {
|
||||||
if (m[1] === undefined || m[2] === undefined) continue;
|
if (m[1] === undefined || m[2] === undefined) continue;
|
||||||
const label = m[1].replace(/ /gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
|
const label = m[1]
|
||||||
const value = m[2].replace(/ /gi, " ").trim().toLowerCase();
|
.replace(/ /gi, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const value = m[2]
|
||||||
|
.replace(/ /gi, " ")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
const key = STATUS_FIELDS[label];
|
const key = STATUS_FIELDS[label];
|
||||||
if (key && (value === "yes" || value === "no")) {
|
if (key && (value === "yes" || value === "no")) {
|
||||||
out[key] = value === "yes";
|
out[key] = value === "yes";
|
||||||
@@ -328,24 +123,6 @@ function parseStatusPage(html: string): StatusFlags {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** TCP connect probe — the print socket has no status protocol we rely on. */
|
|
||||||
function probe(host: string, port: number, timeoutMs: number): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const sock = new Socket();
|
|
||||||
let settled = false;
|
|
||||||
const done = (err?: Error) => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
sock.destroy();
|
|
||||||
err ? reject(err) : resolve();
|
|
||||||
};
|
|
||||||
sock.setTimeout(timeoutMs);
|
|
||||||
sock.on("timeout", () => done(new Error("timeout")));
|
|
||||||
sock.on("error", done);
|
|
||||||
sock.connect(port, host, () => done());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
||||||
readonly driverId = "rongta";
|
readonly driverId = "rongta";
|
||||||
readonly #host: string;
|
readonly #host: string;
|
||||||
@@ -384,11 +161,19 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
|||||||
|
|
||||||
async printReport(report: PrintReport): Promise<void> {
|
async printReport(report: PrintReport): Promise<void> {
|
||||||
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout);
|
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout);
|
||||||
stubLog(this.driverId, `printed report "${report.title}" (${report.lines.length} lines)`);
|
stubLog(
|
||||||
|
this.driverId,
|
||||||
|
`printed report "${report.title}" (${report.lines.length} lines)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
|
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
|
||||||
await sendRaw(this.#host, this.#port, renderSubscriptionCard(data), this.#timeout);
|
await sendRaw(
|
||||||
|
this.#host,
|
||||||
|
this.#port,
|
||||||
|
renderSubscriptionCard(data),
|
||||||
|
this.#timeout,
|
||||||
|
);
|
||||||
stubLog(this.driverId, `printed subscription card ${data.code}`);
|
stubLog(this.driverId, `printed subscription card ${data.code}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +198,13 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const flags = parseStatusPage(html);
|
const flags = parseStatusPage(html);
|
||||||
const expected: StatusFlag[] = ["coverOpen", "cutterError", "paperEnd", "paperNearEnd", "offline"];
|
const expected: StatusFlag[] = [
|
||||||
|
"coverOpen",
|
||||||
|
"cutterError",
|
||||||
|
"paperEnd",
|
||||||
|
"paperNearEnd",
|
||||||
|
"offline",
|
||||||
|
];
|
||||||
const missing = expected.filter((k) => flags[k] === undefined);
|
const missing = expected.filter((k) => flags[k] === undefined);
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
return {
|
return {
|
||||||
@@ -434,7 +225,8 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
|||||||
return {
|
return {
|
||||||
status: faults.length > 0 ? "degraded" : "ready",
|
status: faults.length > 0 ? "degraded" : "ready",
|
||||||
...flags,
|
...flags,
|
||||||
detail: faults.length > 0 ? faults.map((f) => labels[f]).join(", ") : undefined,
|
detail:
|
||||||
|
faults.length > 0 ? faults.map((f) => labels[f]).join(", ") : undefined,
|
||||||
checkedAt,
|
checkedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -450,7 +242,10 @@ const roleField: ConfigField = {
|
|||||||
required: true,
|
required: true,
|
||||||
default: "entry-dispenser",
|
default: "entry-dispenser",
|
||||||
options: [
|
options: [
|
||||||
{ value: "entry-dispenser", label: "Entry dispenser (outside / at the lane)" },
|
{
|
||||||
|
value: "entry-dispenser",
|
||||||
|
label: "Entry dispenser (outside / at the lane)",
|
||||||
|
},
|
||||||
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" },
|
{ 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.",
|
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.",
|
||||||
@@ -470,15 +265,32 @@ export const rongtaDriver: PrinterDriver = {
|
|||||||
category: "printer",
|
category: "printer",
|
||||||
label: "Rongta 80mm thermal printer",
|
label: "Rongta 80mm thermal printer",
|
||||||
description:
|
description:
|
||||||
"Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones) over raw TCP (port 9100). No auth on the print socket — isolate the VLAN.",
|
"Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones that serve the /prn_stat.htm status page) over raw TCP (port 9100). No auth on the print socket — isolate the VLAN.",
|
||||||
transports: ["tcp-ip"],
|
transports: ["tcp-ip"],
|
||||||
configFields: [
|
configFields: [
|
||||||
hostField,
|
hostField,
|
||||||
{ ...portField(9100), required: false, help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100)." },
|
{
|
||||||
{ key: "httpPort", label: "Status web port", type: "port", required: false, default: 80, help: "Device status page (/prn_stat.htm) port for live monitoring (default 80)." },
|
...portField(9100),
|
||||||
|
required: false,
|
||||||
|
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "httpPort",
|
||||||
|
label: "Status web port",
|
||||||
|
type: "port",
|
||||||
|
required: false,
|
||||||
|
default: 80,
|
||||||
|
help: "Device status page (/prn_stat.htm) port for live monitoring (default 80).",
|
||||||
|
},
|
||||||
roleField,
|
roleField,
|
||||||
rankField,
|
rankField,
|
||||||
{ key: "timeoutMs", label: "Timeout (ms)", type: "number", required: false, default: 3000 },
|
{
|
||||||
|
key: "timeoutMs",
|
||||||
|
label: "Timeout (ms)",
|
||||||
|
type: "number",
|
||||||
|
required: false,
|
||||||
|
default: 3000,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
create: (c) => new RongtaPrinter(c),
|
create: (c) => new RongtaPrinter(c),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export {
|
|||||||
hikvisionDriver,
|
hikvisionDriver,
|
||||||
dahuaDriver,
|
dahuaDriver,
|
||||||
rongtaDriver,
|
rongtaDriver,
|
||||||
|
cashinoDriver,
|
||||||
} from "./drivers/index.js";
|
} from "./drivers/index.js";
|
||||||
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
|
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -35,6 +35,19 @@ This is captured as a device capability: `MonitorableDevice.readStatus(): Printe
|
|||||||
`isMonitorable()`. A future printer with a different status mechanism just implements the same
|
`isMonitorable()`. A future printer with a different status mechanism just implements the same
|
||||||
interface.
|
interface.
|
||||||
|
|
||||||
|
**A clone WITHOUT a trustworthy status mechanism must NOT implement `readStatus`.** The **Cashino**
|
||||||
|
80mm printer prints via the identical ESC/POS stream (shared in `drivers/printer-escpos.ts`) but
|
||||||
|
serves **no** `/prn_stat.htm` page. Running it on the Rongta driver made the monitor scrape a page
|
||||||
|
that isn't there → a bogus `degraded`/page-error verdict even while the printer was fine (the
|
||||||
|
incident that prompted this). Fix: a dedicated `cashino` driver that is **not** `MonitorableDevice`
|
||||||
|
(no `readStatus`), so `isMonitorable()` is false and the monitor falls back to the generic
|
||||||
|
`healthCheck()` — a plain **TCP reachability ping** of the print socket: reachable → `ready`,
|
||||||
|
unreachable → `offline`, and **never** a guessed paper/cover state it cannot sense. This is the
|
||||||
|
correct floor for any ESC/POS printer that can't report consumables: report only what you can
|
||||||
|
actually observe. The shared ESC/POS rendering/transport (`renderTicket`/`renderReport`/
|
||||||
|
`renderSubscriptionCard`/`sendRaw`/`probe`) was extracted to `printer-escpos.ts` so both drivers
|
||||||
|
share the print path and only status differs.
|
||||||
|
|
||||||
## Status mapping (fail safe)
|
## Status mapping (fail safe)
|
||||||
|
|
||||||
`readStatus()` maps to `ready | degraded | offline`:
|
`readStatus()` maps to `ready | degraded | offline`:
|
||||||
|
|||||||
@@ -864,3 +864,7 @@ NB incident: the running tsx-watch dev server (server + vite) crashed mid-edit o
|
|||||||
## [2026-06-18] feat | Booth UI — adopted "TRM" design-system tokens (tokens only)
|
## [2026-06-18] feat | Booth UI — adopted "TRM" design-system tokens (tokens only)
|
||||||
|
|
||||||
The owner linked a Claude Design project (`019ddfee-…`, "TRM — Tracking & Race Management") and asked to implement its designs in `apps/web/`. TRM is a RACE-TIMING design system (dashboard/leaderboard/marketing/mobile kits: RaceControl, HeroClock, LiveTable, BibCard, Ticker) — NOT a parking design; literally porting it would have reskinned the booth UI with race components. Flagged the mismatch; owner chose **tokens only**. So: brought TRM's token VOCABULARY into the Tailwind v4 `@theme` (`apps/web/src/index.css`) and ALIGNED the existing `term-*` accents onto TRM's exact values — surfaces → TRM `night` scale (#0b0d10/#14171c/#1e222a/#2a2f38), amber #f5a623→#f2a516, green #2ecc71→#2e8c4a, red #ff4d4f→#e8412b (flag), cyan #38bdf8→#2563c8 (blue). No component touched (170+ `term-*` references resolve unchanged). Also exposed TRM's full vocabulary as utilities for new work: night/ink/paper scales, flag/amber/green/blue + tints, viz-1..8, the 4px spacing scale (s0..s13), type scale (overline..jumbo), square radii, sharp "printed" offset shadows, control/table-row heights. Offline-appliance constraint ⇒ deliberately did NOT keep TRM's Google-Fonts `@import` (no runtime network); Goldplay (TRM display face) left un-self-hosted — display/heading falls back to a sans stack (mono is the booth's primary face anyway), noted for later wiring. Verified: web build green; login renders on the new palette (amber focus ring = #f2a516). Updated [[booth-console]]. No logic/schema/event-chain change.
|
The owner linked a Claude Design project (`019ddfee-…`, "TRM — Tracking & Race Management") and asked to implement its designs in `apps/web/`. TRM is a RACE-TIMING design system (dashboard/leaderboard/marketing/mobile kits: RaceControl, HeroClock, LiveTable, BibCard, Ticker) — NOT a parking design; literally porting it would have reskinned the booth UI with race components. Flagged the mismatch; owner chose **tokens only**. So: brought TRM's token VOCABULARY into the Tailwind v4 `@theme` (`apps/web/src/index.css`) and ALIGNED the existing `term-*` accents onto TRM's exact values — surfaces → TRM `night` scale (#0b0d10/#14171c/#1e222a/#2a2f38), amber #f5a623→#f2a516, green #2ecc71→#2e8c4a, red #ff4d4f→#e8412b (flag), cyan #38bdf8→#2563c8 (blue). No component touched (170+ `term-*` references resolve unchanged). Also exposed TRM's full vocabulary as utilities for new work: night/ink/paper scales, flag/amber/green/blue + tints, viz-1..8, the 4px spacing scale (s0..s13), type scale (overline..jumbo), square radii, sharp "printed" offset shadows, control/table-row heights. Offline-appliance constraint ⇒ deliberately did NOT keep TRM's Google-Fonts `@import` (no runtime network); Goldplay (TRM display face) left un-self-hosted — display/heading falls back to a sans stack (mono is the booth's primary face anyway), noted for later wiring. Verified: web build green; login renders on the new palette (amber focus ring = #f2a516). Updated [[booth-console]]. No logic/schema/event-chain change.
|
||||||
|
|
||||||
|
## [2026-06-18] fix | Cashino printer — ping-only driver (no false status) + Albanian device-role wording
|
||||||
|
|
||||||
|
Two device-feedback issues. (1) **Cashino 80mm printer reported wrong status.** It was configured on the `rongta` driver, whose `readStatus()` scrapes the Rongta board's `/prn_stat.htm` status page — which the Cashino does NOT serve. Result: a bogus `degraded`/page-error verdict while the printer was actually online (it printed fine; `healthCheck` TCP-ping passed). Root cause: the Cashino is an ESC/POS PRINT clone but has no trustworthy STATUS mechanism. Fix: extracted the shared ESC/POS rendering + transport (renderTicket/renderReport/renderSubscriptionCard/sendRaw/probe + CP852 map + code128/qrCode) from `printer-rongta.ts` into a new `drivers/printer-escpos.ts`; added a dedicated `cashino` driver that reuses that print path but is deliberately **NOT** `MonitorableDevice` (no readStatus). So `isMonitorable()` is false and the device monitor falls back to the generic `healthCheck()` — a plain TCP reachability ping of the print socket: reachable→ready, unreachable→offline, never a guessed paper/cover state. Rongta driver unchanged (still scrapes its page, still monitorable). Registered `cashinoDriver`; re-exported from the package. Switched the live entry-dispenser printer at **10.0.10.9** from `rongta`→`cashino` in the DB (backed up incl. WAL: apps/server/parking.sqlite*.bak-cashino-*); 10.0.10.10 (booth Rongta) left as-is. Verified at runtime: cashino registered, isMonitorable=false, no readStatus, healthCheck→offline on unreachable; live /api/devices/status → both printers `ready` (lane via ping, booth via page). (2) **Albanian device-role chip wording was wrong.** The footer label is `"{category} {role}"`; the role suffixes read badly: access `mixed`="i përzier" gave `Barriera i përzier` ("Barrier mixed" — wrong word + wrong gender; `mixed` actually means a barrier spanning >1 direction) → now `hyrje/dalje` (entry/exit). printer `lane`="korsia" gave `Printer korsia` ("Printer the-lane") → now `në korsi` (at the lane); `booth`="kabina" (`Printer kabina`) → `në kabinë` (at the booth). English tidied to match: mixed→"entry/exit", lane→"at lane", booth→"at booth". i18n catalog parity green. Updated [[printer-status-monitoring]]. No schema/event-chain change.
|
||||||
|
|||||||
Reference in New Issue
Block a user