macOS: raw-TSPL Urovo daemon — no vendor SDK needed

ASSESSMENT.md §7 assumed the RFID encode was locked inside the Windows-only
GTSPL_SDK.dll and would need a USBPcap sniff or the GTSPL Java jar to recover.
It didn't. The DLL is a managed .NET assembly, and its IL shows every RFID call
is a one-line String.Concat -> ASCII -> WritePrinter. Recovered from ldstr order
plus argument order, and confirmed by a second independent decompile:

    UHF WRITE H,2,12,E,"<24 hex>"    UHF READ/QUERY ...
    UHF GEN2 EPC|TID|USER|ACCESS|KILL <action>,"<pw>"
    SET RFID <tagType>,<rw_pos>,<void>,<try>,<err>,<speed>,<retry>   (dots, not mm)
    &DEFAULT,i    &CALIBRATE,A,R    PRINT <set>, <copy>

So the whole recipe is plain ASCII over the printer's bulk endpoint and ports to
macOS with no vendor binaries at all.

- mac/urovo.py   USB transport + full GTSPL command set, status decoding, the
                 ribbon-latch recovery, SKU<->EPC packing ported from daemon.cs.
- mac/label.py   PIL bitmap renderer -> TSPL BITMAP. Media-profile driven, so both
                 shop stocks work (small 51x19 and large 55x24 on the 24mm web),
                 with a safe-area inset so the design can't clip at the edges.
- mac/daemon.py  :7790 HTTP service, same routes/JSON/CORS as daemon.cs, plus a
                 /preview route that renders a label as PNG without printing one.

Verified on the bench from macOS: printer identifies (MODEL:UROVO-D812R), the
latched 0x08 "out of ribbon" clears with SET RIBBON OFF + FORMFEED, and labels
print correctly oriented and dark on the 24x55 stock.

NOT yet verified: the RFID encode itself. It is wired and the printer accepts the
command, but the loaded thermal stock has no inlays so nothing has been written to
a chip. Needs a PET RFID label at the antenna. The SKU<->EPC byte layout also
remains unconfirmed against existing Chafon-written tags.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-22 16:07:12 +10:00
parent 3056fd70fc
commit b014205a5b
8 changed files with 885 additions and 1 deletions

5
.gitignore vendored
View File

@ -8,5 +8,10 @@
.DS_Store
Thumbs.db
# macOS daemon virtualenv (recreate with mac/setup.sh)
mac/venv/
__pycache__/
*.pyc
# NOTE: the vendor DLLs (GTSPL_SDK*.dll, zlib.net.dll, UHFPrimeReader.dll, hidapi.dll)
# ARE committed on purpose — they're needed to build/run on Windows.

View File

@ -161,6 +161,21 @@ PriceGod buttons → `:7790` was not done yet (next step).
## 7. ⭐ For the Mac: getting the Urovo to program chips over there
> **UPDATE 2026-07-22 (later the same day) — SOLVED, and more cheaply than expected.**
> The RFID-encode command did **not** need a USB sniff or the Java jar. `GTSPL_SDK.dll` is a
> *managed .NET assembly*, so decompiling its IL reveals every RFID call is a one-line
> `String.Concat` → ASCII → `WritePrinter`. `writeUHF("H",2,12,"E",epc)` emits exactly:
>
> ```
> UHF WRITE H,2,12,E,"<24 hex chars>"\r\n
> ```
>
> The whole vocabulary (`UHF READ/QUERY`, `UHF GEN2 *`, `SET RFID`, `&DEFAULT,i`,
> `&CALIBRATE,A,R`) is in `mac/README.md`. **Option 2 below is therefore fully unblocked and
> option 1 (the Java jar) is unnecessary.** A working macOS daemon now lives in `mac/`
> raw TSPL over libusb, same `:7790` contract, printing verified on the 24 mm stock.
> Options 13 below are kept for historical context.
The **daemon.cs is Windows-bound**`GTSPL_SDK.dll` is a Windows DLL; it won't run on macOS. But the
**recipe in §2 is portable**. Options, best first:
@ -193,7 +208,12 @@ geometry, density, one-pass timing — is documented above and identical.
- [ ] Wire PriceGod buttons → `:7790` + the Mode A/B toggle.
- [ ] Match the label layout to the extension's `labels.js` design more faithfully (current TSPL layout is functional, not pixel-perfect).
- [ ] (Optional) On Windows, decide Chafon = scanner-only vs. chase command mode.
- [ ] (Mac) Try option 1 or 2 in §7 to program chips there.
- [x] (Mac) ~~Try option 1 or 2 in §7 to program chips there.~~ Done — see §7 update and `mac/`.
- [ ] (Mac) Prove the RFID **encode** end-to-end: it is wired and the printer accepts the
command, but no chip has been written yet (the 24 mm thermal stock has no inlays).
Put a PET RFID label at the antenna and `POST /print-encode`, then read back.
- [ ] Confirm `SET RFID OFF` is genuinely honoured by this firmware rather than silently
ignored — the absence of `VOID0` on the last test print is the only evidence so far.
See also the shop's memory notes: `urovo-d812r-ribbon-fix`, `urovo-d812r-rfid-label-gap-issue`,
`urovo-d812r-pet-labels-not-thermal`, `urovo-windows-print-encode-daemon`, `chafon-h102-windows-hid-mode`.

