bbf61c48df
The Cashino KP-300H printed entry tickets as raster garbage (solid black
bars / banding) while the Rongta printed the same byte stream fine. Root
cause: the barcode overflowed the print line, not data corruption.
A 13-digit Code128 at module width 3 is ~534 dots. The KP-300H prints 72mm
(512 usable dots at 203 dpi), so the symbol overran the line and the firmware
rendered the overflow as raster noise. The Rongta runs 80mm (576 dots) and had
just enough room — which is why only the Cashino failed. Confirmed on hardware:
plain text printed clean, the barcode was the trigger, and an 11-digit code at
width 3 (~468 dots) both fits and scans the full value at the exit reader.
- Ticket IDs reduced 13 → 11 digits (10 random + Luhn). Length is driven by
guess-resistance (10^10 space, ~1-in-10^7 to hit a live OPEN ticket even with
thousands parked — the booth-operator threat model), not volume.
- validateTicketCode is now length-agnostic (\d{10,14} + Luhn) so legacy
13-digit tickets still in circulation keep validating; the id stays opaque.
Also: sendRaw now closes the print socket GRACEFULLY (end()+FIN, wait for
close) instead of write-then-destroy, which could RST mid-stream and truncate a
job. A separate latent bug found while diagnosing, fixed here.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
493 lines
18 KiB
TypeScript
493 lines
18 KiB
TypeScript
import { Socket } from "node:net";
|
||
import type {
|
||
PrintReport,
|
||
ReceiptData,
|
||
SubscriptionCardData,
|
||
TicketData,
|
||
} from "../interfaces.js";
|
||
|
||
// Shared ESC/POS rendering + raw-TCP transport for 80mm thermal printers.
|
||
// Rongta RP-series, Cashino, and the many OEM clones all speak ESC/POS over a
|
||
// raw TCP socket on port 9100 (the JetDirect/RAW convention) with no auth on the
|
||
// print socket — they live on the isolated device VLAN. The BYTE STREAM is
|
||
// identical across these clones; what differs is live status reporting (the
|
||
// Rongta board serves a decoded status page; the Cashino does not), so status
|
||
// stays in each driver while the rendering/transport live here.
|
||
// See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md.
|
||
|
||
// --- ESC/POS command bytes ----------------------------------------------------
|
||
const ESC = 0x1b;
|
||
const GS = 0x1d;
|
||
const LF = 0x0a;
|
||
|
||
const INIT = Buffer.from([ESC, 0x40]); // ESC @ — reset to power-on defaults
|
||
const ALIGN_CENTER = Buffer.from([ESC, 0x61, 0x01]); // ESC a 1
|
||
const ALIGN_LEFT = Buffer.from([ESC, 0x61, 0x00]); // ESC a 0
|
||
const BOLD_ON = Buffer.from([ESC, 0x45, 0x01]); // ESC E 1
|
||
const BOLD_OFF = Buffer.from([ESC, 0x45, 0x00]); // ESC E 0
|
||
const DOUBLE_ON = Buffer.from([GS, 0x21, 0x11]); // GS ! — double width+height
|
||
const DOUBLE_OFF = Buffer.from([GS, 0x21, 0x00]);
|
||
const FEED_AND_CUT = Buffer.from([ESC, 0x64, 0x04, GS, 0x56, 0x42, 0x00]); // feed 4, GS V B 0 partial cut
|
||
|
||
// Select code page 852 (Latin-2) for the character set: ESC t n, n=18 (0x12).
|
||
// CP852 carries the Albanian letters we print (ë, ç, …); without it the printer
|
||
// would interpret our high bytes as CP437 glyphs. Sent in every print's INIT
|
||
// preamble. See wiki/concepts/site-metadata.md (i18n / codepage).
|
||
const SELECT_CP852 = Buffer.from([ESC, 0x74, 0x12]);
|
||
|
||
// Minimal Unicode → CP852 byte map for the characters Albanian text actually uses
|
||
// beyond ASCII. Anything not listed is transliterated to an ASCII fallback (below)
|
||
// so we never emit a byte that renders as the wrong glyph. Extend as needed.
|
||
const CP852: Record<string, number> = {
|
||
ë: 0x89,
|
||
Ë: 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",
|
||
};
|
||
|
||
/** 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. */
|
||
function code128(data: string): Buffer {
|
||
// Code128 code set B (printable ASCII) — prefix the data with the {B selector.
|
||
const payload = Buffer.from(`{B${data}`, "ascii");
|
||
return Buffer.concat([
|
||
Buffer.from([GS, 0x68, 0x64]), // GS h 100 — barcode height = 100 dots (taller = tolerant of scan angle)
|
||
Buffer.from([GS, 0x77, 0x03]), // GS w 3 — module width = 3 (wider bars for the short-range "Simple" QR/barcode engine; 13-digit Code128 ≈ 495/576 dots, fits 80mm with quiet zones)
|
||
Buffer.from([GS, 0x48, 0x00]), // GS H 0 — HRI text off (we print the id ourselves)
|
||
// GS k 73 n <data> — function B form: 73 = Code128, n = data byte length.
|
||
Buffer.from([GS, 0x6b, 0x49, payload.length]),
|
||
payload,
|
||
]);
|
||
}
|
||
|
||
// --- 2D QR symbol (printer-generated via ESC/POS GS ( k) -----------------------
|
||
// A true QR for the SUBSCRIPTION card — the subscriber scans it at the reader (which
|
||
// reads QR + 1D barcode) every entry/exit for the coverage period. The board renders
|
||
// the QR from these GS ( k commands (no bitmap, no dependency), same approach as
|
||
// code128. We also print the code as text below as the hand-key fallback.
|
||
|
||
/** A QR code via ESC/POS `GS ( k`. `size` = module dot size (1–16; 6 ≈ readable on
|
||
* 80mm at short range). Error-correction level M (15%) — robust to a smudged print. */
|
||
function qrCode(data: string, size = 6): Buffer {
|
||
const bytes = Buffer.from(data, "ascii");
|
||
// pL/pH encode the data length + 3 (the cn,fn,m header bytes) for function 180.
|
||
const store = bytes.length + 3;
|
||
const pL = store & 0xff;
|
||
const pH = (store >> 8) & 0xff;
|
||
return Buffer.concat([
|
||
// fn 165: select QR model — 1d 28 6b 04 00 31 41 <model=50(2)> 00
|
||
Buffer.from([GS, 0x28, 0x6b, 0x04, 0x00, 0x31, 0x41, 0x32, 0x00]),
|
||
// fn 167: module size — 1d 28 6b 03 00 31 43 <size>
|
||
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x43, size]),
|
||
// fn 169: error correction level — 1d 28 6b 03 00 31 45 <49=M>
|
||
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x45, 0x31]),
|
||
// fn 180: store the symbol data — 1d 28 6b pL pH 31 50 30 <data>
|
||
Buffer.from([GS, 0x28, 0x6b, pL, pH, 0x31, 0x50, 0x30]),
|
||
bytes,
|
||
// fn 181: print the stored symbol — 1d 28 6b 03 00 31 51 30
|
||
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30]),
|
||
]);
|
||
}
|
||
|
||
// Ticket/receipt strings — Albanian (the site prints in Albanian for now). Kept in
|
||
// one place so a real i18n layer (per-locale tables + a t() helper) can replace this
|
||
// later without touching the render functions. See wiki/concepts/site-metadata.md.
|
||
const STR = {
|
||
/** NIUS label prefix; printed only when the park has a NIUS. */
|
||
nius: (v: string) => `NIUS: ${v}`,
|
||
/** "Printed at:" — precedes the issue timestamp. */
|
||
issuedAt: (v: string) => `Printuar më: ${v}`,
|
||
/** Subscription-card title. */
|
||
subscription: "ABONIM",
|
||
/** "Holder: <name>" line on the card. */
|
||
holder: (name: string) => `Mbajtësi: ${name}`,
|
||
/** "Valid: <from> – <to>" line on the card. */
|
||
validity: (from: string, to: string) => `Vlen: ${from} – ${to}`,
|
||
phone: (v: string) => `TEL: ${v}`,
|
||
// --- 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!",
|
||
} 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.
|
||
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);
|
||
}
|
||
|
||
/** 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";
|