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:
type-two 2026-07-22 16:36:34 +10:00
parent b014205a5b
commit 2727545ac7
5 changed files with 405 additions and 38 deletions

View File

@ -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

View File

@ -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
View 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')

View File

@ -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

View File

@ -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) -----