Files
parking_solution/packages/devices/src/drivers/printer-escpos.ts
T
julian 011fe5a4c4
Build desktop / desktop (push) Successful in 4m16s
CI / check (push) Successful in 43s
Build & push images / images (push) Successful in 2m51s
fix(devices): USB truncation mode 2 — close() kills the in-flight usblp URB
The chunked-write fix (81bc2e3) still truncated on hardware: the lab
test slip stopped mid-sentence with no feed and no cut (text hidden
until the feed button). Verified against drivers/usb/class/usblp.c:

- write() returns at URB SUBMISSION, not completion;
- only ONE write URB is in flight (the next write EAGAINs until it
  completes);
- usblp_release() — our close() — KILLS in-flight URBs.

The printer drains bulk data at PRINT speed (tiny internal buffer on
these clones), so closing right after the last accepted write cancels
the still-transferring tail — exactly where the feed + GS V cut bytes
live. Kernel-accepted ≠ printer-received.

Fix: the one-URB rule makes acceptance of write N a completion
certificate for write N−1. writeAllUsb now writes the payload's FINAL
BYTE alone — its acceptance proves everything before it is physically
in the printer — then drains 300 ms for that single packet before the
caller closes. New test pins the final-byte-alone chunking; wiki
printer-usb-transport.md carries the kernel-level account.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 10:50:47 +02:00

