diff --git a/mac/README.md b/mac/README.md index 12f6b0b..1a29941 100644 --- a/mac/README.md +++ b/mac/README.md @@ -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 diff --git a/mac/daemon.py b/mac/daemon.py index 5b5197b..1dde3cc 100644 --- a/mac/daemon.py +++ b/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()) diff --git a/mac/dymo.py b/mac/dymo.py new file mode 100644 index 0000000..b6bffca --- /dev/null +++ b/mac/dymo.py @@ -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') diff --git a/mac/label.py b/mac/label.py index 18ce710..52b1006 100644 --- a/mac/label.py +++ b/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 diff --git a/mac/urovo.py b/mac/urovo.py index 8131461..b17abdf 100644 --- a/mac/urovo.py +++ b/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) -----