diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 5f1638a..d97921e 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -44,9 +44,9 @@ export const en: Catalog = { entry: "entry", exit: "exit", both: "entry/exit", - mixed: "mixed", - lane: "lane", - booth: "booth", + mixed: "entry/exit", + lane: "at lane", + booth: "at booth", }, state: { ready: "ready", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 81bf4b5..4d02e96 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -44,9 +44,9 @@ export const sq = { entry: "hyrje", exit: "dalje", both: "hyrje/dalje", - mixed: "i përzier", - lane: "korsia", - booth: "kabina", + mixed: "hyrje/dalje", + lane: "në korsi", + booth: "në kabinë", }, state: { ready: "gati", diff --git a/packages/devices/src/drivers/index.ts b/packages/devices/src/drivers/index.ts index 44df414..a1f31b0 100644 --- a/packages/devices/src/drivers/index.ts +++ b/packages/devices/src/drivers/index.ts @@ -5,6 +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 { rongtaDriver } from "./printer-rongta.js"; import { geeQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js"; @@ -22,6 +23,7 @@ export function registerBuiltinDrivers(): void { registry.register(hikvisionDriver); registry.register(dahuaDriver); registry.register(rongtaDriver); + registry.register(cashinoDriver); } export { @@ -33,4 +35,5 @@ export { hikvisionDriver, dahuaDriver, rongtaDriver, + cashinoDriver, }; diff --git a/packages/devices/src/drivers/printer-cashino.ts b/packages/devices/src/drivers/printer-cashino.ts new file mode 100644 index 0000000..50c094b --- /dev/null +++ b/packages/devices/src/drivers/printer-cashino.ts @@ -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 { + await this.healthCheck(); + } + + async disconnect(): Promise { + 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 { + 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 { + await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout); + stubLog(this.driverId, `printed ticket ${data.ticketId}`); + } + + async printReport(report: PrintReport): Promise { + 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 { + 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), +}; diff --git a/packages/devices/src/drivers/printer-escpos.ts b/packages/devices/src/drivers/printer-escpos.ts new file mode 100644 index 0000000..85fe183 --- /dev/null +++ b/packages/devices/src/drivers/printer-escpos.ts @@ -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 = { + ë: 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 = { + ë: "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 — 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 00 + Buffer.from([GS, 0x28, 0x6b, 0x04, 0x00, 0x31, 0x41, 0x32, 0x00]), + // fn 167: module size — 1d 28 6b 03 00 31 43 + 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 + 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: " line on the card. */ + holder: (name: string) => `Mbajtësi: ${name}`, + /** "Valid: – " 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 { + 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 { + 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"; diff --git a/packages/devices/src/drivers/printer-rongta.ts b/packages/devices/src/drivers/printer-rongta.ts index 4a98786..7c8b910 100644 --- a/packages/devices/src/drivers/printer-rongta.ts +++ b/packages/devices/src/drivers/printer-rongta.ts @@ -1,4 +1,3 @@ -import { Socket } from "node:net"; import { request as httpRequest } from "node:http"; import type { Device, @@ -12,11 +11,21 @@ import type { } 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"; // 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 -// on port 9100 — the JetDirect/RAW convention. There is no auth on the print -// socket; like the other field devices it lives on the isolated device VLAN. +// on port 9100 — the JetDirect/RAW convention. The ESC/POS rendering + transport +// 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. // // 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) // 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 = { - ë: 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 = { - ë: "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 — 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 00 - Buffer.from([GS, 0x28, 0x6b, 0x04, 0x00, 0x31, 0x41, 0x32, 0x00]), - // fn 167: module size — 1d 28 6b 03 00 31 43 - 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 - 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? " footer; printed only when a phone is set. */ - lostTicket: (phone: string) => `Keni humbur biletën? ${phone}`, - /** Subscription-card title. */ - subscription: "ABONIM", - /** "Holder: " line on the card. */ - holder: (name: string) => `Mbajtësi: ${name}`, - /** "Valid: – " 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 { - 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 ------------------------- // 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 // 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 // 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). */ -type StatusFlag = "coverOpen" | "cutterError" | "paperEnd" | "paperNearEnd" | "offline"; +type StatusFlag = + | "coverOpen" + | "cutterError" + | "paperEnd" + | "paperNearEnd" + | "offline"; type StatusFlags = Partial>; /** Label text on the status page (NBSP/space-normalised, lowercased) → our key. */ @@ -286,10 +64,20 @@ const STATUS_FIELDS: Record = { }; /** GET the status page over HTTP and return the raw HTML. */ -function fetchStatusPage(host: string, httpPort: number, timeoutMs: number): Promise { +function fetchStatusPage( + host: string, + httpPort: number, + timeoutMs: number, +): Promise { return new Promise((resolve, reject) => { 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) => { let data = ""; res.on("data", (c) => (data += c)); @@ -318,8 +106,15 @@ function parseStatusPage(html: string): StatusFlags { let m: RegExpExecArray | null; while ((m = rowRe.exec(html))) { if (m[1] === undefined || m[2] === undefined) continue; - const label = m[1].replace(/ /gi, " ").replace(/\s+/g, " ").trim().toLowerCase(); - const value = m[2].replace(/ /gi, " ").trim().toLowerCase(); + const label = m[1] + .replace(/ /gi, " ") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); + const value = m[2] + .replace(/ /gi, " ") + .trim() + .toLowerCase(); const key = STATUS_FIELDS[label]; if (key && (value === "yes" || value === "no")) { out[key] = value === "yes"; @@ -328,24 +123,6 @@ function parseStatusPage(html: string): StatusFlags { return out; } -/** TCP connect probe — the print socket has no status protocol we rely on. */ -function probe(host: string, port: number, timeoutMs: number): Promise { - 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 { readonly driverId = "rongta"; readonly #host: string; @@ -384,11 +161,19 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice { async printReport(report: PrintReport): Promise { 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 { - 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}`); } @@ -413,7 +198,13 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice { } 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); if (missing.length > 0) { return { @@ -434,7 +225,8 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice { return { status: faults.length > 0 ? "degraded" : "ready", ...flags, - detail: faults.length > 0 ? faults.map((f) => labels[f]).join(", ") : undefined, + detail: + faults.length > 0 ? faults.map((f) => labels[f]).join(", ") : undefined, checkedAt, }; } @@ -450,7 +242,10 @@ const roleField: ConfigField = { required: true, default: "entry-dispenser", 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)" }, ], 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", label: "Rongta 80mm thermal printer", 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"], configFields: [ 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, 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), }; diff --git a/packages/devices/src/index.ts b/packages/devices/src/index.ts index 2a2067f..4885a91 100644 --- a/packages/devices/src/index.ts +++ b/packages/devices/src/index.ts @@ -18,6 +18,7 @@ export { hikvisionDriver, dahuaDriver, rongtaDriver, + cashinoDriver, } from "./drivers/index.js"; export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js"; export { diff --git a/wiki/concepts/printer-status-monitoring.md b/wiki/concepts/printer-status-monitoring.md index dab9410..c416fa2 100644 --- a/wiki/concepts/printer-status-monitoring.md +++ b/wiki/concepts/printer-status-monitoring.md @@ -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 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) `readStatus()` maps to `ready | degraded | offline`: diff --git a/wiki/log.md b/wiki/log.md index 881a472..8f752e7 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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) 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.