feat(printer): USB transport behind the ESC/POS render layer

The ESC/POS printer drivers were TCP-only — every path went through
sendRaw/probe to a raw socket on port 9100. Add a USB transport behind
the existing render layer without touching a single render*() function.

- printer-escpos.ts: sendRawUsb/probeUsb write the same ESC/POS bytes to a
  kernel usblp char device (/dev/usb/lp0) via a plain fs write — no
  libusb/CUPS/native dep (keeps MIT-only + minimal-deps appliance). A
  discriminated Transport + transportFromConfig/sendTo/probeTo dispatch the
  wire; anything not transport:"usb" is TCP, so existing host-only configs
  need no migration. Shared transportField/devicePathField config fields.
- cashino + rongta resolve a Transport once; both are reachability-only over
  USB, and the Rongta's HTTP status page degrades to the open-the-node probe
  over USB (no guessed paper/cover — the standing honesty rule). host/port
  made not-required so a USB printer needs neither.
- Tests: printer-escpos.test.ts (USB writes the exact rendered bytes; probe
  present/absent; transportFromConfig TCP back-compat) + printer-cashino.test.ts
  (USB-configured driver prints to the node, ready/offline).

USB itself is unverified on hardware (the on-site printers are networked);
the appliance-side usblp + udev provisioning is tracked as open-questions #14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-24 20:32:10 +02:00
parent 5a5fedf4f4
commit 7366ad19cb
5 changed files with 348 additions and 60 deletions
@@ -0,0 +1,50 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { cashinoDriver } from "./printer-cashino.js";
import { renderTicket } from "./printer-escpos.js";
// End-to-end transport routing through the real driver: a USB-configured Cashino must
// resolve to the char-device transport and write the SAME ESC/POS bytes the TCP path
// would. (The TCP path is exercised by the routing/escpos suites and on hardware.)
describe("cashinoDriver — USB transport", () => {
let dir: string;
let devicePath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "cashino-usb-"));
devicePath = join(dir, "lp0");
// Stand in for an enumerated usblp node (the kernel creates it; we only open it).
writeFileSync(devicePath, "");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("prints a ticket to the configured USB device path", async () => {
const printer = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
const data = { ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" };
await printer.printTicket(data);
const written = readFileSync(devicePath);
expect(written.equals(renderTicket(data))).toBe(true);
});
it("healthCheck reports ready when the node exists, offline when it doesn't", async () => {
const present = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
expect((await present.healthCheck()).status).toBe("ready");
// An absent device node (printer unplugged / not enumerated) → offline.
const absent = cashinoDriver.create({
transport: "usb",
devicePath: join(dir, "absent-lp0"),
timeoutMs: 1000,
});
expect((await absent.healthCheck()).status).toBe("offline");
});
it("advertises both transports", () => {
expect(cashinoDriver.transports).toContain("usb");
expect(cashinoDriver.transports).toContain("tcp-ip");
});
});
+38 -31
View File
@@ -10,20 +10,31 @@ import type {
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js";
import {
probe,
devicePathField,
probeTo,
renderReceipt,
renderReport,
renderSubscriptionCard,
renderTicket,
renderWindowChargeNotice,
sendRaw,
sendTo,
transportField,
transportFromConfig,
type Transport,
} 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.
// Cashino 80mm thermal printer driver (network OR USB). 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,
// over either transport. 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.
//
// TRANSPORT: a single `config.transport` ("tcp-ip" | "usb") picks the wire; the
// driver resolves it ONCE into a Transport and every print/probe stays transport-
// blind (see transportFromConfig/sendTo/probeTo). USB writes the same bytes to a
// local usblp char device (/dev/usb/lp0); TCP writes to the raw print socket. This
// clone is the natural USB candidate — reachability-only, no status page to lose.
//
// Therefore this driver deliberately does NOT implement MonitorableDevice
// (no readStatus). The device monitor then falls back to the generic
@@ -40,13 +51,11 @@ import {
class CashinoPrinter implements PrinterDevice {
readonly driverId = "cashino";
readonly #host: string;
readonly #port: number;
readonly #transport: Transport;
readonly #timeout: number;
constructor(config: DeviceConfig) {
this.#host = String(config.host);
this.#port = config.port ? Number(config.port) : 9100;
this.#transport = transportFromConfig(config);
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
}
@@ -59,15 +68,15 @@ class CashinoPrinter implements PrinterDevice {
}
/**
* 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.
* Reachability only — a connect probe (TCP) or char-device open probe (USB) of
* the print path. 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);
await probeTo(this.#transport, this.#timeout);
return { status: "ready" };
} catch (err) {
return { status: "offline", detail: (err as Error).message };
@@ -75,12 +84,12 @@ class CashinoPrinter implements PrinterDevice {
}
async printTicket(data: TicketData): Promise<void> {
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout);
await sendTo(this.#transport, 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);
await sendTo(this.#transport, renderReport(report), this.#timeout);
stubLog(
this.driverId,
`printed report "${report.title}" (${report.lines.length} lines)`,
@@ -88,17 +97,12 @@ class CashinoPrinter implements PrinterDevice {
}
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
await sendRaw(
this.#host,
this.#port,
renderSubscriptionCard(data),
this.#timeout,
);
await sendTo(this.#transport, renderSubscriptionCard(data), this.#timeout);
stubLog(this.driverId, `printed subscription card ${data.code}`);
}
async printReceipt(data: ReceiptData): Promise<void> {
await sendRaw(this.#host, this.#port, renderReceipt(data), this.#timeout);
await sendTo(this.#transport, renderReceipt(data), this.#timeout);
stubLog(
this.driverId,
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
@@ -106,7 +110,7 @@ class CashinoPrinter implements PrinterDevice {
}
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
await sendRaw(this.#host, this.#port, renderWindowChargeNotice(data), this.#timeout);
await sendTo(this.#transport, renderWindowChargeNotice(data), this.#timeout);
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
}
}
@@ -141,14 +145,17 @@ export const cashinoDriver: PrinterDriver = {
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"],
"Cashino 80mm thermal printer (ESC/POS over raw TCP port 9100, OR local USB /dev/usb/lp0). Prints like the Rongta but has no status page — monitored by reachability only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
transports: ["tcp-ip", "usb"],
configFields: [
hostField,
transportField,
devicePathField,
// host/port are TCP-only; not required because a USB printer needs neither.
{ ...hostField, required: false, help: `${hostField.help} Leave blank for a USB printer.` },
{
...portField(9100),
required: false,
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100).",
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100). TCP only.",
},
roleField,
rankField,
@@ -1,9 +1,15 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
renderTicket,
renderReceipt,
renderWindowChargeNotice,
renderSubscriptionCard,
probeUsb,
sendRawUsb,
transportFromConfig,
stamp,
} from "./printer-escpos.js";
@@ -103,6 +109,69 @@ describe("CP852 character mapping (the misprint fixes)", () => {
});
});
describe("USB transport (sendRawUsb / probeUsb / transportFromConfig)", () => {
// A regular file stands in for the usblp character device: open(O_WRONLY) + write
// is the same syscall path. This proves the transport is byte-blind — the EXACT
// ESC/POS stream renderTicket produces lands at the device path, with no transport
// touching a rendered byte (the whole point of the seam).
let dir: string;
let devicePath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "escpos-usb-"));
devicePath = join(dir, "lp0");
// A real usblp node already EXISTS (created by the kernel on enumeration); we open
// it O_WRONLY without O_CREAT, never create it. Pre-create the stand-in file so the
// test mirrors that — opening an ABSENT path means "printer not present" (offline).
writeFileSync(devicePath, "");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("writes the exact rendered ESC/POS bytes to the device path", async () => {
const payload = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" });
await sendRawUsb(devicePath, payload, 1000);
const written = readFileSync(devicePath);
expect(written.equals(payload)).toBe(true);
});
it("rejects when the device path can't be opened (printer not present)", async () => {
await expect(
sendRawUsb(join(dir, "absent-lp0"), Buffer.from([0x1b, 0x40]), 1000),
).rejects.toThrow();
});
it("probeUsb resolves for an existing node, rejects for a missing one", async () => {
await expect(probeUsb(devicePath, 1000)).resolves.toBeUndefined();
await expect(probeUsb(join(dir, "nope"), 1000)).rejects.toThrow();
});
it("transportFromConfig: transport=usb selects the char device (default /dev/usb/lp0)", () => {
expect(transportFromConfig({ transport: "usb", devicePath: "/dev/usb/lp1" })).toEqual({
kind: "usb",
devicePath: "/dev/usb/lp1",
});
expect(transportFromConfig({ transport: "usb" })).toEqual({
kind: "usb",
devicePath: "/dev/usb/lp0",
});
});
it("transportFromConfig: anything else is TCP (back-compat with host-only configs)", () => {
expect(transportFromConfig({ host: "10.0.0.9" })).toEqual({
kind: "tcp",
host: "10.0.0.9",
port: 9100,
});
expect(transportFromConfig({ host: "10.0.0.9", port: 9101 })).toEqual({
kind: "tcp",
host: "10.0.0.9",
port: 9101,
});
});
});
describe("stamp (Albanian date format)", () => {
it("formats an ISO time as '<day> <Month> <year> HH:MM:SS'", () => {
// Local-time dependent, so assert the structure + the Albanian month name.
@@ -1,4 +1,6 @@
import { Socket } from "node:net";
import { open } from "node:fs/promises";
import { constants as FS } from "node:fs";
import type {
PrintReport,
ReceiptData,
@@ -567,7 +569,150 @@ export function probe(
});
}
// --- 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);
},
);
});
}
/** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp
* is a RAW character device: a single open + write delivers the job — there is no
* FIN/half-close dance (that was a TCP concern, where an early destroy() could
* truncate the stream). 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 withTimeout(handle.write(payload), timeoutMs, "usb write timeout");
} 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, port 9100)" },
{ 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.",
};
+45 -28
View File
@@ -13,22 +13,28 @@ import type {
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js";
import {
probe,
devicePathField,
probeTo,
renderReceipt,
renderReport,
renderSubscriptionCard,
renderTicket,
renderWindowChargeNotice,
sendRaw,
sendTo,
transportField,
transportFromConfig,
type Transport,
} 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. 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.
// Rongta 80mm thermal printer driver (network OR USB). 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 — or over a local USB usblp
// char device. The ESC/POS rendering + transport are shared with the other ESC/POS
// clones in ./printer-escpos.ts (config.transport picks the wire); what is unique to
// Rongta — and lives here — is LIVE STATUS via the board's own status web page. That
// page is a NETWORK feature: a USB Rongta degrades to reachability-only monitoring
// (see readStatus). There is no auth on the print socket; like the other field
// devices a networked unit 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
@@ -128,14 +134,15 @@ function parseStatusPage(html: string): StatusFlags {
class RongtaPrinter implements PrinterDevice, MonitorableDevice {
readonly driverId = "rongta";
readonly #transport: Transport;
readonly #host: string;
readonly #port: number;
readonly #httpPort: number;
readonly #timeout: number;
constructor(config: DeviceConfig) {
this.#host = String(config.host);
this.#port = config.port ? Number(config.port) : 9100;
this.#transport = transportFromConfig(config);
// Kept for the HTTP status page (TCP only); empty on a USB printer.
this.#host = config.host ? String(config.host) : "";
this.#httpPort = config.httpPort ? Number(config.httpPort) : 80;
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
}
@@ -150,7 +157,7 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
async healthCheck(): Promise<DeviceHealth> {
try {
await probe(this.#host, this.#port, this.#timeout);
await probeTo(this.#transport, this.#timeout);
return { status: "ready" };
} catch (err) {
return { status: "offline", detail: (err as Error).message };
@@ -158,12 +165,12 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
}
async printTicket(data: TicketData): Promise<void> {
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout);
await sendTo(this.#transport, 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);
await sendTo(this.#transport, renderReport(report), this.#timeout);
stubLog(
this.driverId,
`printed report "${report.title}" (${report.lines.length} lines)`,
@@ -171,17 +178,12 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
}
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
await sendRaw(
this.#host,
this.#port,
renderSubscriptionCard(data),
this.#timeout,
);
await sendTo(this.#transport, renderSubscriptionCard(data), this.#timeout);
stubLog(this.driverId, `printed subscription card ${data.code}`);
}
async printReceipt(data: import("../interfaces.js").ReceiptData): Promise<void> {
await sendRaw(this.#host, this.#port, renderReceipt(data), this.#timeout);
await sendTo(this.#transport, renderReceipt(data), this.#timeout);
stubLog(
this.driverId,
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
@@ -189,7 +191,7 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
}
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
await sendRaw(this.#host, this.#port, renderWindowChargeNotice(data), this.#timeout);
await sendTo(this.#transport, renderWindowChargeNotice(data), this.#timeout);
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
}
@@ -206,6 +208,18 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
*/
async readStatus(): Promise<PrinterStatus> {
const checkedAt = new Date().toISOString();
// The status page is an HTTP feature of the network board; a USB printer has no
// such page. Degrade to the reachability floor (open the char device) and report
// ready/offline only — never a guessed paper/cover state, same honesty rule as
// the Cashino. (A USB Rongta is effectively a Cashino for monitoring purposes.)
if (this.#transport.kind === "usb") {
try {
await probeTo(this.#transport, this.#timeout);
return { status: "ready", checkedAt };
} catch (err) {
return { status: "offline", detail: (err as Error).message, checkedAt };
}
}
let html: string;
try {
html = await fetchStatusPage(this.#host, this.#httpPort, this.#timeout);
@@ -281,14 +295,17 @@ export const rongtaDriver: PrinterDriver = {
category: "printer",
label: "Rongta 80mm thermal printer",
description:
"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"],
"Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones that serve the /prn_stat.htm status page) over raw TCP (port 9100), OR local USB /dev/usb/lp0. The decoded status page is a network feature — a USB Rongta is monitored by reachability only. No auth on the print socket — isolate the VLAN.",
transports: ["tcp-ip", "usb"],
configFields: [
hostField,
transportField,
devicePathField,
// host/port/status-page are TCP-only; not required for a USB printer.
{ ...hostField, required: false, help: `${hostField.help} Leave blank for a USB printer.` },
{
...portField(9100),
required: false,
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100).",
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100). TCP only.",
},
{
key: "httpPort",
@@ -296,7 +313,7 @@ export const rongtaDriver: PrinterDriver = {
type: "port",
required: false,
default: 80,
help: "Device status page (/prn_stat.htm) port for live monitoring (default 80).",
help: "Device status page (/prn_stat.htm) port for live monitoring (default 80). TCP only.",
},
roleField,
rankField,