import { Socket } from "node:net"; import type { PrintReport, ReceiptData, SubscriptionCardData, TicketData, WindowChargeNoticeData, } 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, Ë: 0xd3, // CP852 0xD3 = U+00CB Ë (0xEB is ű — wrong; fixed after a misprint) ç: 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", // Typographic punctuation that creeps in from composed strings — CP852 has no // em/en dash, ellipsis, curly quotes, or the warning sign, so without these they // print as "?" (the cause of the "PARKIM ? JASHTË ORARIT" misprint). Degrade to // the obvious ASCII equivalent rather than a literal "?". "—": "-", // em dash U+2014 "–": "-", // en dash U+2013 "…": "...", // ellipsis U+2026 "‘": "'", "’": "'", "“": '"', "”": '"', "⚠": "!", // warning sign U+26A0 — no glyph on a thermal head; "!" reads as a flag "•": "*", }; /** 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[] = []; // Intl.NumberFormat separates the amount from the currency with a NO-BREAK // SPACE (U+00A0) or NARROW NO-BREAK SPACE (U+202F); neither is in CP852, so // they'd print as "?". Normalise to a plain space (e.g. "1000 Lekë"). const normalised = text.replace(/[  ]/g, " "); for (const ch of normalised) { 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. * `moduleWidth` (narrow-bar dots, 1–6) trades scan-tolerance for total width: at ~11 * modules/char a 13-char id fits 80mm (576 printable dots) at width 3, but a ~20-char * id needs width 2 or it OVERFLOWS the head and the firmware silently aborts the * barcode (prints nothing). It does NOT set alignment — the caller does (a barcode that * FITS the head centers fine; the no-print bug was width, not centering). */ function code128(data: string, moduleWidth = 3): Buffer { // Code128 code set B (printable ASCII) — prefix the data with the {B selector. const payload = Buffer.from(`{B${data}`, "ascii"); const w = Math.max(1, Math.min(6, moduleWidth)); return Buffer.concat([ Buffer.from([GS, 0x68, 0x64]), // GS h 100 — barcode height = 100 dots (taller = tolerant of scan angle) Buffer.from([GS, 0x77, w]), // GS w n — module (narrow-bar) width in dots 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}`, // --- payment receipt --- /** Receipt title. */ receipt: "FATURË PAGESE", /** Voucher-mode title (the same slip self-exits). */ voucherTitle: "BILETË DALJE", /** "Entry:" — entry time row. */ entry: (v: string) => `Hyrja: ${v}`, /** "Paid:" — payment time row. */ paid: (v: string) => `Pagesa: ${v}`, /** "Duration:" — time parked. */ duration: (v: string) => `Kohëzgjatja: ${v}`, /** "Tender:" — cash/card. */ tender: (v: string) => `Mënyra: ${v}`, tenderCash: "Para në dorë", tenderCard: "Kartë", /** "Paid:" amount label (precedes the large total). */ amountLabel: "PAGUAR", /** Walk-back grace emphasis (voucher mode) — two short lines that each fit the * 80mm width, so neither wraps mid-word. */ graceLines: (min: number): readonly string[] => [ `Dilni brenda ${min} min.`, "Skanoni këtë biletë në dalje.", ], /** Thank-you footer. */ thanks: "Faleminderit!", // --- out-of-window advisory slip (subscriber) --- /** Slip title. ASCII dash (not em dash) so no codepage surprise. */ windowTitle: "PARKIM - JASHTË ORARIT", /** "Subscriber: " line. */ windowHolder: (name: string) => `Abonent: ${name}`, /** Entry-edge line; appends the window-open time when known. */ windowEnteredEarly: (opensHHMM?: string) => opensHHMM ? `Ka hyrë jashtë orarit (orari hap ${opensHHMM})` : "Ka hyrë jashtë orarit", /** Exit-edge line. */ windowExitedLate: "Ka dalë jashtë orarit", /** "Entry:" / "Exit:" stamp label per edge. */ windowStamp: (edge: "entry" | "exit", v: string) => (edge === "entry" ? `Hyrja: ${v}` : `Dalja: ${v}`), /** Two short lines (each fits 80mm) telling the customer a fee is pending and * is settled at the booth before exit. ASCII "!" flag (no glyph for ⚠). */ windowPending: ["! Detyrim do të llogaritet në dalje", " (paguhet në kabinë para se të dilni)"] as const, } as const; /** Format integer minor units + ISO-4217 currency as a major-unit string for the * printed receipt. Mirrors the booth UI's formatMoney (no float money model). */ function money(amountMinor: number, currency: string): string { const major = amountMinor / 100; try { return new Intl.NumberFormat("sq-AL", { style: "currency", currency, }).format(major); } catch { return `${major.toFixed(2)} ${currency}`; } } /** Human duration between two ISO times, e.g. "2h 14m" / "47m". Whole minutes, * mirroring the booth UI's formatDuration. */ function duration(fromIso: string, toIso: string): string { const ms = Date.parse(toIso) - Date.parse(fromIso); if (!Number.isFinite(ms) || ms < 0) return "—"; const mins = Math.floor(ms / 60_000); const h = Math.floor(mins / 60); const m = mins % 60; return h > 0 ? `${h}h ${m}m` : `${m}m`; } /** Albanian month names (customer-facing receipts are always Albanian — see * i18n.md). Indexed by Date.getMonth() (0 = Janar). */ const SQ_MONTHS = [ "Janar", "Shkurt", "Mars", "Prill", "Maj", "Qershor", "Korrik", "Gusht", "Shtator", "Tetor", "Nëntor", "Dhjetor", ] as const; /** Human local date+time for a receipt row, e.g. "19 Qershor 2026 10:48:25". The * host clock is the site's local time (the appliance runs in the site's zone); * 24-hour with seconds (Albania uses 24h). Falls back to the raw ISO on a bad date. * Exported (as formatStampSq) so other server-side printed output — e.g. the shift * Z-report — shares one Albanian date format. */ export function stamp(iso: string): string { const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; const p = (n: number) => String(n).padStart(2, "0"); const date = `${d.getDate()} ${SQ_MONTHS[d.getMonth()]} ${d.getFullYear()}`; const time = `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; return `${date} ${time}`; } /** Date-only Albanian format "19 Qershor 2026" (for subscription validity dates, * which are date strings with no time). Passes through a non-date value unchanged. */ function dateOnly(value: string): string { const d = new Date(value); if (Number.isNaN(d.getTime())) return value; return `${d.getDate()} ${SQ_MONTHS[d.getMonth()]} ${d.getFullYear()}`; } /** 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(stamp(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 ? dateOnly(data.validFrom) : "—", data.validTo ? dateOnly(data.validTo) : "—")), ); } parts.push(FEED_AND_CUT); return Buffer.concat(parts); } /** Build the ESC/POS byte stream for a PAYMENT RECEIPT. Header → title → the * transparency figures (entry / paid / duration / amount / tender). In voucher * mode it ALSO prints the scannable ticket-id barcode and emphasises the * walk-back grace, so the one slip both proves payment and self-exits at a * distant exit reader. Standalone (voucher=false) is detail-only. Albanian. */ export function renderReceipt(data: ReceiptData): Buffer { const parts: Buffer[] = [ INIT, SELECT_CP852, renderHeader(data.header), line(), ALIGN_CENTER, BOLD_ON, DOUBLE_ON, line(data.voucher ? STR.voucherTitle : STR.receipt), DOUBLE_OFF, BOLD_OFF, line(), ALIGN_LEFT, // The transparency figures. line(STR.entry(stamp(data.enteredAt))), line(STR.paid(stamp(data.paidAt))), line(STR.duration(duration(data.enteredAt, data.paidAt))), line( STR.tender(data.tender === "card" ? STR.tenderCard : STR.tenderCash), ), line(), // The amount, large and centred. ALIGN_CENTER, line(STR.amountLabel), BOLD_ON, DOUBLE_ON, line(money(data.amountMinor, data.currency)), DOUBLE_OFF, BOLD_OFF, line(), ]; if (data.voucher) { // The same ticket id, scannable at the exit reader, + the grace emphasis. The block // is ALIGN_CENTER (set at the amount above), so the barcode + id center as before. parts.push( code128(data.ticketId), line(), line(data.ticketId), line(), ); if (data.graceExitMin != null && data.graceExitMin > 0) { parts.push( BOLD_ON, ...STR.graceLines(data.graceExitMin).map((l) => line(l)), BOLD_OFF, ); } } parts.push(line(), line(STR.thanks), FEED_AND_CUT); return Buffer.concat(parts); } /** Build the ESC/POS byte stream for the ADVISORY out-of-window slip. Header → * title → a SCANNABLE Code128 + QR of the occurrence id (so the operator scans it * straight into the booth pay modal — same path as a transient ticket) → the id in * text (hand-key fallback) → holder + entry/exit stamp → the "pay at booth" notice. * Carries NO amount (the booth quotes the combined charge at settlement). Albanian. */ export function renderWindowChargeNotice(data: WindowChargeNoticeData): Buffer { const hhmm = (m?: number | null) => m == null ? undefined : `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`; const parts: Buffer[] = [ INIT, SELECT_CP852, renderHeader(data.header), line(), ALIGN_CENTER, BOLD_ON, line(STR.windowTitle), BOLD_OFF, line(), // The occurrence id, scannable two ways, both CENTERED (the surrounding block is // ALIGN_CENTER). Code128 for a 1D laser scanner FIRST — module width 2 because the // ~20-char occurrence id is too wide to fit the 80mm head at width 3 (the firmware // would abort it); at width 2 (~510 dots) it fits and centers fine. Then the QR for // the booth's combo reader. Either pulls the occurrence up in the pay modal. code128(data.occurrenceId, 2), line(), qrCode(data.occurrenceId), line(), // The id in text, as the hand-key fallback if neither scans. line(data.occurrenceId), ALIGN_LEFT, line(), ]; if (data.holderName) parts.push(line(STR.windowHolder(data.holderName))); parts.push(line(STR.windowStamp(data.edge, stamp(data.at)))); parts.push( line(data.edge === "entry" ? STR.windowEnteredEarly(hhmm(data.windowOpensMin)) : STR.windowExitedLate), line(), BOLD_ON, ...STR.windowPending.map((l) => line(l)), BOLD_OFF, FEED_AND_CUT, ); return Buffer.concat(parts); } /** Open a TCP socket, write the bytes, and close GRACEFULLY so the printer reads the * whole stream before the connection tears down. * * Why not write-then-destroy: a Socket.write() callback fires when the data reaches * the local kernel buffer, NOT when the peer has read it. Calling destroy() at that * point sends a TCP RST that can truncate the job in flight — the printer then has a * desynced ESC/POS stream and prints raster garbage (solid black bars / banding). * Instead we `end(payload)` (write + FIN) and wait for the socket to fully close, * which only happens after the peer has drained our bytes and the FIN is acked. */ export function sendRaw( host: string, port: number, payload: Buffer, timeoutMs: number, ): Promise { return new Promise((resolve, reject) => { const sock = new Socket(); let settled = false; // True once the payload + FIN have been handed off (flushed locally). After this, // we've done our part; a slow/absent peer-FIN should NOT fail an already-sent job. let written = false; const fail = (err: Error) => { if (settled) return; settled = true; sock.destroy(); reject(err); }; const succeed = () => { if (settled) return; settled = true; sock.destroy(); resolve(); }; sock.setTimeout(timeoutMs); // A timeout BEFORE the bytes are out is a real failure; one AFTER (some printers // never send their FIN, holding the socket open) means the job was delivered — // succeed rather than reject a ticket that already printed. sock.on("timeout", () => (written ? succeed() : fail(new Error("timeout")))); sock.on("error", fail); // `close` fires after the bytes are flushed AND the connection is fully torn down // (our FIN sent, peer's FIN received) — the job has been delivered by then. sock.on("close", (hadError) => (hadError ? undefined : succeed())); sock.connect(port, host, () => { // end() writes the payload then sends FIN — a graceful half-close that lets the // printer finish reading before the socket closes. No abrupt destroy(). The // write callback confirms the bytes left our buffer. sock.end(payload, () => { written = true; }); }); }); } /** 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";