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>
This commit is contained in:
parent
b014205a5b
commit
2727545ac7
@ -1,9 +1,27 @@
|
||||
# pricegod-urovo-daemon (macOS)
|
||||
# pricegod label daemon (macOS)
|
||||
|
||||
The Mac counterpart to the Windows `daemon.cs`. Drives the **Urovo D812R+ (RFID)** over
|
||||
raw USB — printing a label and encoding the UHF chip in one pass — and serves the same
|
||||
`localhost:7790` HTTP contract, so it is a drop-in for the PriceGod extension's
|
||||
`daemon.js` client.
|
||||
One service on `localhost:7790` that drives **both** printers over raw USB:
|
||||
|
||||
| device | role | how |
|
||||
|---|---|---|
|
||||
| **Urovo D812R+** | prints TSPL labels, programs UHF chips | raw TSPL, `urovo.py` |
|
||||
| **Dymo LabelWriter 450** | prints the paper label | raw raster, `dymo.py` — **no CUPS driver, no print dialog** |
|
||||
| Chafon H-102 | reads tags off the shelf | *not this service* — see “Chafon” below |
|
||||
|
||||
It is the Mac counterpart to the Windows `daemon.cs` and keeps the same HTTP contract,
|
||||
so it stays a drop-in for the PriceGod extension's `daemon.js` client.
|
||||
|
||||
### Two things worth knowing up front
|
||||
|
||||
**`UHF WRITE` commits immediately — it does not wait for `PRINT`.** `daemon.cs` assumed the
|
||||
encode only fired together with `printlabel`. It doesn't. That means the Urovo can program
|
||||
a chip while printing nothing, which is what makes the split flow (Urovo encodes, Dymo
|
||||
prints) possible, and it lets the daemon read the chip back to *verify* an encode before
|
||||
spending a label on it.
|
||||
|
||||
**The Chafon works over serial on macOS.** ASSESSMENT.md §4 found the gun was HID-keyboard
|
||||
only on Windows. On the Mac it enumerates a CH340 bridge at `/dev/cu.usbserial-*` and the
|
||||
existing Node `rfid-daemon` speaks its protocol fine, so that dead end is Windows-specific.
|
||||
|
||||
**No vendor SDK required.** `ASSESSMENT.md` §7 assumed the RFID encode was locked inside
|
||||
the Windows-only `GTSPL_SDK.dll` and would need a USB sniff or the Java jar to recover.
|
||||
@ -36,27 +54,62 @@ Then double-click **`start-daemon.command`** (leave it open while you work).
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `urovo.py` | USB transport + the full GTSPL command set, status decoding, fault recovery, SKU↔EPC packing. Runs standalone as a connection test. |
|
||||
| `label.py` | Renders the label to a 1-bit bitmap with PIL and emits TSPL `BITMAP`. Run standalone to dump previews to `/tmp`. |
|
||||
| `urovo.py` | Urovo USB transport + the full GTSPL command set, status decoding, fault recovery, SKU↔EPC packing. Runs standalone as a connection test. |
|
||||
| `dymo.py` | Dymo LabelWriter 450 raster driver over raw USB. Runs standalone to report status/revision. |
|
||||
| `label.py` | Renders the label to a 1-bit bitmap with PIL, resolution-aware (203 dpi Urovo / 300 dpi Dymo). Run standalone to dump previews to `/tmp`. |
|
||||
| `daemon.py` | The `:7790` HTTP service. |
|
||||
|
||||
## Routes
|
||||
|
||||
| method | path | body / query | returns |
|
||||
|---|---|---|---|
|
||||
| GET | `/status` | — | `{ok,ready,status,statusText,printer}` |
|
||||
| POST | `/print-encode` | `{sku,releaseId,artist?,title?,genre?,style?,info?,price?,condition?,size?,w?,h?,gap?}` | `{ok,epc,status,...}` |
|
||||
| GET | `/devices` | — | **status of Urovo, Dymo and Chafon in one call** |
|
||||
| GET | `/status` | — | Urovo only: `{ok,ready,status,statusText,printer}` |
|
||||
| POST | `/tag-and-print` | label fields + `{sku,releaseId}` | **Mode A in one call: Urovo programs the chip, Dymo prints the paper** |
|
||||
| POST | `/encode` | `{sku,releaseId}` | programs the chip at the antenna and reads it back. **Prints nothing, costs no label** |
|
||||
| POST | `/print-paper` | label fields + `size?`, `density?` | prints on the Dymo |
|
||||
| POST | `/print-encode` | `{sku,releaseId,artist?,title?,genre?,style?,info?,price?,condition?,size?,w?,h?,gap?}` | Urovo one-pass print **and** encode (needs RFID stock) |
|
||||
| POST | `/write-tag` | alias of `/print-encode` | same |
|
||||
| POST | `/print-test` | same body — **prints only, no RFID** (safe on chipless stock) | same |
|
||||
| POST | `/print-test` | same body — Urovo prints only, no RFID (safe on chipless stock) | same |
|
||||
| GET | `/read-tag` | — | `{ok,epc,sku,releaseId,recognized}` |
|
||||
| GET | `/read-raw` | — | every memory bank |
|
||||
| GET | `/preview?size=large&artist=…` | — | **PNG of the label, printing nothing** |
|
||||
| GET | `/profiles` | — | the media profiles below |
|
||||
| POST | `/calibrate` | — | RFID auto-calibration |
|
||||
| POST | `/recover` | — | clear a latched fault |
|
||||
| POST | `/calibrate` / `/recover` | — | RFID auto-calibration / clear a latched fault |
|
||||
|
||||
Use `/preview` while tuning a layout — it costs no labels.
|
||||
|
||||
## Which flow to use
|
||||
|
||||
- **Mode A — separate (today, existing stock).** `POST /tag-and-print`. The Urovo programs
|
||||
the chip on an RFID label; the Dymo prints the paper; you stick the paper over the chip.
|
||||
The encode is verified *before* the paper prints, so a failed write wastes nothing.
|
||||
- **Mode B — one pass (the goal).** `POST /print-encode` on direct-thermal RFID paper
|
||||
(ASSESSMENT.md §5). One label, printed and encoded together.
|
||||
|
||||
## The Dymo, without a driver
|
||||
|
||||
The LabelWriter 450 (`0922:0020`) is a bidirectional USB printer-class device, so the
|
||||
daemon writes its raster protocol straight to the bulk endpoint. **No DYMO driver, no CUPS
|
||||
queue, no open-window/cmd-P/cmd-W.** Command set is from DYMO's own *LabelWriter 450 Series
|
||||
Technical Reference Manual*; `dymo.py` documents each command it uses.
|
||||
|
||||
Note the two printers differ in orientation: the Urovo's 24 mm web feeds narrow-edge-first
|
||||
so the design is rotated 90°, while the Dymo's 57 mm head is wider than the label so the
|
||||
same design feeds landscape and must **not** be rotated. `label.render(..., rotate=)`
|
||||
handles this, and the resolution difference (203 vs 300 dpi) comes from `profile(dpmm=)`.
|
||||
|
||||
## Chafon, and the `:7790` collision
|
||||
|
||||
⚠️ The existing Node daemon at `~/Documents/pliceclogs/rfid-daemon` **also listens on
|
||||
:7790**. Both cannot run at once. That daemon owns the Chafon serial protocol
|
||||
(`CF [ADDR] [CMD] [LEN] [DATA] [CRC16]` @115200) plus the postal-scale and inventory
|
||||
routes, none of which this service implements — so it is *not* a full replacement.
|
||||
|
||||
Pick one of: run this on `--port 7791`; keep the Chafon daemon for the gun and run this
|
||||
one elsewhere; or fold the Chafon protocol in here. Unresolved — decide before wiring the
|
||||
extension.
|
||||
|
||||
## Media profiles
|
||||
|
||||
Both shop stocks sit on a **24 mm web** and feed long-edge-first, so the landscape design
|
||||
|
||||
126
mac/daemon.py
126
mac/daemon.py
@ -22,6 +22,7 @@ Routes
|
||||
|
||||
Run: ./venv/bin/python daemon.py (or ./start-daemon.command)
|
||||
"""
|
||||
import glob
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
@ -31,11 +32,12 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
import label
|
||||
import dymo as dymo_mod
|
||||
from urovo import Urovo, UrovoError, epc_encode, epc_decode, status_text
|
||||
|
||||
VERSION = 'pricegod-urovo-daemon-mac/1.0'
|
||||
VERSION = 'pricegod-label-daemon-mac/1.1'
|
||||
DENSITY = 15 # max darkness -- anything lower barely marks
|
||||
LOCK = threading.Lock() # one USB handle; serialise all hardware access
|
||||
LOCK = threading.Lock() # one USB handle each; serialise all hardware access
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@ -78,11 +80,19 @@ def do_print(body, encode):
|
||||
geometry(p, prof)
|
||||
p.cls()
|
||||
|
||||
before = p.read_uhf() if encode else None
|
||||
|
||||
# 3) stage the RFID write BEFORE the image; PRINT fires both in one pass
|
||||
before = verified = None
|
||||
if encode:
|
||||
# UHF WRITE commits immediately -- it does NOT wait for PRINT (daemon.cs
|
||||
# assumed it did). That lets us read the chip back and confirm the encode
|
||||
# BEFORE spending a label on it.
|
||||
before = p.read_uhf()
|
||||
p.write_uhf(epc)
|
||||
time.sleep(1.0)
|
||||
verified = p.read_uhf() == epc
|
||||
if not verified:
|
||||
return {'ok': False, 'error': 'RFID encode failed verification',
|
||||
'epc': epc, 'epcBefore': before, 'readBack': p.read_uhf(),
|
||||
'note': 'nothing printed -- no label wasted'}
|
||||
p.write(label.to_tspl(label.render(body, prof)))
|
||||
|
||||
# 4) re-assert ribbon-off immediately before firing
|
||||
@ -99,7 +109,7 @@ def do_print(body, encode):
|
||||
ok = st == 0x00
|
||||
return {
|
||||
'ok': ok, 'sku': sku, 'releaseId': release_id, 'epc': epc,
|
||||
'epcBefore': before, 'nextTagAtAntenna': after,
|
||||
'epcBefore': before, 'verified': verified, 'nextTagAtAntenna': after,
|
||||
'status': st, 'statusText': status_text(st),
|
||||
'profile': prof['name'], 'size': [prof['web_mm'], prof['pitch_mm']],
|
||||
'mode': 'encode+print' if encode else 'thermal-test',
|
||||
@ -108,6 +118,102 @@ def do_print(body, encode):
|
||||
}
|
||||
|
||||
|
||||
def do_encode(body):
|
||||
"""Program the chip at the antenna and read it straight back. Prints nothing.
|
||||
|
||||
This is Mode A ('Separate'): the Urovo programs the chip while the paper label is
|
||||
printed elsewhere (the Dymo). Possible because UHF WRITE commits on its own rather
|
||||
than waiting for PRINT, so no label is consumed and the write is verifiable.
|
||||
"""
|
||||
sku = str(body.get('sku', '') or '').strip()
|
||||
release_id = str(body.get('releaseId', '') or '').strip()
|
||||
try:
|
||||
epc = epc_encode(sku, release_id)
|
||||
except ValueError as e:
|
||||
return {'ok': False, 'error': str(e)}
|
||||
|
||||
with LOCK, Urovo() as p:
|
||||
before = p.read_uhf()
|
||||
if before is None:
|
||||
return {'ok': False, 'error': 'no tag at the antenna',
|
||||
'note': 'seat an RFID label at the print head and retry'}
|
||||
p.write_uhf(epc)
|
||||
time.sleep(1.0)
|
||||
after = p.read_uhf()
|
||||
|
||||
ok = after == epc
|
||||
return {'ok': ok, 'sku': sku, 'releaseId': release_id, 'epc': epc,
|
||||
'epcBefore': before, 'epcAfter': after, 'verified': ok,
|
||||
'decoded': epc_decode(after or ''),
|
||||
'note': ('chip programmed and read back -- scan it to confirm' if ok
|
||||
else 'read-back did NOT match; tag may be locked or out of position')}
|
||||
|
||||
|
||||
def do_print_paper(body):
|
||||
"""Print the paper label on the Dymo LabelWriter 450 (raw USB, no CUPS driver)."""
|
||||
prof = label.profile(body.get('size'), dpmm=label.DPMM_DYMO,
|
||||
art_mm=body.get('art_mm'), margin_mm=body.get('margin'))
|
||||
img = label.render(body, prof, rotate=False) # 57 mm head: no rotation needed
|
||||
with LOCK, dymo_mod.Dymo() as d:
|
||||
before = d.status()
|
||||
if before is not None and not before & 0x01:
|
||||
return {'ok': False, 'status': before,
|
||||
'statusText': dymo_mod.status_text(before),
|
||||
'error': 'Dymo not ready'}
|
||||
lines = d.print_image(img, density=body.get('density', 'normal'))
|
||||
time.sleep(1.5)
|
||||
st = d.status()
|
||||
return {'ok': st is None or bool(st & 0x01), 'printer': 'dymo',
|
||||
'status': st, 'statusText': dymo_mod.status_text(st),
|
||||
'profile': prof['name'], 'lines': lines, 'bytesPerLine': img.width // 8}
|
||||
|
||||
|
||||
def do_tag_and_print(body):
|
||||
"""Mode A, one call: the Urovo programs the chip, the Dymo prints the paper.
|
||||
|
||||
Encode first and verify it, so a failed write never costs a printed label.
|
||||
"""
|
||||
out = {'ok': False}
|
||||
enc = do_encode(body)
|
||||
out['encode'] = enc
|
||||
if not enc.get('ok'):
|
||||
out['error'] = 'chip not programmed -- nothing printed'
|
||||
return out
|
||||
pr = do_print_paper(body)
|
||||
out['print'] = pr
|
||||
out['ok'] = bool(pr.get('ok'))
|
||||
out['epc'] = enc.get('epc')
|
||||
out['note'] = ('chip programmed and paper printed -- stick the paper over the chip'
|
||||
if out['ok'] else 'chip programmed but the paper print failed')
|
||||
return out
|
||||
|
||||
|
||||
def do_devices():
|
||||
"""One glance at all three pieces of hardware."""
|
||||
out = {'ok': True, 'service': VERSION}
|
||||
try:
|
||||
with LOCK, Urovo() as p:
|
||||
st = p.status()
|
||||
out['urovo'] = {'present': True, 'model': p.model(), 'status': st,
|
||||
'statusText': status_text(st), 'ready': st == 0x00}
|
||||
except Exception as e:
|
||||
out['urovo'] = {'present': False, 'error': str(e)}
|
||||
try:
|
||||
with LOCK, dymo_mod.Dymo() as d:
|
||||
st = d.status()
|
||||
out['dymo'] = {'present': True, 'revision': d.revision(), 'status': st,
|
||||
'statusText': dymo_mod.status_text(st),
|
||||
'ready': bool(st and st & 0x01)}
|
||||
except Exception as e:
|
||||
out['dymo'] = {'present': False, 'error': str(e)}
|
||||
# The Chafon gun speaks its own serial protocol on a CH340 bridge. This daemon only
|
||||
# reports the port; the Node rfid-daemon owns that protocol (see README).
|
||||
ports = sorted(glob.glob('/dev/cu.usbserial*'))
|
||||
out['chafon'] = {'present': bool(ports), 'ports': ports,
|
||||
'handledBy': 'node rfid-daemon (not this service)'}
|
||||
return out
|
||||
|
||||
|
||||
def do_status():
|
||||
with LOCK, Urovo() as p:
|
||||
st = p.status()
|
||||
@ -200,7 +306,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self._json({'ok': True, 'default': label.DEFAULT_PROFILE,
|
||||
'profiles': label.PROFILES})
|
||||
handler = {'/status': do_status, '/read-tag': do_read_tag,
|
||||
'/read-raw': do_read_raw}.get(path)
|
||||
'/read-raw': do_read_raw, '/devices': do_devices}.get(path)
|
||||
if handler:
|
||||
return self._json(handler())
|
||||
if path == '/':
|
||||
@ -224,6 +330,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self._json(do_print(body, True))
|
||||
if path == '/print-test':
|
||||
return self._json(do_print(body, False))
|
||||
if path == '/encode':
|
||||
return self._json(do_encode(body))
|
||||
if path == '/print-paper':
|
||||
return self._json(do_print_paper(body))
|
||||
if path == '/tag-and-print':
|
||||
return self._json(do_tag_and_print(body))
|
||||
handler = {'/calibrate': do_calibrate, '/recover': do_recover}.get(path)
|
||||
if handler:
|
||||
return self._json(handler())
|
||||
|
||||
175
mac/dymo.py
Normal file
175
mac/dymo.py
Normal file
@ -0,0 +1,175 @@
|
||||
#!/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')
|
||||
45
mac/label.py
45
mac/label.py
@ -28,11 +28,12 @@ Names match the extension's size dropdown. Anything can be overridden per reques
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import segno
|
||||
|
||||
DPMM = 8 # 203 dpi ~= 8 dots/mm
|
||||
DPMM = 8 # Urovo: 203 dpi ~= 8 dots/mm
|
||||
DPMM_DYMO = 300 / 25.4 # Dymo LabelWriter 450: 300 dpi
|
||||
|
||||
|
||||
def mm(v):
|
||||
return int(round(v * DPMM))
|
||||
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)
|
||||
@ -48,10 +49,13 @@ PROFILES = {
|
||||
DEFAULT_PROFILE = 'large'
|
||||
|
||||
|
||||
def profile(name=None, **over):
|
||||
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
|
||||
|
||||
|
||||
@ -114,8 +118,9 @@ def _qr(data, maxpx):
|
||||
def render_art(fields, prof):
|
||||
"""Draw the landscape design (long axis horizontal). Returns a 1-bit image."""
|
||||
inset = prof.get('margin_mm', 1.5)
|
||||
W = mm(prof['art_mm'][0] - 2 * inset)
|
||||
H = mm(prof['art_mm'][1] - 2 * inset)
|
||||
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()
|
||||
@ -173,17 +178,27 @@ def render_art(fields, prof):
|
||||
return img
|
||||
|
||||
|
||||
def render(fields, prof=None):
|
||||
"""Full media-sized bitmap, design centred on the web and rotated for the head."""
|
||||
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)
|
||||
# ROTATE_270 == 90 degrees clockwise, matching TSPL's rotation sense and the
|
||||
# orientation confirmed on the bench.
|
||||
rot = art.transpose(Image.ROTATE_270) # now (short x long)
|
||||
web, pitch = mm(prof['web_mm']), mm(prof['pitch_mm'])
|
||||
canvas = Image.new('1', (web + (-web % 8), pitch), 1)
|
||||
canvas.paste(rot, (max(0, (canvas.width - rot.width) // 2),
|
||||
max(0, (pitch - rot.height) // 2)))
|
||||
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
|
||||
|
||||
|
||||
|
||||
20
mac/urovo.py
20
mac/urovo.py
@ -135,7 +135,10 @@ class Urovo(object):
|
||||
return out
|
||||
|
||||
def flush_in(self):
|
||||
self.read(0.25)
|
||||
"""Drain stale replies. Without this a slow answer from the previous command
|
||||
gets attributed to the next one, which silently corrupts tag reads."""
|
||||
while self.read(0.25):
|
||||
pass
|
||||
|
||||
def cmd(self, line, wait=0.0):
|
||||
"""Send one TSPL line (CRLF appended); optionally collect a reply."""
|
||||
@ -231,11 +234,20 @@ def _esc(s):
|
||||
|
||||
|
||||
def _tag_hex(raw):
|
||||
"""Mirror the SDK's response handling: raw bytes -> uppercase hex, and treat an
|
||||
all-zero / empty reply as 'no tag at the antenna' (the SDK's own sentinel)."""
|
||||
"""Parse a UHF READ/QUERY reply.
|
||||
|
||||
With dataFormat 'H' the printer answers in ASCII hex *text* -- e.g. it literally
|
||||
sends b'E28069950000600625F600D0'. (Do NOT hex-encode it again; the SDK's own
|
||||
byte.ToString("X2") loop is formatting a buffer it has already converted.)
|
||||
An empty reply, all zeros, or a lone status byte such as 0xFC means no readable
|
||||
tag at the antenna.
|
||||
"""
|
||||
if not raw or not any(raw):
|
||||
return None
|
||||
return ''.join('%02X' % b for b in raw)
|
||||
s = raw.decode('latin-1', 'replace').strip().strip('\x00').strip()
|
||||
if len(s) < 4 or any(c not in '0123456789abcdefABCDEF' for c in s):
|
||||
return None
|
||||
return s.upper()
|
||||
|
||||
|
||||
# ---- SKU <-> EPC (v1 scheme, ported byte-for-byte from daemon.cs Epc) -----
|
||||
|
||||
Loading…
Reference in New Issue
Block a user