yourovo/mac/dymo.py
type-two d254d16278 Fix Dymo label geometry: rotation and 300x600 dpi halving
Two bugs found on the bench, both from wrong assumptions about the LW450:

1. Orientation. I assumed the 57mm head meant the label feeds landscape. It
   doesn't -- the Dymo takes the SAME 24mm narrow-edge-first stock as the Urovo,
   so the landscape design needs the identical 90-degree rotation. Rendering
   656 dots onto a ~288-dot label also silently clipped the price and QR off the
   right-hand edge. Now rotates by default, overridable per request via "rotate",
   and the daemon rejects an image wider than the head instead of clipping it.

2. Half-length labels. print_image() sent ESC i (300x600 Barcode/Graphics mode),
   where the head stays 300 dpi across but each raster line becomes 1/600" in the
   travel direction -- so a 650-line label printed at 27.5mm instead of 55mm, with
   the across-head dimension correct. Default is now ESC h (300x300), matching our
   square 300 dpi bitmap. graphics_mode=True remains available and now stretches
   the image 2x vertically so physical size stays correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 16:39:58 +10:00

186 lines
6.9 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=False, feed=True):
"""Send a 1-bit PIL image, already oriented for the head (width = across head).
The bitmap is assumed to be 300 dpi in BOTH axes, so the default is `ESC h`
(300x300 Text mode). `ESC i` selects 300x600 Barcode/Graphics mode, where the
head is still 300 dpi across but each raster line becomes 1/600" in the travel
direction -- printing the label at HALF length unless the image is doubled up.
So when graphics_mode is on we stretch the image 2x vertically to keep the
physical size right.
Returns the number of raster lines sent.
"""
if img.mode != '1':
img = img.convert('1')
if graphics_mode:
from PIL import Image as _I
img = img.resize((img.width, img.height * 2), _I.NEAREST)
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')