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>
253 lines
9.3 KiB
Python
253 lines
9.3 KiB
Python
#!/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()
|