Files
parking_solution/packages/devices/src/drivers/printer-escpos.ts
T
julian 663bf0e925 fix(print): out-of-window slip Code128 was too wide to print — width 2 + left-align
The slip printed the QR but NOT the Code128 barcode on the Rongta. Root cause:
the ~20-char occurrence id (SUBSESS-…) at module width 3 is ~765 dots wide —
over the 80mm head's ~576 printable dots — so the firmware silently aborts the
barcode (prints nothing). It was also emitted while ALIGN_CENTER (set for the
title) was active, which shifts the start point right and makes it overflow
even sooner. The QR, being compact, rendered fine — hence QR-only output.

code128() now takes a moduleWidth (default 3, so the shorter entry-ticket id is
unchanged) and forces ALIGN_LEFT (a wide barcode must hug the margin). The slip
passes width 2 (~510 dots — fits with quiet zones) and re-centers the QR/text
after. renderReceipt's voucher barcode re-asserts ALIGN_CENTER for the lines
that follow it.

Verified: width n=2 in the byte stream, est 510 dots; Code128 + QR both present;
build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 13:44:01 +02:00

578 lines
22 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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). Keep the symbol LEFT-aligned — centering shifts the start
* point right and makes a wide barcode overflow even sooner. */
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([
ALIGN_LEFT, // a wide barcode must start at the left margin to fit the 80mm head
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. code128
// forces ALIGN_LEFT (a wide barcode must hug the margin); restore centering for the
// human-readable id + grace lines that follow.
parts.push(
code128(data.ticketId),
ALIGN_CENTER,
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. Code128 for a 1D laser scanner FIRST —
// module width 2 + left-aligned (code128 forces ALIGN_LEFT) because the ~20-char
// occurrence id is too wide to fit the 80mm head at width 3 (the firmware would
// abort it). Then the QR for the booth's combo reader. Either pulls the occurrence
// up in the pay modal.
code128(data.occurrenceId, 2),
line(),
ALIGN_CENTER,
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";