yourovo/mac/README.md
type-two 2727545ac7 Add Dymo LabelWriter 450 driver; unify both printers in one daemon
Answers "can we direct print to the Dymo via CUPS, or is it the cmd-P dance?":
neither. The LW450 (0922:0020) is a bidirectional USB printer-class device just
like the Urovo, so its raster protocol goes straight to the bulk endpoint -- no
DYMO driver, no CUPS queue, no print dialog. Command set taken from DYMO's own
LabelWriter 450 Series Technical Reference Manual and documented in dymo.py.
Verified on the bench: revision 1750111r53, status 0x03 Ready, 283 raster lines
at 82 bytes/line accepted.

Also corrects an assumption inherited from daemon.cs: UHF WRITE commits
immediately and does NOT wait for PRINT. Confirmed by writing a chip and reading
it back with no PRINT sent. Two consequences:
  - the Urovo can program a chip without printing or consuming a label, which is
    what makes the Urovo-encodes/Dymo-prints split work at all;
  - an encode can be verified BEFORE printing, so a failed write never costs a
    label. /print-encode and /encode both do this now.

New routes: /devices (all three devices at a glance), /encode (chip only),
/print-paper (Dymo), /tag-and-print (Mode A in one call).

label.py is now resolution-aware (203 dpi Urovo / 300 dpi Dymo via profile(dpmm=))
and orientation-aware: the Urovo's 24mm web feeds narrow-edge-first so the design
rotates 90 degrees, while the Dymo's 57mm head is wider than the label so the same
design feeds landscape unrotated.

Also fixes tag read parsing: with dataFormat 'H' the printer replies in ASCII hex
TEXT, not raw bytes -- the old code hex-encoded it a second time. flush_in() now
drains fully, since a late reply to one command was being attributed to the next
and silently corrupting reads.

KNOWN ISSUE: the Node rfid-daemon (Chafon + scale + inventory) also binds :7790.
Both cannot run at once and this service does not implement its routes. Unresolved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 16:36:34 +10:00

