yourovo/mac
type-two 50157933fa fix(label): rotation is a profile property; rfid65 feeds landscape
render() always rotated the landscape design 90 degrees -- right for the 24 mm
narrow stock, which feeds narrow-edge-first, and wrong for the 65 x 35 UHF roll,
whose 65 mm is the WEB. On that roll the rotated design ran along a 35 mm label:
printed sideways and clipped after ~35 mm, seen on the bench.

rotate now defaults from the profile (rfid65: False) and only an explicit
argument overrides it, so the Dymo path (which passes rotate itself) is
untouched. When a label-printer profile is unrotated the canvas is still the
media -- web x pitch, 65 x 35 -- with the 64 x 34 design centred, not an
art-sized bitmap; that art-sized case is kept only for the Dymo, whose head is
wider than its label. Offline render verified 520 x 280 px = 65.0 x 35.0 mm,
upright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:35:20 +10:00
..
chafon.py Fix the EPC scheme; verify encode independently with the Chafon 2026-07-22 16:45:07 +10:00
daemon.py fix(urovo): don't SET RFID before an encode; document that it can wedge the module 2026-09-04 11:26:51 +10:00
dymo-24x55.ppd docs(mac): patched CUPS PPD for the DYMO 450's 24x55mm price stock 2026-07-24 13:40:30 +10:00
dymo.py Fix Dymo label geometry: rotation and 300x600 dpi halving 2026-07-22 16:39:58 +10:00
install.sh One-command install + launchd agent for deploying to another Mac 2026-07-22 16:49:21 +10:00
label.py fix(label): rotation is a profile property; rfid65 feeds landscape 2026-09-04 11:35:20 +10:00
patch_ppd.py docs(mac): patched CUPS PPD for the DYMO 450's 24x55mm price stock 2026-07-24 13:40:30 +10:00
README.md feat(label): rfid65 profile for the 65x35 UHF direct-thermal roll 2026-09-04 11:28:41 +10:00
setup.sh Fix the EPC scheme; verify encode independently with the Chafon 2026-07-22 16:45:07 +10:00
start-daemon.command macOS: raw-TSPL Urovo daemon — no vendor SDK needed 2026-07-22 16:07:12 +10:00
urovo.py fix(urovo): don't SET RFID before an encode; document that it can wedge the module 2026-09-04 11:26:51 +10:00

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.pyno 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)

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 (rfid65 = the 65x35 UHF roll: web 65, pitch 35, gap 0)
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 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 ( 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 + 3533220260321001001000035332.

⚠️ 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:

./venv/bin/python chafon.py          # inventory the field

Confirmed working: after programming, the Chafon reads back 0000044A5600D4CDA1FAB932sku=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.