94
mac/README.md Normal file
View File

@ -0,0 +1,94 @@
# pricegod-urovo-daemon (macOS)
The Mac counterpart to the Windows `daemon.cs`. Drives the **Urovo D812R+ (RFID)** over
raw USB — printing a label and encoding the UHF chip in one pass — and serves the same
`localhost:7790` HTTP contract, so it is a drop-in for the PriceGod extension's
`daemon.js` client.
**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` | USB transport + the full GTSPL command set, status decoding, fault recovery, SKU↔EPC packing. Runs standalone as a connection test. |
| `label.py` | Renders the label to a 1-bit bitmap with PIL and emits TSPL `BITMAP`. Run standalone to dump previews to `/tmp`. |
| `daemon.py` | The `:7790` HTTP service. |
## Routes
| method | path | body / query | returns |
|---|---|---|---|
| GET | `/status` | — | `{ok,ready,status,statusText,printer}` |
| POST | `/print-encode` | `{sku,releaseId,artist?,title?,genre?,style?,info?,price?,condition?,size?,w?,h?,gap?}` | `{ok,epc,status,...}` |
| POST | `/write-tag` | alias of `/print-encode` | same |
| POST | `/print-test` | same body — **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` | — | RFID auto-calibration |
| POST | `/recover` | — | clear a latched fault |
Use `/preview` while tuning a layout — it costs no labels.
## 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`.

252
mac/daemon.py Normal file
View File