148 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# pricegod label daemon (macOS)
One service on `localhost:7790` that drives **both** printers over raw USB:
| device | role | how |
|---|---|---|
| **Urovo D812R+** | prints TSPL labels, programs UHF chips | raw TSPL, `urovo.py` |
| **Dymo LabelWriter 450** | prints the paper label | raw raster, `dymo.py`**no CUPS driver, no print dialog** |
| Chafon H-102 | reads tags off the shelf | *not this service* — see “Chafon” below |
It is the Mac counterpart to the Windows `daemon.cs` and keeps the same HTTP contract,
so it stays a drop-in for the PriceGod extension's `daemon.js` client.
### Two things worth knowing up front
**`UHF WRITE` commits immediately — it does not wait for `PRINT`.** `daemon.cs` assumed the
encode only fired together with `printlabel`. It doesn't. That means the Urovo can program
a chip while printing nothing, which is what makes the split flow (Urovo encodes, Dymo
prints) possible, and it lets the daemon read the chip back to *verify* an encode before
spending a label on it.
**The Chafon works over serial on macOS.** ASSESSMENT.md §4 found the gun was HID-keyboard
only on Windows. On the Mac it enumerates a CH340 bridge at `/dev/cu.usbserial-*` and the
existing Node `rfid-daemon` speaks its protocol fine, so that dead end is Windows-specific.
**No vendor SDK required.** `ASSESSMENT.md` §7 assumed the RFID encode was locked inside
the Windows-only `GTSPL_SDK.dll` and would need a USB sniff or the Java jar to recover.
It doesn't: the DLL is a managed .NET assembly, and its IL shows every RFID call is a
one-line `String.Concat` → ASCII → `WritePrinter`. The wire format below was recovered
from `ldstr` order + argument order and independently confirmed by a second decompile.
```
UHF WRITE <fmt>,<start>,<len>,<bank>,"<data>" # the RFID encode
UHF READ <fmt>,<start>,<len>,<bank>
UHF QUERY <fmt>,<pcStatus>,<crcStatus>
UHF GEN2 EPC|TID|USER|ACCESS|KILL <action>,"<pw>"
SET RFID <tagType>,<rw_pos>,<void_printout>,<tryEncode>,<errHandle>,<speed>,<retry>
&DEFAULT,i # rfidSetupDefault &CALIBRATE,A,R # RFIDAutoCalibration
PRINT <set>, <copy> # note the space after the comma
```
All plain ASCII, one command per line, CRLF-terminated. `writeUHF("H",2,12,"E",epc)`
emits exactly `UHF WRITE H,2,12,E,"<24 hex chars>"`.
## Setup
```bash
./setup.sh # brew install libusb + venv with pyusb/pillow/segno
```
Then double-click **`start-daemon.command`** (leave it open while you work).
## Files
| File | What |
|---|---|
| `urovo.py` | Urovo USB transport + the full GTSPL command set, status decoding, fault recovery, SKU↔EPC packing. Runs standalone as a connection test. |
| `dymo.py` | Dymo LabelWriter 450 raster driver over raw USB. Runs standalone to report status/revision. |
| `label.py` | Renders the label to a 1-bit bitmap with PIL, resolution-aware (203 dpi Urovo / 300 dpi Dymo). Run standalone to dump previews to `/tmp`. |
| `daemon.py` | The `:7790` HTTP service. |
## Routes
| method | path | body / query | returns |
|---|---|---|---|
| GET | `/devices` | — | **status of Urovo, Dymo and Chafon in one call** |
| GET | `/status` | — | Urovo only: `{ok,ready,status,statusText,printer}` |
| POST | `/tag-and-print` | label fields + `{sku,releaseId}` | **Mode A in one call: Urovo programs the chip, Dymo prints the paper** |
| POST | `/encode` | `{sku,releaseId}` | programs the chip at the antenna and reads it back. **Prints nothing, costs no label** |
| POST | `/print-paper` | label fields + `size?`, `density?` | prints on the Dymo |
| POST | `/print-encode` | `{sku,releaseId,artist?,title?,genre?,style?,info?,price?,condition?,size?,w?,h?,gap?}` | Urovo one-pass print **and** encode (needs RFID stock) |
| POST | `/write-tag` | alias of `/print-encode` | same |
| POST | `/print-test` | same body — Urovo prints only, no RFID (safe on chipless stock) | same |
| GET | `/read-tag` | — | `{ok,epc,sku,releaseId,recognized}` |
| GET | `/read-raw` | — | every memory bank |
| GET | `/preview?size=large&artist=…` | — | **PNG of the label, printing nothing** |
| GET | `/profiles` | — | the media profiles below |
| POST | `/calibrate` / `/recover` | — | RFID auto-calibration / clear a latched fault |
Use `/preview` while tuning a layout — it costs no labels.
## Which flow to use
- **Mode A — separate (today, existing stock).** `POST /tag-and-print`. The Urovo programs
the chip on an RFID label; the Dymo prints the paper; you stick the paper over the chip.
The encode is verified *before* the paper prints, so a failed write wastes nothing.
- **Mode B — one pass (the goal).** `POST /print-encode` on direct-thermal RFID paper
(ASSESSMENT.md §5). One label, printed and encoded together.
## The Dymo, without a driver
The LabelWriter 450 (`0922:0020`) is a bidirectional USB printer-class device, so the
daemon writes its raster protocol straight to the bulk endpoint. **No DYMO driver, no CUPS
queue, no open-window/cmd-P/cmd-W.** Command set is from DYMO's own *LabelWriter 450 Series
Technical Reference Manual*; `dymo.py` documents each command it uses.
Note the two printers differ in orientation: the Urovo's 24 mm web feeds narrow-edge-first
so the design is rotated 90°, while the Dymo's 57 mm head is wider than the label so the
same design feeds landscape and must **not** be rotated. `label.render(..., rotate=)`
handles this, and the resolution difference (203 vs 300 dpi) comes from `profile(dpmm=)`.
## Chafon, and the `:7790` collision
⚠️ The existing Node daemon at `~/Documents/pliceclogs/rfid-daemon` **also listens on
:7790**. Both cannot run at once. That daemon owns the Chafon serial protocol
(`CF [ADDR] [CMD] [LEN] [DATA] [CRC16]` @115200) plus the postal-scale and inventory
routes, none of which this service implements — so it is *not* a full replacement.
Pick one of: run this on `--port 7791`; keep the Chafon daemon for the gun and run this
one elsewhere; or fold the Chafon protocol in here. Unresolved — decide before wiring the
extension.
## Media profiles
Both shop stocks sit on a **24 mm web** and feed long-edge-first, so the landscape design
is rotated 90° onto the media. Names match the extension's size dropdown.
| profile | label face | pitch | notes |
|---|---|---|---|
| `small` | 51 × 19 mm | 51 mm | older stock; the rest of the 24 mm web is blank filler, design is centred across it |
| `large` | 55 × 24 mm | 55 mm | current stock (default) |
| `xlarge` | 64 × 34 mm | 64 mm | untested |
Each inherits `margin_mm` (default 1.5), a safe-area inset held back from every edge —
without it the design runs edge-to-edge and the least registration drift clips it.
Override per request with `size`, or `w`/`h`/`gap` directly.
## Gotchas confirmed on macOS
1. **`0x08` "out of ribbon" is a latch.** `SET RIBBON OFF` alone will *not* clear it — it
also needs a `FORMFEED` (costs one blank label). `recover()` / `POST /recover` does this.
2. **The status byte lies for 13 s after a print** (transient `0x20`/`0x31`/`0x45`).
Never judge a print by the immediate post-print byte; poll until it settles to `0x00`.
3. **`VOID0` stamps on chipless stock.** If RFID encoding is armed, every label without a
chip gets voided. `SET RFID OFF` stops it; `/print-test` is the safe route on plain
thermal. Note `&DEFAULT,i` appears to *arm* RFID, so don't send it casually.
4. **Bidirectional USB is required.** `UHF READ`/`QUERY` reply with raw bytes on the
bulk-IN endpoint, so CUPS `lp -o raw` is not sufficient — hence libusb.
5. **After changing stock**, calibrate on the printer itself: hold **FEED** while powering
on and wait for **5 beeps**.
## SKU ↔ EPC scheme (⚠ still unverified)
96-bit EPC = **6 bytes** 14-digit timestamp SKU + **4 bytes** release_id + **2 bytes**
`0xEC01` marker — ported byte-for-byte from `daemon.cs`. Confirm against an existing
Chafon-written tag before trusting it; if it differs, change **only** `epc_encode` /
`epc_decode` in `urovo.py`.