#!/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')