@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""
pricegod-urovo-daemon (macOS) -- localhost:7790 HTTP bridge to the Urovo D812R+.
The macOS counterpart to daemon.cs. Same port, same routes, same JSON shape, CORS *,
so it is a drop-in for the PriceGod extension's existing daemon.js client. The Windows
build needs GTSPL_SDK.dll; this one needs nothing but libusb, because the SDK turned out
to be a thin string formatter over a raw byte pipe (see urovo.py for the recovered
wire format).
Routes
GET /status detect + status -> {ok,ready,status,statusText,printer}
POST /print-encode print a label AND encode the chip in one pass
POST /write-tag alias of /print-encode (the daemon.js name)
POST /print-test print only, no RFID (safe on plain thermal stock)
GET /read-tag read the tag at the antenna -> {ok,epc,sku,releaseId}
GET /read-raw dump every memory bank
GET /preview?... PNG of the rendered label -- costs no labels
GET /profiles the media profiles this daemon knows
POST /calibrate RFID auto-calibration
POST /recover clear a latched fault
Run: ./venv/bin/python daemon.py (or ./start-daemon.command)
"""
import io
import json
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
import label
from urovo import Urovo, UrovoError, epc_encode, epc_decode, status_text
VERSION = 'pricegod-urovo-daemon-mac/1.0'
DENSITY = 15 # max darkness -- anything lower barely marks
LOCK = threading.Lock() # one USB handle; serialise all hardware access
# --------------------------------------------------------------------------
def geometry(p, prof):
"""Apply media geometry. gap>0 = die-cut via the optical gap sensor;
gap=0 = continuous, for RFID rolls whose inlay blinds the gap sensor."""
p.tear_on()
p.gap(prof['gap_mm'], 0)
p.size(prof['web_mm'], prof['pitch_mm'])
p.density(DENSITY)
p.direction(1)
def do_print(body, encode):
prof = label.profile(body.get('size'),
web_mm=body.get('w'), pitch_mm=body.get('h'),
gap_mm=body.get('gap'))
sku = str(body.get('sku', '') or '').strip()
release_id = str(body.get('releaseId', '') or '').strip()
epc = None
if encode:
try:
epc = epc_encode(sku, release_id)
except ValueError as e:
return {'ok': False, 'error': str(e)}
with LOCK, Urovo() as p:
# 1) direct thermal. Wait out transient busy states before feeding to clear,
# so we don't waste a label on a momentary post-print code.
p.ribbon_off()
time.sleep(0.4)
if p.wait_ready(3.0) != 0x00:
st = p.recover()
if st != 0x00:
return {'ok': False, 'error': 'printer not ready',
'status': st, 'statusText': status_text(st)}
# 2) geometry, then the artwork
geometry(p, prof)
p.cls()
before = p.read_uhf() if encode else None
# 3) stage the RFID write BEFORE the image; PRINT fires both in one pass
if encode:
p.write_uhf(epc)
p.write(label.to_tspl(label.render(body, prof)))
# 4) re-assert ribbon-off immediately before firing
p.ribbon_off()
time.sleep(0.4)
p.printlabel(1, 1)
# 5) the status byte returns garbage for 1-3 s during the RFID write + feed,
# so poll until it settles rather than trusting the first read.
st = p.wait_ready(12.0)
after = p.read_uhf() if encode else None
p.ribbon_off()
ok = st == 0x00
return {
'ok': ok, 'sku': sku, 'releaseId': release_id, 'epc': epc,
'epcBefore': before, 'nextTagAtAntenna': after,
'status': st, 'statusText': status_text(st),
'profile': prof['name'], 'size': [prof['web_mm'], prof['pitch_mm']],
'mode': 'encode+print' if encode else 'thermal-test',
'note': ('printer returned to Ready (no VOID) -- verify the chip with a scan'
if ok else 'printer did NOT return to Ready -- check for VOID / jam / tag position'),
}
def do_status():
with LOCK, Urovo() as p:
st = p.status()
return {'ok': True, 'ready': st == 0x00, 'status': st,
'statusText': status_text(st), 'printer': p.model(),
'service': VERSION}
def do_read_tag():
with LOCK, Urovo() as p:
epc = p.read_uhf()
out = {'ok': epc is not None, 'epc': epc}
if epc:
out.update(epc_decode(epc) or {})
out['recognized'] = epc_decode(epc) is not None
else:
out['error'] = 'no tag at the antenna'
return out
def do_read_raw():
with LOCK, Urovo() as p:
banks = {'query': p.query_uhf()}
for code, name in (('E', 'epc'), ('T', 'tid'), ('U', 'user')):
banks[name] = p.read_uhf(bank=code, start=0, length=12)
return {'ok': any(banks.values()), 'banks': banks}
def do_calibrate():
with LOCK, Urovo() as p:
p.rfid_autocalibrate()
st = p.wait_ready(20.0)
return {'ok': st == 0x00, 'status': st, 'statusText': status_text(st)}
def do_recover():
with LOCK, Urovo() as p:
st = p.recover()
return {'ok': st == 0x00, 'status': st, 'statusText': status_text(st)}
# --------------------------------------------------------------------------
class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
def log_message(self, fmt, *args):
sys.stdout.write('%s %s\n' % (time.strftime('%H:%M:%S'), fmt % args))
sys.stdout.flush()
def _cors(self):
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
def _json(self, obj, code=200):
raw = json.dumps(obj, default=str).encode()
self.send_response(code)
self._cors()
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def _png(self, img):
buf = io.BytesIO()
img.convert('L').save(buf, 'PNG')
raw = buf.getvalue()
self.send_response(200)
self._cors()
self.send_header('Content-Type', 'image/png')
self.send_header('Content-Length', str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_OPTIONS(self):
self.send_response(204)
self._cors()
self.send_header('Content-Length', '0')
self.end_headers()
def do_GET(self):
u = urlparse(self.path)
path = u.path.rstrip('/') or '/'
q = {k: v[0] for k, v in parse_qs(u.query).items()}
try:
if path == '/preview':
prof = label.profile(q.get('size'))
return self._png(label.render_art(q or label.DEMO, prof))
if path == '/profiles':
return self._json({'ok': True, 'default': label.DEFAULT_PROFILE,
'profiles': label.PROFILES})
handler = {'/status': do_status, '/read-tag': do_read_tag,
'/read-raw': do_read_raw}.get(path)
if handler:
return self._json(handler())
if path == '/':
return self._json({'ok': True, 'service': VERSION})
self._json({'ok': False, 'error': 'unknown route ' + path}, 404)
except UrovoError as e:
self._json({'ok': False, 'error': str(e)}, 503)
except Exception as e:
self._json({'ok': False, 'error': '%s: %s' % (type(e).__name__, e)}, 500)
def do_POST(self):
path = urlparse(self.path).path.rstrip('/') or '/'
n = int(self.headers.get('Content-Length') or 0)
raw = self.rfile.read(n).decode('utf-8', 'replace') if n else ''
try:
body = json.loads(raw) if raw.strip() else {}
except ValueError:
body = {}
try:
if path in ('/print-encode', '/write-tag'):
return self._json(do_print(body, True))
if path == '/print-test':
return self._json(do_print(body, False))
handler = {'/calibrate': do_calibrate, '/recover': do_recover}.get(path)
if handler:
return self._json(handler())
self._json({'ok': False, 'error': 'unknown route ' + path}, 404)
except UrovoError as e:
self._json({'ok': False, 'error': str(e)}, 503)
except Exception as e:
self._json({'ok': False, 'error': '%s: %s' % (type(e).__name__, e)}, 500)
def main():
port = 7790
if '--port' in sys.argv:
port = int(sys.argv[sys.argv.index('--port') + 1])
srv = ThreadingHTTPServer(('127.0.0.1', port), Handler)
print('%s listening on http://localhost:%d/ (Ctrl+C to stop)' % (VERSION, port))
print('routes: /status /print-encode /write-tag /print-test /read-tag /read-raw '
'/preview /profiles /calibrate /recover')
try:
srv.serve_forever()
except KeyboardInterrupt:
print('\nbye')
if __name__ == '__main__':
main()

216
mac/label.py Normal file
View File

@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""
Render a record label to a 1-bit bitmap for the Urovo D812R+.
WHY A BITMAP INSTEAD OF TSPL TEXT/QRCODE COMMANDS
-------------------------------------------------
The media runs 24 mm across the print head with the long edge along the feed, so a
landscape label design has to be rotated 90 degrees onto it. The printer's built-in
bitmap fonts are small, blocky, and their anchor under `TEXT ...,90,...` is awkward to
lay out against. Rendering with PIL gives real fonts, exact rotation and pixel-level
control -- which is also what it takes to match the extension's labels.js design.
Output goes out via TSPL `BITMAP x,y,<width_bytes>,<height_dots>,<mode>,<data>`.
Bit convention: in TSPL BITMAP data a set bit is WHITE and a clear bit is BLACK, which
is exactly what PIL mode-'1' `tobytes()` produces -- no inversion needed.
MEDIA PROFILES
--------------
Both of the shop's stocks sit on a 24 mm web and feed long-edge-first; they differ in
the label pitch and how much of the web is actually printable:
small 51 x 19 mm design on a 24 mm web (older stock -- the label is 19 mm tall and
the rest of the web is blank filler, so the design is centred across the web)
large 55 x 24 mm design, full width of the web (current stock)
Names match the extension's size dropdown. Anything can be overridden per request.
"""
from PIL import Image, ImageDraw, ImageFont
import segno
DPMM = 8 # 203 dpi ~= 8 dots/mm
def mm(v):
return int(round(v * DPMM))
# web_mm : media width across the print head (what SIZE's first arg must be)
# pitch_mm : label length along the feed (SIZE's second arg)
# art_mm : (long, short) of the printable label face, centred on the web
# margin_mm: safe-area inset held back from every edge of that face. Without it the
# design runs edge-to-edge and the smallest registration drift clips it.
PROFILES = {
'small': dict(web_mm=24, pitch_mm=51, art_mm=(51, 19), gap_mm=2, margin_mm=1.5),
'large': dict(web_mm=24, pitch_mm=55, art_mm=(55, 24), gap_mm=2, margin_mm=1.5),
'xlarge': dict(web_mm=34, pitch_mm=64, art_mm=(64, 34), gap_mm=2, margin_mm=1.5),
}
DEFAULT_PROFILE = 'large'
def profile(name=None, **over):
p = dict(PROFILES.get(name or DEFAULT_PROFILE, PROFILES[DEFAULT_PROFILE]))
p.update({k: v for k, v in over.items() if v is not None})
p['name'] = name or DEFAULT_PROFILE
return p
_FONTS = {
'bold': ['/System/Library/Fonts/Supplemental/Arial Bold.ttf',
'/System/Library/Fonts/Supplemental/Arial Narrow Bold.ttf',
'/System/Library/Fonts/HelveticaNeue.ttc'],
'regular': ['/System/Library/Fonts/Supplemental/Arial.ttf',
'/System/Library/Fonts/Supplemental/Arial Narrow.ttf',
'/System/Library/Fonts/HelveticaNeue.ttc'],
}
_cache = {}
def font(kind, size):
size = max(7, int(size))
key = (kind, size)
if key not in _cache:
for path in _FONTS[kind]:
try:
_cache[key] = ImageFont.truetype(path, size)
break
except Exception:
continue
else:
_cache[key] = ImageFont.load_default()
return _cache[key]
def _w(d, s, f):
return d.textbbox((0, 0), s, font=f)[2]
def fit(d, s, f, maxw):
"""Trim with an ellipsis until it fits maxw dots."""
s = (s or '').strip()
if not s or _w(d, s, f) <= maxw:
return s
while s and _w(d, s + '', f) > maxw:
s = s[:-1]
return (s + '') if s else ''
def _qr(data, maxpx):
"""segno has no PIL export without a plugin, so build it from the module matrix."""
rows = [list(r) for r in segno.make(data, error='m').matrix]
n = len(rows)
scale = max(1, maxpx // n)
img = Image.new('1', (n * scale, n * scale), 1)
px = img.load()
for ry, row in enumerate(rows):
for rx, on in enumerate(row):
if on:
for dy in range(scale):
for dx in range(scale):
px[rx * scale + dx, ry * scale + dy] = 0
return img
def render_art(fields, prof):
"""Draw the landscape design (long axis horizontal). Returns a 1-bit image."""
inset = prof.get('margin_mm', 1.5)
W = mm(prof['art_mm'][0] - 2 * inset)
H = mm(prof['art_mm'][1] - 2 * inset)
img = Image.new('1', (W, H), 1)
d = ImageDraw.Draw(img)
g = lambda k: str(fields.get(k, '') or '').strip()
pad = max(3, H // 24)
# Type scale is a fraction of the design height so 19 mm and 24 mm both work.
S = lambda frac: max(7, int(H * frac))
# ---- right column: price (top), condition, QR (bottom) ----
price, cond, sku = g('price'), g('condition'), g('sku')
f_price = font('bold', S(0.24))
f_cond = font('regular', S(0.105))
qr_img = _qr(sku, int(H * 0.55)) if sku else None
right = 0
if price:
right = max(right, _w(d, price, f_price))
if cond:
right = max(right, _w(d, cond, f_cond))
if qr_img:
right = max(right, qr_img.width)
if right:
right += pad * 2
rx_end = W - pad
y = pad
if price:
d.text((rx_end - _w(d, price, f_price), y), price, font=f_price, fill=0)
y += S(0.24) + 2
if cond:
d.text((rx_end - _w(d, cond, f_cond), y), cond, font=f_cond, fill=0)
if qr_img:
img.paste(qr_img, (rx_end - qr_img.width, H - pad - qr_img.height))
# ---- left column, mirroring the extension's hierarchy ----
lx, avail = pad, W - pad - right
info = g('info')
info_h = S(0.095) + 2 if info else 0
y = pad
for key, kind, frac in (('artist', 'regular', 0.155),
('title', 'regular', 0.085),
('genre', 'regular', 0.125),
('style', 'bold', 0.155)):
val = g(key)
if not val:
continue
size = S(frac)
if y + size > H - pad - info_h:
break
f = font(kind, size)
d.text((lx, y), fit(d, val, f, avail), font=f, fill=0)
y += size + 2
if info:
f = font('regular', S(0.095))
d.text((lx, H - pad - S(0.095)), fit(d, info, f, avail), font=f, fill=0)
return img
def render(fields, prof=None):
"""Full media-sized bitmap, design centred on the web and rotated for the head."""
prof = prof or profile()
art = render_art(fields, prof)
# ROTATE_270 == 90 degrees clockwise, matching TSPL's rotation sense and the
# orientation confirmed on the bench.
rot = art.transpose(Image.ROTATE_270) # now (short x long)
web, pitch = mm(prof['web_mm']), mm(prof['pitch_mm'])
canvas = Image.new('1', (web + (-web % 8), pitch), 1)
canvas.paste(rot, (max(0, (canvas.width - rot.width) // 2),
max(0, (pitch - rot.height) // 2)))
return canvas
def to_tspl(img, x=0, y=0, mode=0):
"""Build the TSPL BITMAP command (ASCII header + raw 1-bpp payload)."""
if img.mode != '1':
img = img.convert('1')
if img.width % 8:
pad = Image.new('1', (img.width + 8 - img.width % 8, img.height), 1)
pad.paste(img, (0, 0))
img = pad
header = 'BITMAP %d,%d,%d,%d,%d,' % (x, y, img.width // 8, img.height, mode)
return header.encode('ascii') + img.tobytes() + b'\r\n'
DEMO = {'artist': 'Lushlife',
'title': '& The Age Of Imagination Quartet Order Of Operations Instrumentals EP',
'genre': 'Hip Hop', 'style': 'Instrumental',
'info': '2007 | Japan | Miclife Recordings MLR-2016',
'price': '$25', 'condition': 'VG+ / VG+',
'sku': '20260722153000', 'releaseId': '12345'}
if __name__ == '__main__':
import sys
for name in (sys.argv[1:] or ['small', 'large']):
p = profile(name)
art = render_art(DEMO, p)
art.save('/tmp/label-%s.png' % name)
print('%-7s art %dx%d dots (%.1fmm inset) web %dmm pitch %dmm -> /tmp/label-%s.png'
% (name, art.width, art.height, p['margin_mm'], p['web_mm'], p['pitch_mm'], name))

14
mac/setup.sh Executable file
View File

@ -0,0 +1,14 @@
#!/bin/bash
# One-time setup for the macOS daemon. Safe to re-run.
set -e
cd "$(dirname "$0")"
command -v brew >/dev/null || { echo "Homebrew required: https://brew.sh"; exit 1; }
brew list libusb >/dev/null 2>&1 || brew install libusb
python3 -m venv venv
./venv/bin/pip install --quiet --upgrade pip
./venv/bin/pip install --quiet pyusb pillow segno
echo
./venv/bin/python urovo.py && echo "OK -- run ./start-daemon.command"

5
mac/start-daemon.command Executable file
View File

@ -0,0 +1,5 @@
#!/bin/bash
# Double-click me. Leave the window open while you work.
cd "$(dirname "$0")"
[ -x venv/bin/python ] || ./setup.sh
exec ./venv/bin/python daemon.py "$@"

278
mac/urovo.py Normal file
View File

@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""
Urovo D812R+ (Gainscha GTSPL) driver for macOS -- raw TSPL over USB, no vendor SDK.
WHY THIS WORKS WITHOUT THE WINDOWS DLL
--------------------------------------
ASSESSMENT.md sec.7 assumed the RFID encode was locked inside 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
decompiling its IL shows every RFID function is a one-line String.Concat -> ASCII ->
WritePrinter. The exact wire format, recovered from ldstr order + argument order:
writeUHF(fmt, start, len, bank, data) -> 'UHF WRITE <fmt>,<start>,<len>,<bank>,"<data>"'
readUHF(fmt, start, len, bank) -> 'UHF READ <fmt>,<start>,<len>,<bank>'
query_UHF(fmt, pcStatus, crcStatus) -> 'UHF QUERY <fmt>,<pc>,<crc>'
EPCPWD_Action(action, pw) -> 'UHF GEN2 EPC <action>,"<pw>"'
Set_RFIDPorcedure(tagType, rw_pos, void_printout, tryEncode, errHandle, speed, retry)
-> 'SET RFID <..7 fields..>' (positions in DOTS)
rfidSetupDefault() -> '&DEFAULT,i'
RFIDAutoCalibration() -> '&CALIBRATE,A,R'
printlabel(set, copy) -> 'PRINT <set>, <copy>' (note space after comma)
Everything is plain ASCII, one command per line, CRLF-terminated. So the whole print+encode
recipe is portable to any platform that can push bytes at the printer's bulk-OUT endpoint.
READ-BACK SEMANTICS (matters -- this is why CUPS 'lp -o raw' is not enough)
UHF READ / UHF QUERY do NOT reply with text. The printer returns raw bytes on the bulk-IN
endpoint and the SDK hex-formats them itself. A reply whose second byte is 0 is the SDK's
"no tag" case (it substitutes 22 zeros). We need a real bidirectional pipe, hence libusb.
Requires: pyusb + libusb -> brew install libusb && pip install pyusb
"""
import ctypes.util
import os
import time
import usb.backend.libusb1
import usb.core
import usb.util
VID, PID = 0x0471, 0x8E92 # Urovo D812R+ ("PRINTER"/"PRINTER", printer class, proto 2)
# Homebrew installs libusb where the dynamic loader won't look by default.
_LIBUSB_HINTS = [
'/opt/homebrew/lib/libusb-1.0.dylib',
'/usr/local/lib/libusb-1.0.dylib',
]
def _backend():
for path in _LIBUSB_HINTS:
if os.path.exists(path):
b = usb.backend.libusb1.get_backend(find_library=lambda _p=path: _p)
if b is not None:
return b
if ctypes.util.find_library('usb-1.0'):
return usb.backend.libusb1.get_backend()
raise RuntimeError('libusb not found -- run: brew install libusb')
# Status byte -> meaning. 0x08 ("out of ribbon") is a LATCH: SET RIBBON OFF alone will not
# clear it, it needs a FORMFEED as well. Verified on this printer from macOS.
STATUS_TEXT = {
0x00: 'Ready',
0x01: 'Head opened',
0x02: 'Paper jam',
0x03: 'Paper jam and head opened',
0x04: 'Out of paper',
0x05: 'Out of paper and head opened',
0x08: 'Out of ribbon (thermal-transfer mode latched -- needs SET RIBBON OFF + FORMFEED)',
0x09: 'Out of ribbon and head opened',
0x10: 'Pause',
0x20: 'Printing',
0x80: 'Other error',
}
def status_text(code):
if code is None:
return 'no response'
return STATUS_TEXT.get(code, 'busy/unknown (0x%02X)' % code)
class UrovoError(RuntimeError):
pass
class Urovo(object):
def __init__(self):
self.dev = usb.core.find(idVendor=VID, idProduct=PID, backend=_backend())
if self.dev is None:
raise UrovoError('Urovo D812R+ (%04x:%04x) not found on USB' % (VID, PID))
try:
self.dev.set_configuration()
except usb.core.USBError:
pass # already configured is fine
intf = next(i for i in self.dev.get_active_configuration()
if i.bInterfaceClass == 7) # USB printer class
self.out = next(e for e in intf if usb.util.endpoint_direction(
e.bEndpointAddress) == usb.util.ENDPOINT_OUT)
self.inp = next((e for e in intf if usb.util.endpoint_direction(
e.bEndpointAddress) == usb.util.ENDPOINT_IN), None)
def close(self):
try:
usb.util.dispose_resources(self.dev)
except Exception:
pass
def __enter__(self):
return self
def __exit__(self, *a):
self.close()
# ---- raw pipe --------------------------------------------------------
def write(self, data):
if isinstance(data, str):
data = data.encode('latin-1', 'replace')
self.out.write(data, timeout=5000)
def read(self, wait=1.0, size=4096):
if self.inp is None:
return b''
out, end = b'', time.time() + wait
while time.time() < end:
try:
chunk = self.inp.read(size, timeout=300)
except usb.core.USBTimeoutError:
continue
except usb.core.USBError:
break
if chunk:
out += bytes(chunk)
end = time.time() + 0.3
return out
def flush_in(self):
self.read(0.25)
def cmd(self, line, wait=0.0):
"""Send one TSPL line (CRLF appended); optionally collect a reply."""
self.write(line + '\r\n')
return self.read(wait) if wait else b''
# ---- status ----------------------------------------------------------
def status(self, wait=0.8):
"""Real-time status: <ESC>!? -> one byte. 0x00 = Ready."""
self.flush_in()
self.write(b'\x1b!?')
r = self.read(wait)
return r[-1] if r else None
def model(self):
self.flush_in()
return self.cmd('~!T', 1.5).decode('latin-1', 'replace').strip()
def wait_ready(self, timeout=10.0):
"""Poll until Ready. The status byte is unreliable for 1-3s after a print
(returns transient garbage like 0x20/0x31/0x45), so never judge a print by
the immediate post-print byte -- wait for it to settle to 0x00."""
end, last = time.time() + timeout, None
while time.time() < end:
last = self.status(0.5)
if last == 0x00:
return 0x00
time.sleep(0.25)
return last
def recover(self):
"""Clear the latched 'out of ribbon' fault. SET RIBBON OFF on its own does not
do it -- it takes a FORMFEED too (costs one blank label)."""
self.cmd('SET RIBBON OFF')
time.sleep(0.4)
if self.wait_ready(3.0) == 0x00:
return 0x00
self.cmd('FORMFEED')
time.sleep(1.5)
self.cmd('SET RIBBON OFF')
time.sleep(0.4)
return self.wait_ready(5.0)
# ---- geometry / drawing (exact GTSPL wire format) --------------------
def cls(self): self.cmd('CLS')
def formfeed(self): self.cmd('FORMFEED')
def ribbon_off(self): self.cmd('SET RIBBON OFF')
def tear_on(self): self.cmd('SET TEAR ON')
def size(self, w, h): self.cmd('SIZE %s mm,%s mm' % (w, h))
def gap(self, g, off=0): self.cmd('GAP %s mm,%s mm' % (g, off))
def density(self, d): self.cmd('DENSITY %s' % d)
def direction(self, d): self.cmd('DIRECTION %s' % d)
def speed(self, s): self.cmd('SPEED %s' % s)
def text(self, x, y, font, rot, xmul, ymul, s):
self.cmd('TEXT %s,%s,"%s",%s,%s,%s,"%s"' % (x, y, font, rot, xmul, ymul, _esc(s)))
def qrcode(self, x, y, ecc, cell, mode, rot, s):
self.cmd('QRCODE %s,%s,%s,%s,%s,%s,"%s"' % (x, y, ecc, cell, mode, rot, _esc(s)))
def bar(self, x, y, w, h):
self.cmd('BAR %s,%s,%s,%s' % (x, y, w, h))
def printlabel(self, sets=1, copies=1):
self.cmd('PRINT %s, %s' % (sets, copies))
# ---- RFID ------------------------------------------------------------
def rfid_setup_default(self): self.cmd('&DEFAULT,i')
def rfid_autocalibrate(self): self.cmd('&CALIBRATE,A,R')
def set_rfid(self, tag_type=0, rw_position=0, void_printout=0,
try_encode=3, error_handle=0, speed=1, retry=3):
"""SET RFID -- positions are in DOTS (203dpi = 8 dots/mm), never mm."""
self.cmd('SET RFID %s,%s,%s,%s,%s,%s,%s' % (
tag_type, rw_position, void_printout, try_encode, error_handle, speed, retry))
def write_uhf(self, hexdata, fmt='H', start=2, length=12, bank='E'):
"""Stage an RFID encode. Fires together with the next PRINT, in one pass."""
self.cmd('UHF WRITE %s,%s,%s,%s,"%s"' % (fmt, start, length, bank, hexdata))
def read_uhf(self, fmt='H', start=2, length=12, bank='E', wait=2.5):
self.flush_in()
return _tag_hex(self.cmd('UHF READ %s,%s,%s,%s' % (fmt, start, length, bank), wait))
def query_uhf(self, fmt='H', pc=0, crc=0, wait=2.5):
self.flush_in()
return _tag_hex(self.cmd('UHF QUERY %s,%s,%s' % (fmt, pc, crc), wait))
def _esc(s):
"""TSPL quotes strings with " and escapes with \\."""
return str(s).replace('\\', '\\\\').replace('"', '\\"')
def _tag_hex(raw):
"""Mirror the SDK's response handling: raw bytes -> uppercase hex, and treat an
all-zero / empty reply as 'no tag at the antenna' (the SDK's own sentinel)."""
if not raw or not any(raw):
return None
return ''.join('%02X' % b for b in raw)
# ---- SKU <-> EPC (v1 scheme, ported byte-for-byte from daemon.cs Epc) -----
# 96-bit EPC = 6 bytes of the 14-digit timestamp SKU + 4 bytes release_id + 0xEC01 marker.
# UNVERIFIED against whatever the Chafon-written tags use. If it must change, change ONLY
# these two functions -- nothing else depends on the byte layout.
EPC_MARKER = b'\xec\x01'
def epc_encode(sku14, release_id):
sku14 = (sku14 or '').strip()
if len(sku14) != 14 or not sku14.isdigit():
raise ValueError('sku must be 14 digits')
rel = int(str(release_id or '0').strip() or '0')
if not 0 <= rel <= 0xFFFFFFFF:
raise ValueError('releaseId must fit in uint32')
b = int(sku14).to_bytes(6, 'big') + rel.to_bytes(4, 'big') + EPC_MARKER
return b.hex().upper()
def epc_decode(hexstr):
hexstr = (hexstr or '').strip()
if len(hexstr) != 24:
return None
try:
b = bytes.fromhex(hexstr)
except ValueError:
return None
if b[10:12] != EPC_MARKER:
return None # not our scheme
return {'sku': str(int.from_bytes(b[:6], 'big')).zfill(14),
'releaseId': str(int.from_bytes(b[6:10], 'big'))}
if __name__ == '__main__':
with Urovo() as p:
print('model :', p.model())
st = p.status()
print('status : 0x%02X %s' % (st, status_text(st)) if st is not None else 'status : none')
print('tag :', p.read_uhf() or '(no tag at antenna)')