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

176 lines
6.3 KiB
Python

#!/usr/bin/env python3
"""
DYMO LabelWriter 450 driver for macOS -- raw raster over USB, no CUPS, no driver.
The LW450 (USB 0922:0020) is a bidirectional USB printer-class device just like the
Urovo, so it can be driven straight off the bulk endpoints. That means no DYMO driver
install, no CUPS queue, and no open-window/cmd-P/cmd-W dance -- the daemon just prints.
Command set is from DYMO's own LabelWriter 450 Series Technical Reference Manual:
ESC @ 1B 40 reset printer (all params to defaults, top-of-form true)
ESC * 1B 2A restore default settings
ESC A 1B 41 get status -> 1 byte; 0x03 = Ready + top-of-form
ESC B n 1B 42 n dot tab: shift start right by n bytes (8 dots each), 0..83
ESC D n 1B 44 n bytes per line, 1..84 (default 84)
ESC L h l label length in dot lines (msb, lsb); 0x8000-0xFFFF = continuous
ESC E 1B 45 form feed (advance last label to the tear bar)
ESC G 1B 47 short form feed (between labels of a multi-label job)
ESC h / ESC i 300x300 text mode (default) / 300x600 graphics mode
ESC c/d/e/g density light / medium / normal / dark
SYN 16 + data one uncompressed raster line
ETB 17 + data one compressed raster line (not needed over USB)
Head: 57 mm wide, 300 dpi, 672 addressable dots = 84 bytes per line.
Bit polarity: a SET bit means "print this dot" (black) -- the OPPOSITE of TSPL BITMAP,
where a set bit is white. PIL mode '1' gives 1 = white, so the data is inverted here.
Recovery: to resynchronise from an unknown state the manual specifies sending at least
85 consecutive ESC bytes (one more than the longest possible raster line), which
guarantees the printer stops treating input as pixel data.
"""
import time
import usb.core
import usb.util
from urovo import _backend
VID, PID = 0x0922, 0x0020
DPI = 300
HEAD_BYTES = 84 # 672 dots / 8
ESC = 0x1B
DENSITY = {'light': b'\x1bc', 'medium': b'\x1bd', 'normal': b'\x1be', 'dark': b'\x1bg'}
STATUS_BITS = [(0, 'Ready'), (1, 'Top of form'), (5, 'No paper'),
(6, 'Paper jam'), (7, 'Printer error')]
class DymoError(RuntimeError):
pass
def status_text(code):
if code is None:
return 'no response'
if code == 0x03:
return 'Ready'
on = [name for bit, name in STATUS_BITS if code & (1 << bit)]
return ', '.join(on) or 'busy/unknown (0x%02X)' % code
class Dymo(object):
def __init__(self):
self.dev = usb.core.find(idVendor=VID, idProduct=PID, backend=_backend())
if self.dev is None:
raise DymoError('DYMO LabelWriter 450 (%04x:%04x) not found on USB' % (VID, PID))
try:
self.dev.set_configuration()
except usb.core.USBError:
pass
intf = next(i for i in self.dev.get_active_configuration()
if i.bInterfaceClass == 7)
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()
# ---- pipe ------------------------------------------------------------
def write(self, data):
self.out.write(data, timeout=10000)
def read(self, wait=1.0):
if self.inp is None:
return b''
out, end = b'', time.time() + wait
while time.time() < end:
try:
c = self.inp.read(64, timeout=300)
except usb.core.USBTimeoutError:
continue
except usb.core.USBError:
break
if c:
out += bytes(c)
end = time.time() + 0.2
return out
# ---- commands --------------------------------------------------------
def resync(self):
"""85 ESC bytes -- the manual's prescribed way out of an unknown state."""
self.write(bytes([ESC] * 85))
def reset(self):
self.write(b'\x1b@')
def status(self, wait=1.0):
self.read(0.2)
self.write(b'\x1bA')
r = self.read(wait)
return r[-1] if r else None
def revision(self, wait=1.5):
self.read(0.2)
self.write(b'\x1bV')
return self.read(wait).decode('latin-1', 'replace').strip()
def form_feed(self):
self.write(b'\x1bE')
def print_image(self, img, density='normal', graphics_mode=True, feed=True):
"""Send a 1-bit PIL image, already oriented for the head (width = across head).
Returns the number of raster lines sent.
"""
if img.mode != '1':
img = img.convert('1')
if img.width > HEAD_BYTES * 8:
raise DymoError('image is %d dots wide; head is %d'
% (img.width, HEAD_BYTES * 8))
# pad the row width out to whole bytes
if img.width % 8:
from PIL import Image as _I
pad = _I.new('1', (img.width + 8 - img.width % 8, img.height), 1)
pad.paste(img, (0, 0))
img = pad
per_line = img.width // 8
raw = img.tobytes() # PIL: bit set = WHITE
data = bytes(b ^ 0xFF for b in raw) # DYMO: bit set = BLACK
out = bytearray()
out += b'\x1b@' # reset
out += b'\x1bi' if graphics_mode else b'\x1bh' # 300x600 / 300x300
out += DENSITY.get(density, DENSITY['normal'])
out += bytes([ESC, 0x4C, (img.height >> 8) & 0xFF, img.height & 0xFF])
out += bytes([ESC, 0x42, 0]) # dot tab 0
out += bytes([ESC, 0x44, per_line]) # bytes per line
for row in range(img.height):
out += b'\x16' + data[row * per_line:(row + 1) * per_line]
if feed:
out += b'\x1bE'
self.write(bytes(out))
return img.height
if __name__ == '__main__':
with Dymo() as p:
st = p.status()
print('revision:', p.revision())
print('status : 0x%02X %s' % (st, status_text(st)) if st is not None
else 'status : none')