For developers & ISVs
Integrate restaurant hardware once.
Every POS team ends up owning a private zoo of printer drivers, certified-hardware whitelists, and 'printer offline' tickets. Proxy Nodes puts that whole problem behind one HTTP/JSON API on the restaurant's LAN — printer, cash-drawer pulse, and barcode scanner behind the same contract. A byte-compatible simulator and 123 conformance specs mean the integration passes in CI before a printer is on your bench; the same specs then run unchanged against real hardware.
PN-PROTO-1 · conceptmDNS at proxynodes-<id>.local · JSON on :80 · raw ESC/POS on :9100
The API
One call. The node does the last mile.
Nodes announce themselves over mDNS and serve JSON on the LAN. Your software never touches ESC/POS, RS-232 framing, or USB descriptors — unless it wants to.
Print a receipt
curl http://proxynodes-7f3a.local/print \
-X POST -H "content-type: application/json" \
-d '{
"lines": [
{ "type": "banner", "value": "ORDER", "align": "center" },
{ "type": "text", "value": "Table 4", "bold": true, "size": "2x" },
{ "type": "barcode", "symbology": "qr", "value": "ORDER-1042" },
{ "type": "cut" }
]
}'Seven line types: text (with bold, alignment, and double width or height), banner block lettering the node draws itself, rules, feeds, cuts, barcodes, and 1-bit rasters. The node wraps text at the paper’s real column count, so a line that will not fit comes back as an error rather than as a receipt missing its right-hand half.
Ask how the hardware actually is
curl http://proxynodes-7f3a.local/status
{
"identity": {
"deviceId": "pn-7f3a", "variant": "wifi",
"firmwareVersion": "1.0.24"
},
"online": true,
"uptimeSeconds": 86213,
"printers": [{
"type": "printer_status", "endpoint": "receipt",
"online": true, "paperOut": false, "coverOpen": false,
"drawerOpen": false
}]
}Live paper, cover, and drawer state, read over DLE EOT. And when a reading can't be taken — a write-only printer, a dead status line — the driver layer models it as known: falserather than inventing "no faults", so "paper is fine" and "I can't tell" never get conflated.
Already speak :9100?
If your software prints raw ESC/POS to network printers today, there is no integration at all: point it at the node's :9100 port. DLE EOT status queries are answered too.
Swap hardware, not code
Star to Epson to a $60 generic is a node-side config change. Your software keeps calling the same API.
Tune per printer, remotely
Cut clearance, print width, USB pacing — runtime config over PUT /config, persisted on the node. No reflash, no site visit.
Nodes also phone home on a schedule — a heartbeat, not a control channel — configured over GET/PUT /cloud. Point it at your own server instead of the Proxy Nodes service; see the self-hosted telemetry guide for the wire contract.
Under the hood
One classifier, five verdicts. Rejection is never silent.
Every USB device that appears gets classified, and every branch that turns a device away records why — VID:PID and make/model are logged before any rejection, so a refused device is still nameable by whoever is standing in front of it.
USB plug-in
Device enumerates · VID:PID + make/model recorded
pn_classify_device()
Printer
BOUND in 1,012 ms
Bulk-OUT for bytes, bulk-IN for DLE EOT status. Ready to print.
Scanner
BOUND · HID boot
Reports framed into barcodes; every read logged with a verdict.
Hub
Enumerate children
Powered hubs supported — printer + scanner behind one, simultaneously.
Scale
In development
Recognized and named, not yet driven. Reported honestly in /status.
Unknown
Rejected, with reason
VID:PID and model kept — the refusal names the device it refused.
A STALL and a silence are different: a stall completes the transfer and the device stays bound — one job lost, not the printer. A silence marks it not-ready. Every rejection names the reason GET /status will report.
Scanners
Every read gets a verdict.
A keyboard-wedge scanner types whatever it decodes and never explains itself. The node keeps the record it doesn't.
curl http://proxynodes-7f3a.local/scans
{
"endpoint": "scanner",
"accepted": 214, "rejected": 3, "capacity": 32,
"scans": [
{ "value": "0123456789012", "verdict": "accepted",
"symbology": "ean13", "symbologySource": "aim",
"ageMs": 1180, "gapMs": 2 },
{ "value": "0123456789012", "verdict": "duplicate",
"symbology": "code128", "symbologySource": "inferred",
"ageMs": 5411, "gapMs": 640 }
]
}GET /scans is a ring of recent reads with a verdict on each. Reads the node refused stay in the log with the reason — duplicate, too_short, burst — because a rule that silently drops a barcode is indistinguishable from a broken scanner.
Symbology is labeled by provenance: aim means the scanner transmitted it, inferred means the node worked it out from the payload — and an inference stays marked, so your software never routes on a guess without knowing it. Decode policy — read modes, timeouts, symbology allow-lists, keyboard layouts — is set over PUT /config, same as printer tuning.
And nodes find each other: GET /peers lists every other node heard on the LAN, so one known IP reaches the whole fleet.
CI-first
Your integration passes before a printer exists.
The digital twin is a LAN service that mirrors the firmware byte for byte — same API, same :9100 behavior, same telemetry — with fault injection for the failure paths you can't schedule on real hardware.
$ pnpm sim # digital twin: HTTP :8080, :9100, mDNS $ pnpm print # POST a sample receipt — renders in the console $ pnpm conformance # protocol conformance suite vs the twin sim> paper out # now make it a bad day sim> cover open sim> offline
The conformance suite is the honest answer to "does passing against a simulator mean anything?" — one suite, validated against shared schemas, that runs identically against the twin and a physical node on your bench.
Protocol conformance is unconditional; physical-effect assertions skip with a printed reason when the hardware isn't attached — never a silent pass.
123 conformance specs · 996 JS tests · 32 firmware suites under ASan/UBSan
Guides
The reference material we wished existed.
Deep technical guides on the protocols and failure modes of receipt printing — useful whether or not you ever run a node.
- Receipt Printer API: the Complete GuideEvery way software prints receipts in 2026 — ESC/POS, port 9100, vendor SDKs, cloud relays — and how a local HTTP API on the LAN compares.
- ESC/POS Commands: a Practical ReferenceThe ESC/POS commands that matter in production — init, text style, feed, cut, drawer kick, status — with raw bytes and the quirks between brands.
- ESC/POS Emulator: Print Without a PrinterCompare ESC/POS emulators and receipt printer simulators for development — and how to emulate paper-out, cover-open, and DLE EOT status in software.
- Virtual Receipt Printer for Dev and TestingOptions for a virtual thermal printer: print-to-image drivers, GitHub emulators, and a network digital twin your app can't tell from real hardware.
- Print to a Receipt Printer from a Web AppFour working ways to print receipts from the browser — QZ Tray, WebUSB, cloud relays, and a LAN print API — with code and honest trade-offs.
- Port 9100 Printing: Raw TCP for POS, ExplainedHow raw port 9100 printing works, why every POS uses it, how to test it with netcat, and its blind spots — status, discovery, and error handling.
- ESC/POS Printer Status: DLE EOT ExplainedThe DLE EOT real-time status commands byte by byte — paper, cover, drawer, errors — plus why many printers lie or stay silent, and what to do about it.
- Test Receipt Printing in CI — No HardwareHow to put receipt printing under CI: render ESC/POS to text, assert on bytes, simulate paper-out and offline faults, and run conformance checks.
- ESC/POS vs ePOS vs CloudPRNT vs StarPRNTThe four receipt-printing protocols compared: how each moves bytes, what hardware it locks you into, latency, status support, and when to use which.