yourovo/mac/label.py
type-two b014205a5b 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>
2026-07-22 16:07:12 +10:00

217 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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))