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";