31f116a068
The previous width fix also forced ALIGN_LEFT inside code128(), which moved the slip's barcode to the left. But the no-print bug was the barcode WIDTH (too wide to fit the head at module width 3), not the centering — at width 2 it fits and centers fine. So code128() no longer touches alignment; the caller controls it. The out-of-window slip block is ALIGN_CENTER, so the Code128 + QR center as they did before, just narrow enough (width 2, ~510 dots) to actually print. The voucher receipt barcode likewise centers as it originally did. Verified: alignment-in-effect at the barcode = CENTER, module width = 2, QR present; build+lint 14/14. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
574 lines
22 KiB
TypeScript
574 lines
22 KiB
TypeScript
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<string, number> = {
|
||
ë: 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<string, string> = {
|
||
ë: "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 <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}`,
|
||
// --- 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: <name>" 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<void> {
|
||
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<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";
|