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>
176 lines
6.3 KiB
Python
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')
|