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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user