yourovo/mac/label.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

232 lines
8.7 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 # Urovo: 203 dpi ~= 8 dots/mm
DPMM_DYMO = 300 / 25.4 # Dymo LabelWriter 450: 300 dpi
def mm(v, dpmm=DPMM):
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, dpmm=DPMM, **over):
"""Media profile. `dpmm` picks the target device's resolution -- the same design
renders for the Urovo (203 dpi) or the Dymo (300 dpi) just by changing it."""
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
p['dpmm'] = dpmm or DPMM
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)
dpmm = prof.get('dpmm', DPMM)
W = mm(prof['art_mm'][0] - 2 * inset, dpmm)
H = mm(prof['art_mm'][1] - 2 * inset, dpmm)
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, rotate=True):
"""Full media-sized bitmap, design centred and oriented for the print head.
rotate=True (Urovo) the 24 mm web feeds narrow-edge-first, so the landscape
design is turned 90 degrees onto it.
rotate=False (Dymo) the 57 mm head is wider than the label, so the same design
feeds landscape already and must NOT be rotated.
"""
prof = prof or profile()
dpmm = prof.get('dpmm', DPMM)
art = render_art(fields, prof)
if rotate:
# ROTATE_270 == 90 degrees clockwise, matching TSPL's rotation sense and the
# orientation confirmed on the bench.
art = art.transpose(Image.ROTATE_270)
cw, ch = mm(prof['web_mm'], dpmm), mm(prof['pitch_mm'], dpmm)
else:
cw, ch = mm(prof['art_mm'][0], dpmm), mm(prof['art_mm'][1], dpmm)
canvas = Image.new('1', (cw + (-cw % 8), ch), 1)
canvas.paste(art, (max(0, (canvas.width - art.width) // 2),
max(0, (ch - art.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))