yourovo/mac/urovo.py
type-two 2727545ac7 Add Dymo LabelWriter 450 driver; unify both printers in one daemon
Answers "can we direct print to the Dymo via CUPS, or is it the cmd-P dance?":
neither. The LW450 (0922:0020) is a bidirectional USB printer-class device just
like the Urovo, so its raster protocol goes straight to the bulk endpoint -- no
DYMO driver, no CUPS queue, no print dialog. Command set taken from DYMO's own
LabelWriter 450 Series Technical Reference Manual and documented in dymo.py.
Verified on the bench: revision 1750111r53, status 0x03 Ready, 283 raster lines
at 82 bytes/line accepted.

Also corrects an assumption inherited from daemon.cs: UHF WRITE commits
immediately and does NOT wait for PRINT. Confirmed by writing a chip and reading
it back with no PRINT sent. Two consequences:
  - the Urovo can program a chip without printing or consuming a label, which is
    what makes the Urovo-encodes/Dymo-prints split work at all;
  - an encode can be verified BEFORE printing, so a failed write never costs a
    label. /print-encode and /encode both do this now.

New routes: /devices (all three devices at a glance), /encode (chip only),
/print-paper (Dymo), /tag-and-print (Mode A in one call).

label.py is now resolution-aware (203 dpi Urovo / 300 dpi Dymo via profile(dpmm=))
and orientation-aware: the Urovo's 24mm web feeds narrow-edge-first so the design
rotates 90 degrees, while the Dymo's 57mm head is wider than the label so the same
design feeds landscape unrotated.

Also fixes tag read parsing: with dataFormat 'H' the printer replies in ASCII hex
TEXT, not raw bytes -- the old code hex-encoded it a second time. flush_in() now
drains fully, since a late reply to one command was being attributed to the next
and silently corrupting reads.

KNOWN ISSUE: the Node rfid-daemon (Chafon + scale + inventory) also binds :7790.
Both cannot run at once and this service does not implement its routes. Unresolved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 16:36:34 +10:00

291 lines
11 KiB
Python

#!/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):
"""Drain stale replies. Without this a slow answer from the previous command
gets attributed to the next one, which silently corrupts tag reads."""
while self.read(0.25):
pass
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):
"""Parse a UHF READ/QUERY reply.
With dataFormat 'H' the printer answers in ASCII hex *text* -- e.g. it literally
sends b'E28069950000600625F600D0'. (Do NOT hex-encode it again; the SDK's own
byte.ToString("X2") loop is formatting a buffer it has already converted.)
An empty reply, all zeros, or a lone status byte such as 0xFC means no readable
tag at the antenna.
"""
if not raw or not any(raw):
return None
s = raw.decode('latin-1', 'replace').strip().strip('\x00').strip()
if len(s) < 4 or any(c not in '0123456789abcdefABCDEF' for c in s):
return None
return s.upper()
# ---- 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)')