bryanwintermute/unspooled
GitHub: bryanwintermute/unspooled
Stars: 0 | Forks: 0
# unspooled
`unspooled` is two things in one repo:
1. **A generic stdlib ESC/POS renderer** (`receipt_print.py`) — works
on any ESC/POS-compatible 80mm or 58mm thermal receipt printer
(Rongta, Epson, Star, Bixolon, Xprinter, …). Use as a CLI or
import as a library:
from receipt_print import Receipt
r = Receipt(title="Costco", style="checkbox", print_width=42)
r.add_items(["milk", "eggs", "bread"])
open("/dev/usb/lp0", "wb").write(r.to_bytes())
2. **A Rongta RP332 NV-config CLI** (`nv_config.py`, `ethernet_config.py`,
`papersave_config.py`, `blackmark_config.py`, `other1_config.py`,
all dispatched through `rongta_config.py`) — flips the persistent
factory defaults (auto-cutter, buzzer, drawer kick, paper width,
DHCP, static IP, MAC, 43 code pages, black-mark sensor, paper-save
trimming, …) without needing the proprietary Windows tool.
The renderer is **brand-agnostic** by design and is the bit you want
if you're building anything that prints to a thermal receipt
printer. The Rongta CLIs are **brand-specific** by necessity — the
NV-config wire protocol is proprietary to Rongta. Use whichever half
applies.
## Why "unspooled"?
The vendor tool talks to the printer through the Windows print
spooler. We routed that spool through a logging CUPS backend on
Linux (the printer presented to Wine as a CUPS printer) and
captured every byte. The project is the printer literally being
"unspooled" out of the vendor pipeline — and the protocol itself
being unspooled into something documented.
## Hardware
- **Renderer (`receipt_print.py`):** Any ESC/POS-compatible thermal
receipt printer (80mm or 58mm head). No vendor lock-in.
- **NV-config CLIs (`rongta_config.py` et al.):** Rongta RP332,
USB id `0fe6:811e` (the printer presents as an "ICS Advent
Parallel Adapter" — Rongta licenses the USB-to-parallel chip).
**Likely** also works on other Rongta SKUs that share the
`PrinterTool.exe` config tool (RP325, RP326, RP328, etc.) but
**untested** — PRs welcome.
## Requirements
- Python 3.9+ — **stdlib only, no third-party packages.** That's
the whole runtime dependency footprint.
- A Linux host with the printer attached via USB
- Membership in the `plugdev` group (so you can write to the
printer without `sudo`)
- The udev rule in this repo (`99-rongta-receipt.rules`) installed
to `/etc/udev/rules.d/`
## Setup
git clone git@github.com:bryanwintermute/unspooled.git
cd unspooled
# Install the udev rule (one-time, requires sudo)
sudo install -o root -g root -m 0644 99-rongta-receipt.rules \
/etc/udev/rules.d/99-rongta-receipt.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --action=change /sys/class/usbmisc/lp0
sudo udevadm settle
# Verify the symlink exists
ls -la /dev/rongta-receipt # should point to usb/lp0
Add yourself to `plugdev` if you're not already (log out + back in
after):
sudo usermod -aG plugdev "$USER"
## ⚠️ Safety — read this before writing anything
Most commands in this CLI write to the printer's **NV-RAM**. The
writes are **persistent across power cycles** — there is no
"undo" beyond writing the previous value back. Wrong values can
leave the printer in a state where the only recovery path is
this CLI itself (which is also the project's de-facto factory-reset).
Three concrete failure modes worth knowing before you flip
anything:
1. **`rongta_config.py other1 usb-mode virtual-serial`** —
makes the printer re-enumerate as `/dev/ttyACM*` instead of
`/dev/usb/lp0`. The udev rule in this repo won't fire for
`ttyACM` devices, so `/dev/rongta-receipt` will not exist.
You'll need a different recovery path. Don't run this casually.
2. **`rongta_config.py ethernet mac `** — if you change
the MAC and forget the original, you can't read it back over
USB (the firmware echoes confirmations, but only of the value
you sent). Always note the existing MAC from the
power-on-self-test report before changing it.
3. **`rongta_config.py ethernet static --ip `** — wrong
static IP / gateway / subnet can isolate the printer on its
own Ethernet but it's harmless if you're driving over USB.
**Always use `--dry-run` first.** Every command supports it.
Print the bytes, eyeball them, then drop the flag.
./rongta_config.py base --cutter on --buzzer on --dry-run
# 1f 73 02 00 00 01 00 00 00 00 00 1f 72 00 1f 74 00
If you do botch a setting, re-run with the desired values. The
CLI is its own factory-reset.
## Quick reference
The unified entry point is `rongta_config.py`. It dispatches to
six per-tab modules (each of which is also runnable standalone if
you prefer narrower help):
# Out-of-the-box: enable DHCP so the printer is reachable on the LAN.
./rongta_config.py ethernet dhcp on
# Out-of-the-box: enable the NV-gated auto-cutter (off from factory).
./rongta_config.py base --cutter on
# Aggressive paper-saving for shopping-list-style receipts.
./rongta_config.py papersave --delete-top enable --cut-line-interval 75%
# Switch paper width to 58mm.
./rongta_config.py other1 print-width 58mm
# Print a list.
echo -e 'milk\neggs\nbread' | ./rongta_config.py print --title 'Costco'
# Full help for any area:
./rongta_config.py --help
### Areas
| Area | Module | Coverage |
|---|---|---|
| `base` | `nv_config.py` | Cutter, buzzer, drawer kick, font, density, char/line, code page (43 named entries, sourced from the printer's own self-test report; 5 reserved slots accessible via `--code-page-raw`), baud rate, parity, auto-reprint, buzzer-after-print. |
| `ethernet` | `ethernet_config.py` | DHCP, static IP, submask, gateway, MAC address, link mode. |
| `papersave` | `papersave_config.py` | Whitespace trimming (uses standard Epson `GS ( E`). |
| `blackmark` | `blackmark_config.py` | Black-mark sensor: enable/disable, length, width, print/cut offset. |
| `other1` | `other1_config.py` | Paper width (80mm/58mm), buzzer volume, alarm, USB enumeration mode, Chinese character mode, cutter-count query. |
| `print` | `receipt_print.py` | Render a list (with `--title`, `--style`, `--print-width`) as standard Epson ESC/POS. Brand-agnostic. |
## Use as a library (`receipt_print.py`)
`receipt_print.py` is intentionally importable from downstream
projects (e.g. [`tickertape`](https://github.com/bryanwintermute/tickertape))
without dragging in any of the Rongta-specific modules. It's
stdlib-only, brand-agnostic, and the entire byte-emitting surface
is standard Epson ESC/POS (init, code-page CP437, align, font size,
bold, full-cut).
from receipt_print import Receipt
# 80mm head, Font A (the default — 42 columns).
r = Receipt(title="Costco", style="checkbox")
r.add_items(["milk", "eggs", "bread"])
with open("/dev/usb/lp0", "wb") as f:
f.write(r.to_bytes())
# 58mm head — pass print_width=32.
r58 = Receipt(title="Reminders", style="bullet", print_width=32)
r58.add_items(["pick up package", "water plants"])
Constructor signature:
Receipt(
title: str | None = None, # optional bold/centered/upper-cased title
timestamp: bool = True, # adds a YYYY-MM-DD HH:MM line under the title
items: list[str] = [], # or use .add_item() / .add_items()
style: str = "checkbox", # 'checkbox' / 'numbered' / 'bullet' / 'plain'
cut: bool = True, # GS V 0 full-cut at the end
print_width: int = 42, # 42=80mm Font A, 32=58mm Font A, 56=80mm Font B
sanitize: bool | dict | callable = True, # see "Text sanitization" below
)
The `Receipt.to_bytes()` method is deterministic given the same
inputs (timestamp aside) and the byte format is locked by the test
suite in `tests/test_receipt_print_library.py`.
### Render Markdown directly
Real-world text (notes apps, web pastes, generated to-do lists)
is usually markdown-ish. `render_markdown()` parses a constrained
CommonMark subset and emits ESC/POS bytes — no third-party markdown
library needed.
from receipt_print import render_markdown
md = """# Shopping List
Generated for **Saturday**.
- [ ] milk
- [ ] eggs
- [x] bread (already bought)
## Notes
Store closes at 8pm.
---
"""
bytes_out = render_markdown(md, title="Costco", print_width=42)
open("/dev/usb/lp0", "wb").write(bytes_out)
Supported subset (stdlib regex tokenizer — see `tests/test_markdown.py`
for the full grammar):
| Markdown | ESC/POS rendering |
|---|---|
| `# H1` | double-size + bold + center |
| `## H2` | bold + center |
| `### H3` | bold + left-aligned |
| `**bold**` (inline) | bold span |
| `- item` / `* item` | bullet list |
| `1. item` (literal numbers) | numbered list |
| `- [ ] item` / `- [x] item` | checkbox (state preserved) |
| `---` / `***` / `___` | horizontal rule across `print_width` |
| paragraph | wrapped to `print_width`, consecutive lines fold |
`Receipt.from_markdown(text, **kwargs)` is also available as a
classmethod for API symmetry; it's a thin wrapper around
`render_markdown()` and returns bytes directly.
Deliberately out of scope (v1): tables, code blocks, images, links
(the printer can't follow them), nested lists, blockquotes.
### Text sanitization
By default (since v0.3.0), text passes through a NFKD + smart-quote /
em-dash / ellipsis / arrow translation pass before CP437 encoding.
This means clipboard pastes from web pages no longer silently render
as `?` glyphs.
# Smart quotes -> straight, em-dash -> --, ellipsis -> ..., café -> cafe.
r = Receipt(items=['He said "hello"—then left…'])
# Opt out for v0.2.0 behavior (raw CP437 errors='replace'):
r = Receipt(items=['raw\u00B5'], sanitize=False)
# Extend the built-in map:
r = Receipt(items=['10 \u00B5s'], sanitize={"\u00B5": "u"}) # -> "10 us"
# Or pass a full custom callable:
r = Receipt(items=['hello'], sanitize=lambda s: s.upper()) # -> "HELLO"
The built-in translation table is exposed as
`DEFAULT_SANITIZE_MAP` for inspection or extension. The
`sanitize()` function itself is also importable if you want to
preprocess text outside the `Receipt` / `render_markdown` API.
## Command-family catalogue
Roughly half of what the vendor tool emits is **standard Epson
ESC/POS** — documented in the public Epson TM-T88 / TM-T20 spec.
The other half is Rongta-vendor extensions with no public docs.
| Prefix | Family | Coverage |
|---|---|---|
| `1f 73 XX ` | Rongta vendor | Base tab base-config (+ sub-fns `1f 72`, `1f 74`) |
| `1f 69`, `1f 25`, `1f 4e`, `1f 6d`, `1f 70`, `1f 62 44` | Rongta vendor | Ethernet (IP/submask/gateway/MAC/duplex/DHCP) |
| `1f 1b 1f XX ` | Rongta vendor extended | BlackMark + Other1 |
| `1f 7b X ` | Rongta vendor mode toggles | Paper sensor (`'p'`), USB mode (`'u'`) |
| `1d 28 45 ...` | **Standard Epson `GS ( E`** | PaperSave + Volume |
| `1d 28 46 ...` | **Standard Epson `GS ( F`** | BlackMark print/cut-after offsets |
| `1d 56 00` | **Standard Epson `GS V 0`** | Full-cut (runtime) |
| `12 54` | **Standard Epson DC2 'T'** | Self-test trigger |
| `1b 1b 45 ... 0c 5a` | Rongta vendor "structured" | Reset button — emits a "Setting Fail!" on this firmware. Documented but non-functional. |
## How we got the bytes
Full technique in
[`docs/wine-cups-backend-recovers-nv-bytes.md`](docs/wine-cups-backend-recovers-nv-bytes.md).
Short version:
1. **usbip-export the printer** from the Pi it lives on to an
x86_64 Linux host (so Wine can run on x86 while the printer
stays on the Pi).
2. **Run `PrinterTool.exe` under Wine** (Xvfb + x11vnc lets you
click through it from a phone VNC client).
3. **Install a custom CUPS backend** at
`/usr/lib/cups/backend/rongta` (mode `0700` so it runs as root)
that `tee`s every print-spool job to `/tmp/rongta-writes/.bin`.
4. **Click through the GUI**: each click = one labelled `.bin`
file. Diff them to find the bytes that change.
A concrete example — the Base tab's "Set" command with four
isolated states (all-off, only-cutter, only-drawer, only-buzzer)
produces these four 17-byte files. Aligning them column-wise:
┌─cutter
│ ┌─buzzer
│ │ ┌─drawer
all-off : 1f 73 02 | 01 01 01 | 00 00 00 00 00 | 1f 72 00 | 1f 74 00
cutter-only : 1f 73 02 | 00 01 01 | 00 00 00 00 00 | 1f 72 00 | 1f 74 00
drawer-only : 1f 73 02 | 01 01 00 | 00 00 00 00 00 | 1f 72 00 | 1f 74 00
buzzer-only : 1f 73 02 | 01 00 01 | 00 00 00 00 00 | 1f 72 00 | 1f 74 00
└────────────┘
three settings,
inverted booleans
(0 = on, 1 = off)
Position 3 only changes when Cutter is toggled, position 4 only
when Buzzer, position 5 only when Drawer. The encoding is
inverted (0 = on, 1 = off) because the factory firmware is shipped
with everything off and "0" means "default no-add-ons". Four
clicks → complete bit-mapping in 30 seconds of diffing.
For big enum dropdowns (like code pages), there's an even
cheaper trick: **static-analyse the PE binary**. MFC dropdown
labels are stored as contiguous string literals in the binary's
`.rdata` section. MSVC emits them **bottom-up** (reverse source
order), so:
strings -el -t d PrinterTool.exe | grep -E '^(CP|WCP|ISO|Katakana)' | sort -rn
…gives you the dropdown labels in their *visual* order.
**Important:** dropdown order is NOT the same as wire-byte order.
The RP332's first 6 code-page entries (CP437, Katakana,
CP850/860/863/865) happen to be wire bytes 0-5 because the
most-common pages are listed first AND happen to have the lowest
enum values — but past that, the dropdown order diverges.
**The truly cheap source of truth turned out to be the printer's
own self-test report**: the RP332's power-on diagnostic prints
its full 48-entry code-page table verbatim. We just hadn't read
all the way to the bottom of the receipt. Always read every
diagnostic output the device exposes before reaching for static
analysis. Full debrief in
[`docs/wine-cups-backend-recovers-nv-bytes.md`](docs/wine-cups-backend-recovers-nv-bytes.md).
## More reading
See [`docs/`](docs/) for the full lesson set:
- [`wine-cups-backend-recovers-nv-bytes.md`](docs/wine-cups-backend-recovers-nv-bytes.md)
— the core technique with 7 distinct RE patterns.
- [`vendor-mobile-sdks-may-stub-nv-config.md`](docs/vendor-mobile-sdks-may-stub-nv-config.md)
— the prequel: how we disassembled Rongta's iOS/Android SDKs and
proved the NV-config methods were stubs.
- [`escpos-thermal-printers-need-no-cups-driver.md`](docs/escpos-thermal-printers-need-no-cups-driver.md)
— the foundational lesson: ESC/POS printers don't need CUPS for
the basic print path.
- [`rongta-rp332-vendor-tool-replacement-recap.md`](docs/rongta-rp332-vendor-tool-replacement-recap.md)
— project-recap: what was built, what's still TODO.
- [`udev-settle-after-trigger-or-rebind.md`](docs/udev-settle-after-trigger-or-rebind.md)
— the trigger/settle discipline used in the setup recipe.
## Status / TODO
All major NV-setting tabs in `PrinterTool.exe` v2.63.0 are
reverse-engineered. Remaining items (all nice-to-haves):
- **Bluetooth setting tab** — RP332 has no BT hardware; tab might
emit no-op commands or preview a different family for other
Rongta SKUs.
- **UDP discovery** (the tool's "Search Printer" tab) — would be
nice as a Python equivalent.
- **Capture cutter-stats response** — the cutter-count query is
exposed as `rongta_config.py other1 cutter-query`, but the
response comes back on BULK-IN, which the CUPS backend doesn't
relay. Capture with `usbmon` to decode and parse.
- **Find a working factory-reset command** — the GUI's Reset
button emits a 13-byte structured packet that the firmware
rejects ("Setting Fail!"). Trailing `0c 5a` smells like a
checksum; figuring it out + sending a real reset would be neat.
See [`docs/rongta-rp332-vendor-tool-replacement-recap.md`](docs/rongta-rp332-vendor-tool-replacement-recap.md)
for the full wishlist.
## License
Apache-2.0. See [`LICENSE`](LICENSE).