fix(devices): Cashino printer — ping-only driver (no false status) + Albanian role wording
The Cashino 80mm printer reported wrong status: it ran on the `rongta`
driver, whose readStatus() scrapes the Rongta board's /prn_stat.htm status
page — which the Cashino does not serve — yielding a bogus degraded/page-
error verdict while the printer was online and printing fine. Root cause:
the Cashino is an ESC/POS PRINT clone with no trustworthy STATUS mechanism.
Fix: extract the shared ESC/POS rendering + transport (renderTicket/
renderReport/renderSubscriptionCard/sendRaw/probe + CP852 map + code128/
qrCode) from printer-rongta into drivers/printer-escpos.ts, and add a
dedicated `cashino` driver that reuses that print path but is deliberately
NOT MonitorableDevice (no readStatus). isMonitorable() is then false, so the
device monitor falls back to healthCheck() — a plain TCP reachability ping:
reachable -> ready, unreachable -> offline, never a guessed paper/cover
state it cannot sense. Rongta driver unchanged (still scrapes its page,
still monitorable). Register + re-export cashinoDriver.
Verified at runtime (cashino registered, isMonitorable=false, no readStatus,
healthCheck->offline on unreachable) and live: /api/devices/status shows both
printers ready (lane via ping, booth via page). The live entry-dispenser at
10.0.10.9 was switched rongta->cashino in the operator DB (backed up).
Also fix the Albanian device-role chip wording, which read wrong as a
"{category} {role}" label: access mixed "i përzier" -> "hyrje/dalje"
(it means a barrier spanning both directions); printer lane "korsia" ->
"në korsi"; booth "kabina" -> "në kabinë". English tidied to match
(mixed->entry/exit, lane->at lane, booth->at booth).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -5,6 +5,7 @@ import { registry } from "../registry.js";
|
||||
import { dingtianDriver } from "./access-dingtian.js";
|
||||
import { stubAccessDriver } from "./access-stub.js";
|
||||
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
||||
import { cashinoDriver } from "./printer-cashino.js";
|
||||
import { rongtaDriver } from "./printer-rongta.js";
|
||||
import { geeQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
||||
|
||||
@@ -22,6 +23,7 @@ export function registerBuiltinDrivers(): void {
|
||||
registry.register(hikvisionDriver);
|
||||
registry.register(dahuaDriver);
|
||||
registry.register(rongtaDriver);
|
||||
registry.register(cashinoDriver);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -33,4 +35,5 @@ export {
|
||||
hikvisionDriver,
|
||||
dahuaDriver,
|
||||
rongtaDriver,
|
||||
cashinoDriver,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type {
|
||||
DeviceHealth,
|
||||
PrinterDevice,
|
||||
PrintReport,
|
||||
SubscriptionCardData,
|
||||
TicketData,
|
||||
} from "../interfaces.js";
|
||||
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
||||
import { hostField, portField, stubLog } from "./common.js";
|
||||
import {
|
||||
probe,
|
||||
renderReport,
|
||||
renderSubscriptionCard,
|
||||
renderTicket,
|
||||
sendRaw,
|
||||
} from "./printer-escpos.js";
|
||||
|
||||
// Cashino 80mm network thermal printer driver. The Cashino is an ESC/POS clone:
|
||||
// it PRINTS identically to the Rongta (same byte stream — see ./printer-escpos.ts),
|
||||
// so tickets, reports and subscription cards render the same. What it does NOT
|
||||
// have is the Rongta board's decoded status web page (/prn_stat.htm). It cannot
|
||||
// report paper-out / cover-open / cutter faults in a form we trust.
|
||||
//
|
||||
// Therefore this driver deliberately does NOT implement MonitorableDevice
|
||||
// (no readStatus). The device monitor then falls back to the generic
|
||||
// `healthCheck()` — a plain TCP reachability PING of the print socket. So the
|
||||
// booth footer shows this printer as "ready" when it's reachable and "offline"
|
||||
// when it isn't, and never a wrong paper/cover verdict it cannot actually sense.
|
||||
// (Reusing the Rongta driver made it scrape a status page the Cashino doesn't
|
||||
// serve, producing the bogus "degraded" feedback this driver fixes.)
|
||||
//
|
||||
// No auth on the print socket — like the other field devices it lives on the
|
||||
// isolated device VLAN. Roles + failover work exactly as for the Rongta
|
||||
// (entry-dispenser / booth-receipt + failoverRank); the server owns selection.
|
||||
// See wiki/concepts/printer-status-monitoring.md and printer-roles-failover.md.
|
||||
|
||||
class CashinoPrinter implements PrinterDevice {
|
||||
readonly driverId = "cashino";
|
||||
readonly #host: string;
|
||||
readonly #port: number;
|
||||
readonly #timeout: number;
|
||||
|
||||
constructor(config: DeviceConfig) {
|
||||
this.#host = String(config.host);
|
||||
this.#port = config.port ? Number(config.port) : 9100;
|
||||
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
await this.healthCheck();
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
stubLog(this.driverId, "disconnect");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reachability only — a TCP connect probe of the raw print socket. The Cashino
|
||||
* has no trustworthy status protocol, so this is the floor and the ceiling of
|
||||
* what we report: reachable → ready, unreachable → offline. Deliberately NO
|
||||
* readStatus(): the monitor uses this for the traffic-light, never a guessed
|
||||
* paper/cover state.
|
||||
*/
|
||||
async healthCheck(): Promise<DeviceHealth> {
|
||||
try {
|
||||
await probe(this.#host, this.#port, this.#timeout);
|
||||
return { status: "ready" };
|
||||
} catch (err) {
|
||||
return { status: "offline", detail: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
async printTicket(data: TicketData): Promise<void> {
|
||||
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout);
|
||||
stubLog(this.driverId, `printed ticket ${data.ticketId}`);
|
||||
}
|
||||
|
||||
async printReport(report: PrintReport): Promise<void> {
|
||||
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout);
|
||||
stubLog(
|
||||
this.driverId,
|
||||
`printed report "${report.title}" (${report.lines.length} lines)`,
|
||||
);
|
||||
}
|
||||
|
||||
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
|
||||
await sendRaw(
|
||||
this.#host,
|
||||
this.#port,
|
||||
renderSubscriptionCard(data),
|
||||
this.#timeout,
|
||||
);
|
||||
stubLog(this.driverId, `printed subscription card ${data.code}`);
|
||||
}
|
||||
}
|
||||
|
||||
const roleField: ConfigField = {
|
||||
key: "role",
|
||||
label: "Role",
|
||||
type: "select",
|
||||
required: true,
|
||||
default: "entry-dispenser",
|
||||
options: [
|
||||
{
|
||||
value: "entry-dispenser",
|
||||
label: "Entry dispenser (outside / at the lane)",
|
||||
},
|
||||
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" },
|
||||
],
|
||||
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.",
|
||||
};
|
||||
|
||||
const rankField: ConfigField = {
|
||||
key: "failoverRank",
|
||||
label: "Failover rank",
|
||||
type: "number",
|
||||
required: false,
|
||||
default: 0,
|
||||
help: "Higher = tried first within the same role. The booth printer also backs up the entry dispenser.",
|
||||
};
|
||||
|
||||
export const cashinoDriver: PrinterDriver = {
|
||||
id: "cashino",
|
||||
category: "printer",
|
||||
label: "Cashino 80mm thermal printer",
|
||||
description:
|
||||
"Cashino 80mm thermal printer (ESC/POS over raw TCP, port 9100). Prints like the Rongta but has no status page — monitored by reachability ping only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
|
||||
transports: ["tcp-ip"],
|
||||
configFields: [
|
||||
hostField,
|
||||
{
|
||||
...portField(9100),
|
||||
required: false,
|
||||
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100).",
|
||||
},
|
||||
roleField,
|
||||
rankField,
|
||||
{
|
||||
key: "timeoutMs",
|
||||
label: "Timeout (ms)",
|
||||
type: "number",
|
||||
required: false,
|
||||
default: 3000,
|
||||
},
|
||||
],
|
||||
create: (c) => new CashinoPrinter(c),
|
||||
};
|
||||
@@ -0,0 +1,309 @@
|
||||
import { Socket } from "node:net";
|
||||
import type {
|
||||
PrintReport,
|
||||
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,
|
||||
Ë: 0xeb,
|
||||
ç: 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[] = [];
|
||||
for (const ch of text) {
|
||||
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}`,
|
||||
} as const;
|
||||
|
||||
/** 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(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 ?? "—", data.validTo ?? "—")));
|
||||
}
|
||||
parts.push(FEED_AND_CUT);
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
/** Open a TCP socket, write the bytes, wait for flush, then close. */
|
||||
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;
|
||||
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, () => {
|
||||
sock.write(payload, (err) => (err ? done(err) : done()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 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";
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Socket } from "node:net";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import type {
|
||||
Device,
|
||||
@@ -12,11 +11,21 @@ import type {
|
||||
} from "../interfaces.js";
|
||||
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
||||
import { hostField, portField, stubLog } from "./common.js";
|
||||
import {
|
||||
probe,
|
||||
renderReport,
|
||||
renderSubscriptionCard,
|
||||
renderTicket,
|
||||
sendRaw,
|
||||
} from "./printer-escpos.js";
|
||||
|
||||
// Rongta 80mm network thermal printer driver. Rongta RP-series printers (and the
|
||||
// many OEM clones that share their firmware) speak ESC/POS over a raw TCP socket
|
||||
// on port 9100 — the JetDirect/RAW convention. There is no auth on the print
|
||||
// socket; like the other field devices it lives on the isolated device VLAN.
|
||||
// on port 9100 — the JetDirect/RAW convention. The ESC/POS rendering + transport
|
||||
// are shared with the other ESC/POS clones in ./printer-escpos.ts; what is unique
|
||||
// to Rongta — and lives here — is LIVE STATUS via the board's own status web page.
|
||||
// There is no auth on the print socket; like the other field devices it lives on
|
||||
// the isolated device VLAN.
|
||||
// See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md.
|
||||
//
|
||||
// ROLES + FAILOVER: a lane has more than one printer. Each instance declares a
|
||||
@@ -27,253 +36,22 @@ import { hostField, portField, stubLog } from "./common.js";
|
||||
// itself is role-agnostic; the role/rank live in config and the caller (server)
|
||||
// owns the failover selection. See wiki/concepts/printer-roles-failover.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, Ë: 0xeb,
|
||||
ç: 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[] = [];
|
||||
for (const ch of text) {
|
||||
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 Rongta 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. (A QR for phone scanning may
|
||||
// be added later behind an admin toggle.)
|
||||
|
||||
/** 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. The QR
|
||||
// "model 2" sequence: set model → set module size → set error-correction → store the
|
||||
// data in symbol storage → print it. See ESC/POS GS ( k (function 165/167/169/180/181).
|
||||
|
||||
/** 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}`,
|
||||
/** "Lost your ticket? <phone>" footer; printed only when a phone is set. */
|
||||
lostTicket: (phone: string) => `Keni humbur biletën? ${phone}`,
|
||||
/** 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}`,
|
||||
} as const;
|
||||
|
||||
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */
|
||||
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()));
|
||||
}
|
||||
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 → optional lost-ticket footer. 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. */
|
||||
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(data.issuedAt)),
|
||||
// Contact footer (lost-ticket help) if a phone is set.
|
||||
...(data.header?.phone ? [line(STR.lostTicket(data.header.phone))] : []),
|
||||
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. */
|
||||
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 ?? "—", data.validTo ?? "—")));
|
||||
}
|
||||
parts.push(FEED_AND_CUT);
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
/** Open a TCP socket, write the bytes, wait for flush, then close. */
|
||||
function sendRaw(host: string, port: number, payload: Buffer, 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, () => {
|
||||
sock.write(payload, (err) => (err ? done(err) : done()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- live status via the device's own status web page -------------------------
|
||||
// The Rongta board serves /prn_stat.htm, a small HTML table where the DEVICE has
|
||||
// already decoded the ESC/POS status bits into labelled Yes/No rows. We scrape
|
||||
// that rather than send raw `DLE EOT` ourselves: on this clone the DLE EOT reply
|
||||
// bytes don't follow the canonical bit layout (verified on hardware), so trusting
|
||||
// the device's own decode is the safe choice. See printer-status-monitoring.md.
|
||||
// A clone that does NOT serve this page (e.g. the Cashino) uses its own driver
|
||||
// with a plain reachability probe — it must not pretend to report paper/cover.
|
||||
|
||||
/** The fault flags the status page reports (a subset of PrinterStatus). */
|
||||
type StatusFlag = "coverOpen" | "cutterError" | "paperEnd" | "paperNearEnd" | "offline";
|
||||
type StatusFlag =
|
||||
| "coverOpen"
|
||||
| "cutterError"
|
||||
| "paperEnd"
|
||||
| "paperNearEnd"
|
||||
| "offline";
|
||||
type StatusFlags = Partial<Record<StatusFlag, boolean>>;
|
||||
|
||||
/** Label text on the status page (NBSP/space-normalised, lowercased) → our key. */
|
||||
@@ -286,10 +64,20 @@ const STATUS_FIELDS: Record<string, StatusFlag> = {
|
||||
};
|
||||
|
||||
/** GET the status page over HTTP and return the raw HTML. */
|
||||
function fetchStatusPage(host: string, httpPort: number, timeoutMs: number): Promise<string> {
|
||||
function fetchStatusPage(
|
||||
host: string,
|
||||
httpPort: number,
|
||||
timeoutMs: number,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = httpRequest(
|
||||
{ host, port: httpPort, path: "/prn_stat.htm", method: "GET", timeout: timeoutMs },
|
||||
{
|
||||
host,
|
||||
port: httpPort,
|
||||
path: "/prn_stat.htm",
|
||||
method: "GET",
|
||||
timeout: timeoutMs,
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (c) => (data += c));
|
||||
@@ -318,8 +106,15 @@ function parseStatusPage(html: string): StatusFlags {
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = rowRe.exec(html))) {
|
||||
if (m[1] === undefined || m[2] === undefined) continue;
|
||||
const label = m[1].replace(/ /gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
|
||||
const value = m[2].replace(/ /gi, " ").trim().toLowerCase();
|
||||
const label = m[1]
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const value = m[2]
|
||||
.replace(/ /gi, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const key = STATUS_FIELDS[label];
|
||||
if (key && (value === "yes" || value === "no")) {
|
||||
out[key] = value === "yes";
|
||||
@@ -328,24 +123,6 @@ function parseStatusPage(html: string): StatusFlags {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** TCP connect probe — the print socket has no status protocol we rely on. */
|
||||
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());
|
||||
});
|
||||
}
|
||||
|
||||
class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
||||
readonly driverId = "rongta";
|
||||
readonly #host: string;
|
||||
@@ -384,11 +161,19 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
||||
|
||||
async printReport(report: PrintReport): Promise<void> {
|
||||
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout);
|
||||
stubLog(this.driverId, `printed report "${report.title}" (${report.lines.length} lines)`);
|
||||
stubLog(
|
||||
this.driverId,
|
||||
`printed report "${report.title}" (${report.lines.length} lines)`,
|
||||
);
|
||||
}
|
||||
|
||||
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
|
||||
await sendRaw(this.#host, this.#port, renderSubscriptionCard(data), this.#timeout);
|
||||
await sendRaw(
|
||||
this.#host,
|
||||
this.#port,
|
||||
renderSubscriptionCard(data),
|
||||
this.#timeout,
|
||||
);
|
||||
stubLog(this.driverId, `printed subscription card ${data.code}`);
|
||||
}
|
||||
|
||||
@@ -413,7 +198,13 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
||||
}
|
||||
|
||||
const flags = parseStatusPage(html);
|
||||
const expected: StatusFlag[] = ["coverOpen", "cutterError", "paperEnd", "paperNearEnd", "offline"];
|
||||
const expected: StatusFlag[] = [
|
||||
"coverOpen",
|
||||
"cutterError",
|
||||
"paperEnd",
|
||||
"paperNearEnd",
|
||||
"offline",
|
||||
];
|
||||
const missing = expected.filter((k) => flags[k] === undefined);
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
@@ -434,7 +225,8 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
||||
return {
|
||||
status: faults.length > 0 ? "degraded" : "ready",
|
||||
...flags,
|
||||
detail: faults.length > 0 ? faults.map((f) => labels[f]).join(", ") : undefined,
|
||||
detail:
|
||||
faults.length > 0 ? faults.map((f) => labels[f]).join(", ") : undefined,
|
||||
checkedAt,
|
||||
};
|
||||
}
|
||||
@@ -450,7 +242,10 @@ const roleField: ConfigField = {
|
||||
required: true,
|
||||
default: "entry-dispenser",
|
||||
options: [
|
||||
{ value: "entry-dispenser", label: "Entry dispenser (outside / at the lane)" },
|
||||
{
|
||||
value: "entry-dispenser",
|
||||
label: "Entry dispenser (outside / at the lane)",
|
||||
},
|
||||
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" },
|
||||
],
|
||||
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.",
|
||||
@@ -470,15 +265,32 @@ export const rongtaDriver: PrinterDriver = {
|
||||
category: "printer",
|
||||
label: "Rongta 80mm thermal printer",
|
||||
description:
|
||||
"Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones) over raw TCP (port 9100). No auth on the print socket — isolate the VLAN.",
|
||||
"Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones that serve the /prn_stat.htm status page) over raw TCP (port 9100). No auth on the print socket — isolate the VLAN.",
|
||||
transports: ["tcp-ip"],
|
||||
configFields: [
|
||||
hostField,
|
||||
{ ...portField(9100), required: false, help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100)." },
|
||||
{ key: "httpPort", label: "Status web port", type: "port", required: false, default: 80, help: "Device status page (/prn_stat.htm) port for live monitoring (default 80)." },
|
||||
{
|
||||
...portField(9100),
|
||||
required: false,
|
||||
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100).",
|
||||
},
|
||||
{
|
||||
key: "httpPort",
|
||||
label: "Status web port",
|
||||
type: "port",
|
||||
required: false,
|
||||
default: 80,
|
||||
help: "Device status page (/prn_stat.htm) port for live monitoring (default 80).",
|
||||
},
|
||||
roleField,
|
||||
rankField,
|
||||
{ key: "timeoutMs", label: "Timeout (ms)", type: "number", required: false, default: 3000 },
|
||||
{
|
||||
key: "timeoutMs",
|
||||
label: "Timeout (ms)",
|
||||
type: "number",
|
||||
required: false,
|
||||
default: 3000,
|
||||
},
|
||||
],
|
||||
create: (c) => new RongtaPrinter(c),
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
hikvisionDriver,
|
||||
dahuaDriver,
|
||||
rongtaDriver,
|
||||
cashinoDriver,
|
||||
} from "./drivers/index.js";
|
||||
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
|
||||
export {
|
||||
|
||||
Reference in New Issue
Block a user