diff --git a/.gitignore b/.gitignore index d9228d1..bf59736 100644 --- a/.gitignore +++ b/.gitignore @@ -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. diff --git a/ASSESSMENT.md b/ASSESSMENT.md index bcceae9..79cf846 100644 --- a/ASSESSMENT.md +++ b/ASSESSMENT.md @@ -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 1–3 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`. diff --git a/mac/README.md b/mac/README.md new file mode 100644 index 0000000..12f6b0b --- /dev/null +++ b/mac/README.md @@ -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 ,,,,"" # the RFID encode +UHF READ ,,, +UHF QUERY ,, +UHF GEN2 EPC|TID|USER|ACCESS|KILL ,"" +SET RFID ,,,,,, +&DEFAULT,i # rfidSetupDefault &CALIBRATE,A,R # RFIDAutoCalibration +PRINT , # 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 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 (⚠ 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`. diff --git a/mac/daemon.py b/mac/daemon.py new file mode 100644 index 0000000..5b5197b --- /dev/null +++ b/mac/daemon.py @@ -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() diff --git a/mac/label.py b/mac/label.py new file mode 100644 index 0000000..18ce710 --- /dev/null +++ b/mac/label.py @@ -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,,,,`. +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)) diff --git a/mac/setup.sh b/mac/setup.sh new file mode 100755 index 0000000..f8bc575 --- /dev/null +++ b/mac/setup.sh @@ -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" diff --git a/mac/start-daemon.command b/mac/start-daemon.command new file mode 100755 index 0000000..04e3f2f --- /dev/null +++ b/mac/start-daemon.command @@ -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 "$@" diff --git a/mac/urovo.py b/mac/urovo.py new file mode 100644 index 0000000..8131461 --- /dev/null +++ b/mac/urovo.py @@ -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 ,,,,""' + readUHF(fmt, start, len, bank) -> 'UHF READ ,,,' + query_UHF(fmt, pcStatus, crcStatus) -> 'UHF QUERY ,,' + EPCPWD_Action(action, pw) -> 'UHF GEN2 EPC ,""' + 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 , ' (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: !? -> 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)')