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>
232 lines
8.7 KiB
Python
232 lines
8.7 KiB
Python
#!/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))
|