792 lines
32 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 { open } from "node:fs/promises";
import { constants as FS } from "node:fs";
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());
});
}
// --- USB transport (kernel usblp character device) ----------------------------
// An ESC/POS USB printer plugged into the appliance enumerates as a character
// device (e.g. /dev/usb/lp0) via the in-box `usblp` kernel driver. We deliver the
// SAME ESC/POS byte stream there as over TCP — only the transport differs, not a
// single rendered byte. No libusb / CUPS / native addon: a plain file write keeps
// the MIT-only + offline-first, minimal-deps appliance constraints, and the path is
// a LOCAL char device the booth operator (the threat model's adversary) can't reach
// over the network. Paper/cover is NOT sensed here — same honesty floor as the
// Cashino TCP probe. usblp + a udev rule granting the server write access to the
// node are a provisioning dependency. See wiki/concepts/printer-usb-transport.md.
/** Bound a promise with a timeout — a wedged USB printer can block a write (or even
* the open) indefinitely, and a stuck print must surface as a failure rather than
* hang the entry flow. The underlying handle leaks on timeout, but the process is
* the appliance server; a failed print is logged and retried/failed-over upstream. */
function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error(msg)), ms);
p.then(
(v) => {
clearTimeout(t);
resolve(v);
},
(e) => {
clearTimeout(t);
reject(e as Error);
},
);
});
}
/** usblp accepts only what fits its kernel buffer (~8 KB) per write on a NONBLOCK fd,
* so jobs are pushed in chunks safely under that. */
const USB_WRITE_CHUNK = 4096;
/** Pause after the FINAL byte's write is accepted, before close. Its acceptance
* proves everything before it is physically in the printer (see writeAllUsb); this
* covers the one-byte URB still in flight — a single bulk packet the printer ACKs
* immediately (it just freed buffer space by ACKing the previous chunk). */
const USB_DRAIN_MS = 300;
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** The slice of FileHandle the USB write loop needs (injectable for tests — a real
* regular file can't reproduce the char device's partial writes / EAGAIN). */
export interface UsbWriteHandle {
write(buffer: Buffer, offset: number, length: number): Promise<{ bytesWritten: number }>;
}
/**
* Push the WHOLE payload through a non-blocking usblp fd AND ensure the printer has
* physically received it before the caller may close. TWO field-verified truncation
* modes on the ICS XP-K200L (same symptom: text head prints, barcode/feed/CUT tail
* lost; TCP fine):
*
* 1. SHORT WRITES (2026-07-06): a single fire-and-forget write() only delivers what
* the kernel accepts. Fix: chunked loop, retry EAGAIN, until all bytes accepted.
* 2. CLOSE CANCELS THE LAST TRANSFER (2026-07-07, lab bench): per usblp.c, write()
* returns at URB *submission*, only ONE write URB is in flight at a time, and
* usblp_release() (our close) KILLS in-flight URBs. The printer consumes bulk
* data at PRINT speed (tiny internal buffer), so closing right after the last
* accepted write cancels the still-transferring tail — which is exactly where
* the feed + GS V cut live ("have to press the feed button to see the text").
*
* The delivery guarantee follows from usblp's one-URB rule: ACCEPTANCE OF WRITE N
* PROVES WRITE N−1 FULLY COMPLETED (the driver EAGAINs until the previous URB's
* completion). So the payload is pushed as chunks, then its FINAL BYTE alone: when
* that 1-byte write is accepted, every byte before it is physically in the printer.
* A short drain pause then covers the lone final-byte URB (one bulk packet), and
* close is safe. `drainMs` is parameterised only for tests.
*/
export async function writeAllUsb(
handle: UsbWriteHandle,
payload: Buffer,
deadlineMs: number,
drainMs: number = USB_DRAIN_MS,
): Promise<void> {
if (payload.length === 0) return;
const lastByteAt = payload.length - 1;
let off = 0;
while (off < payload.length) {
if (Date.now() > deadlineMs) {
throw new Error(`usb write timeout (${off}/${payload.length} bytes accepted)`);
}
try {
// Never let the final byte ride a bigger chunk: it is written ALONE so its
// acceptance certifies delivery of everything before it (see doc above).
const len = off === lastByteAt ? 1 : Math.min(USB_WRITE_CHUNK, lastByteAt - off);
const { bytesWritten } = await handle.write(payload, off, len);
off += bytesWritten;
if (bytesWritten === 0) await delay(5); // buffer full, no error — breathe
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EAGAIN") {
await delay(10); // printer draining its buffer — retry until the deadline
} else {
throw err;
}
}
}
// All bytes accepted; only the 1-byte final URB can still be in flight. Give it a
// moment to land before the caller closes (close would cancel it).
await delay(drainMs);
}
/** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp
* is a RAW character device — no FIN/half-close dance (that was a TCP concern) —
* but delivery must go through the chunked loop above (see its doc for why). We
* always close the handle (even on a failed write). */
export async function sendRawUsb(
devicePath: string,
payload: Buffer,
timeoutMs: number,
): Promise<void> {
const handle = await withTimeout(
open(devicePath, FS.O_WRONLY | FS.O_NONBLOCK),
timeoutMs,
"usb open timeout",
);
try {
await writeAllUsb(handle, payload, Date.now() + timeoutMs);
} finally {
await handle.close();
}
}
/** Reachability for a USB printer: the floor is "does the char device exist and
* open writable". A present, openable /dev/usb/lp0 means usblp bound a powered,
* enumerated printer — the USB analogue of the TCP connect probe. (Like the Cashino
* TCP probe, this reports reachability only, never a guessed paper/cover state.) */
export async function probeUsb(devicePath: string, timeoutMs: number): Promise<void> {
const handle = await withTimeout(
open(devicePath, FS.O_WRONLY | FS.O_NONBLOCK),
timeoutMs,
"usb open timeout",
);
await handle.close();
}
// --- transport dispatch -------------------------------------------------------
// A discriminated transport so each driver resolves the wire ONCE (from config) and
// every print/probe call site stays transport-blind. Adding a transport = one more
// arm here + the render layer is untouched.
/** Where a printer's bytes go: a TCP raw-print socket, or a local USB char device. */
export type Transport =
| { kind: "tcp"; host: string; port: number }
| { kind: "usb"; devicePath: string };
/** Build a Transport from a driver's flat config. `transport: "usb"` selects the
* USB char device (`devicePath`, default /dev/usb/lp0); anything else is TCP
* (host + port, default 9100) — so existing network configs with no `transport`
* key keep working unchanged. */
export function transportFromConfig(config: {
transport?: unknown;
host?: unknown;
port?: unknown;
devicePath?: unknown;
}): Transport {
if (config.transport === "usb") {
return { kind: "usb", devicePath: String(config.devicePath ?? "/dev/usb/lp0") };
}
return {
kind: "tcp",
host: String(config.host),
port: config.port ? Number(config.port) : 9100,
};
}
/** Send an ESC/POS payload over whichever transport the printer is configured for. */
export function sendTo(t: Transport, payload: Buffer, timeoutMs: number): Promise<void> {
return t.kind === "usb"
? sendRawUsb(t.devicePath, payload, timeoutMs)
: sendRaw(t.host, t.port, payload, timeoutMs);
}
/** Reachability probe over whichever transport the printer is configured for. */
export function probeTo(t: Transport, timeoutMs: number): Promise<void> {
return t.kind === "usb"
? probeUsb(t.devicePath, timeoutMs)
: probe(t.host, t.port, timeoutMs);
}
/** Human label for a transport, for status detail / logs. */
export function transportLabel(t: Transport): string {
return t.kind === "usb" ? t.devicePath : `${t.host}:${t.port}`;
}
// --- 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";
// --- shared printer config fields (transport) ---------------------------------
// TCP-or-USB is offered identically across the ESC/POS drivers; defined here so each
// shares the exact field set. The setup wizard renders these generically.
import type { ConfigField } from "../registry.js";
/** Connection-transport select: network (raw TCP 9100) or local USB char device. */
export const transportField: ConfigField = {
key: "transport",
label: "Connection",
type: "select",
required: true,
default: "tcp-ip",
options: [
{ value: "tcp-ip", label: "Network (raw TCP)" },
{ value: "usb", label: "USB (local /dev/usb/lp0)" },
],
help: "USB drives a printer plugged into the appliance (usblp); Network drives one on the isolated device VLAN.",
};
/** USB character-device path; used only when transport=usb (ignored for TCP). */
export const devicePathField: ConfigField = {
key: "devicePath",
label: "USB device",
type: "string",
required: false,
default: "/dev/usb/lp0",
help: "Character device for a USB printer (usblp), e.g. /dev/usb/lp0. Only used when Connection is USB.",
};