Four devices, two daemons, one front door. This service drives the Urovo D812R+ and the Dymo LabelWriter over raw USB; the Node rfid-daemon on 7790 drives the Chafon H102 gun and the Dymo M25 scale, and is what the PriceGod extension is pointed at. Both were binding 7790. The port was not the whole problem. /status, /read-tag, /write-tag and /recover exist on BOTH services and mean different things — the gun in your hand versus the antenna inside the printer. Merging them into one flat namespace would let a "read-tag" land on whichever daemon answered, which is how you get a tag written by the wrong device. So this keeps its own names and the Node side proxies /printer/* here, leaving the extension with a single endpoint on 7790. They stay separate processes on purpose: this side needs libusb for a real bidirectional pipe (UHF READ replies as raw bytes on the bulk-IN endpoint, which is why CUPS 'lp -o raw' cannot drive it) and the other needs node-serialport and node-hid. Merging would mean porting one language's device stack to the other for no gain. --port still wins, UROVO_PORT overrides the default, and neither the launchd plist nor start-daemon.command pins a port, so install.sh needed no change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
219 lines
11 KiB
Markdown
219 lines
11 KiB
Markdown
# pricegod label daemon (macOS)
|
||
|
||
One service on `localhost:7791` that drives **both** printers over raw USB:
|
||
|
||
> **Port 7791, and you normally talk to 7790.** The Node `rfid-daemon` owns 7790 — it drives the
|
||
> Chafon gun and the Dymo scale, and it is what the PriceGod extension is configured against. It
|
||
> proxies `/printer/*` to this service, so the extension keeps ONE front door: `/printer/status`,
|
||
> `/printer/print-encode`, `/printer/recover` and so on land here. `GET /devices` on 7790 reports
|
||
> all four devices at once.
|
||
>
|
||
> They stay separate processes deliberately. This side needs libusb (UHF READ answers with raw
|
||
> bytes on the bulk-IN endpoint, so `lp -o raw` cannot drive it); the other needs node-serialport
|
||
> and node-hid. And they cannot share a flat namespace — `/status`, `/read-tag`, `/write-tag` and
|
||
> `/recover` exist on both and mean different things: the gun in your hand versus the antenna in
|
||
> the printer. The prefix is what keeps that unambiguous.
|
||
|
||
|
||
| 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>"`.
|
||
|
||
## Install on a new Mac (one command)
|
||
|
||
```bash
|
||
git clone ssh://git@100.71.119.27:222/monster/yourovo.git
|
||
cd yourovo/mac && ./install.sh
|
||
```
|
||
|
||
That installs libusb + the venv, then registers a **launchd agent** so the daemon starts
|
||
at login and restarts itself if it ever dies. **No Terminal window to keep open, and
|
||
nothing to click** — after this it is simply always running. Verified: `kill` the process
|
||
and launchd has it serving again within seconds.
|
||
|
||
Re-run `./install.sh` any time to pull the latest commit and reload. `./install.sh
|
||
uninstall` removes the agent.
|
||
|
||
| | |
|
||
|---|---|
|
||
| logs | `tail -f ~/Library/Logs/pricegod-labeld.log` |
|
||
| restart | `launchctl kickstart -k gui/$(id -u)/com.pricegod.labeld` |
|
||
| check | `curl -s localhost:7790/devices` |
|
||
|
||
For a foreground run while developing (agent must be stopped first), use `./setup.sh`
|
||
then double-click **`start-daemon.command`**.
|
||
|
||
## 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.
|
||
|
||
The extension calls only six endpoints:
|
||
|
||
| endpoint | owner today |
|
||
|---|---|
|
||
| `/status` `/write-tag` `/read-tag` | ✅ this service |
|
||
| `/weight` `/scale/start` `/scale/stop` | ❌ Node only — DYMO M10 postal scale over USB HID |
|
||
|
||
So the gap to a genuinely single service is **the postal scale, and nothing else**. Once
|
||
`/weight` and `/scale/*` are ported here (the M10 is USB HID `0922:8003/8004`; the parsing
|
||
is ~130 lines of `index.js`), the Node daemon can be retired for the extension's purposes
|
||
and there is one process, one venv, one launchd agent.
|
||
|
||
Until then the honest position is: two services, or no scale. Running this one on
|
||
`--port 7791` behind the Node daemon works but gives one URL and *two* processes, which
|
||
defeats the point of a single launchd agent.
|
||
|
||
## 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 1–3 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 (✅ verified — and `daemon.cs` has it wrong)
|
||
|
||
The shop's real scheme, matching `pliceclogs/rfid-daemon/index.js`
|
||
(`buildEpcPayload` / `parseEpcData`), is a **single 96-bit big-endian integer**:
|
||
|
||
```
|
||
value = sku(14 digits) * 10^9 + releaseId
|
||
```
|
||
|
||
In decimal that reads as `[digits 1-14 = SKU][digits 15-23 = releaseId]`, so any scanner
|
||
showing hex can be converted to decimal and split at digit 14. Verified against that
|
||
file's own worked example: `20260321001001` + `35332` → `20260321001001000035332`.
|
||
|
||
> ⚠️ **`daemon.cs` (Windows) uses a different, incompatible layout** — 6 bytes SKU +
|
||
> 4 bytes release_id + an `0xEC01` marker. It was never checked against a real tag.
|
||
> `parseEpcData` rejects it outright, so **any tag the Windows daemon has written is
|
||
> invisible to the extension**. Its `Epc` class needs updating to the scheme above.
|
||
|
||
`epc_decode` returns `None` for foreign/factory EPCs (e.g. `E28069…`), which is what
|
||
stops a stray tag on the shelf being reported as a real product SKU.
|
||
|
||
## Verifying a tag independently
|
||
|
||
`chafon.py` is a read-only client for the Chafon gun over its CH340 serial bridge — an
|
||
*independent* check that a chip the Urovo wrote holds what we think it does:
|
||
|
||
```bash
|
||
./venv/bin/python chafon.py # inventory the field
|
||
```
|
||
|
||
Confirmed working: after programming, the Chafon reads back
|
||
`0000044A5600D4CDA1FAB932` → `sku=20260722180000, releaseId=424242`, alongside the
|
||
untouched factory tags on the rest of the roll.
|
||
|
||
⚠️ Only one process can hold the serial port, so don't run this while the Node
|
||
rfid-daemon is up.
|