macOS: raw-TSPL Urovo daemon — no vendor SDK needed
ASSESSMENT.md §7 assumed the RFID encode was locked inside the Windows-only
GTSPL_SDK.dll and would need a USBPcap sniff or the GTSPL Java jar to recover.
It didn't. The DLL is a managed .NET assembly, and its IL shows every RFID call
is a one-line String.Concat -> ASCII -> WritePrinter. Recovered from ldstr order
plus argument order, and confirmed by a second independent decompile:
UHF WRITE H,2,12,E,"<24 hex>" UHF READ/QUERY ...
UHF GEN2 EPC|TID|USER|ACCESS|KILL <action>,"<pw>"
SET RFID <tagType>,<rw_pos>,<void>,<try>,<err>,<speed>,<retry> (dots, not mm)
&DEFAULT,i &CALIBRATE,A,R PRINT <set>, <copy>
So the whole recipe is plain ASCII over the printer's bulk endpoint and ports to
macOS with no vendor binaries at all.
- mac/urovo.py USB transport + full GTSPL command set, status decoding, the
ribbon-latch recovery, SKU<->EPC packing ported from daemon.cs.
- mac/label.py PIL bitmap renderer -> TSPL BITMAP. Media-profile driven, so both
shop stocks work (small 51x19 and large 55x24 on the 24mm web),
with a safe-area inset so the design can't clip at the edges.
- mac/daemon.py :7790 HTTP service, same routes/JSON/CORS as daemon.cs, plus a
/preview route that renders a label as PNG without printing one.
Verified on the bench from macOS: printer identifies (MODEL:UROVO-D812R), the
latched 0x08 "out of ribbon" clears with SET RIBBON OFF + FORMFEED, and labels
print correctly oriented and dark on the 24x55 stock.
NOT yet verified: the RFID encode itself. It is wired and the printer accepts the
command, but the loaded thermal stock has no inlays so nothing has been written to
a chip. Needs a PET RFID label at the antenna. The SKU<->EPC byte layout also
remains unconfirmed against existing Chafon-written tags.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3056fd70fc
commit
b014205a5b
5
.gitignore
vendored
5
.gitignore
vendored
@ -8,5 +8,10 @@
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# macOS daemon virtualenv (recreate with mac/setup.sh)
|
||||
mac/venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# NOTE: the vendor DLLs (GTSPL_SDK*.dll, zlib.net.dll, UHFPrimeReader.dll, hidapi.dll)
|
||||
# ARE committed on purpose — they're needed to build/run on Windows.
|
||||
|
||||
@ -161,6 +161,21 @@ PriceGod buttons → `:7790` was not done yet (next step).
|
||||
|
||||
## 7. ⭐ For the Mac: getting the Urovo to program chips over there
|
||||
|
||||
> **UPDATE 2026-07-22 (later the same day) — SOLVED, and more cheaply than expected.**
|
||||
> The RFID-encode command did **not** need a USB sniff or the Java jar. `GTSPL_SDK.dll` is a
|
||||
> *managed .NET assembly*, so decompiling its IL reveals every RFID call is a one-line
|
||||
> `String.Concat` → ASCII → `WritePrinter`. `writeUHF("H",2,12,"E",epc)` emits exactly:
|
||||
>
|
||||
> ```
|
||||
> UHF WRITE H,2,12,E,"<24 hex chars>"\r\n
|
||||
> ```
|
||||
>
|
||||
> The whole vocabulary (`UHF READ/QUERY`, `UHF GEN2 *`, `SET RFID`, `&DEFAULT,i`,
|
||||
> `&CALIBRATE,A,R`) is in `mac/README.md`. **Option 2 below is therefore fully unblocked and
|
||||
> option 1 (the Java jar) is unnecessary.** A working macOS daemon now lives in `mac/` —
|
||||
> raw TSPL over libusb, same `:7790` contract, printing verified on the 24 mm stock.
|
||||
> Options 1–3 below are kept for historical context.
|
||||
|
||||
The **daemon.cs is Windows-bound** — `GTSPL_SDK.dll` is a Windows DLL; it won't run on macOS. But the
|
||||
**recipe in §2 is portable**. Options, best first:
|
||||
|
||||
@ -193,7 +208,12 @@ geometry, density, one-pass timing — is documented above and identical.
|
||||
- [ ] Wire PriceGod buttons → `:7790` + the Mode A/B toggle.
|
||||
- [ ] Match the label layout to the extension's `labels.js` design more faithfully (current TSPL layout is functional, not pixel-perfect).
|
||||
- [ ] (Optional) On Windows, decide Chafon = scanner-only vs. chase command mode.
|
||||
- [ ] (Mac) Try option 1 or 2 in §7 to program chips there.
|
||||
- [x] (Mac) ~~Try option 1 or 2 in §7 to program chips there.~~ Done — see §7 update and `mac/`.
|
||||
- [ ] (Mac) Prove the RFID **encode** end-to-end: it is wired and the printer accepts the
|
||||
command, but no chip has been written yet (the 24 mm thermal stock has no inlays).
|
||||
Put a PET RFID label at the antenna and `POST /print-encode`, then read back.
|
||||
- [ ] Confirm `SET RFID OFF` is genuinely honoured by this firmware rather than silently
|
||||
ignored — the absence of `VOID0` on the last test print is the only evidence so far.
|
||||
|
||||
See also the shop's memory notes: `urovo-d812r-ribbon-fix`, `urovo-d812r-rfid-label-gap-issue`,
|
||||
`urovo-d812r-pet-labels-not-thermal`, `urovo-windows-print-encode-daemon`, `chafon-h102-windows-hid-mode`.
|
||||
|
||||
94
mac/README.md
Normal file
94
mac/README.md
Normal file
@ -0,0 +1,94 @@
|
||||
# pricegod-urovo-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.
|
||||
|
||||
**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.
|
||||
It doesn't: the DLL is a managed .NET assembly, and its IL shows every RFID call is a
|
||||
one-line `String.Concat` → ASCII → `WritePrinter`. The wire format below was recovered
|
||||
from `ldstr` order + argument order and independently confirmed by a second decompile.
|
||||
|
||||
```
|
||||
UHF WRITE <fmt>,<start>,<len>,<bank>,"<data>" # the RFID encode
|
||||
UHF READ <fmt>,<start>,<len>,<bank>
|
||||
UHF QUERY <fmt>,<pcStatus>,<crcStatus>
|
||||
UHF GEN2 EPC|TID|USER|ACCESS|KILL <action>,"<pw>"
|
||||
SET RFID <tagType>,<rw_pos>,<void_printout>,<tryEncode>,<errHandle>,<speed>,<retry>
|
||||
&DEFAULT,i # rfidSetupDefault &CALIBRATE,A,R # RFIDAutoCalibration
|
||||
PRINT <set>, <copy> # note the space after the comma
|
||||
```
|
||||
|
||||
All plain ASCII, one command per line, CRLF-terminated. `writeUHF("H",2,12,"E",epc)`
|
||||
emits exactly `UHF WRITE H,2,12,E,"<24 hex chars>"`.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
./setup.sh # brew install libusb + venv with pyusb/pillow/segno
|
||||
```
|
||||
|
||||
Then double-click **`start-daemon.command`** (leave it open while you work).
|
||||
|
||||
## Files
|
||||
|
||||
| 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`. |
|
||||
| `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,...}` |
|
||||
| POST | `/write-tag` | alias of `/print-encode` | same |
|
||||
| POST | `/print-test` | same body — **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 |
|
||||
|
||||
Use `/preview` while tuning a layout — it costs no labels.
|
||||
|
||||
## Media profiles
|
||||
|
||||
Both shop stocks sit on a **24 mm web** and feed long-edge-first, so the landscape design
|
||||
is rotated 90° onto the media. Names match the extension's size dropdown.
|
||||
|
||||
| profile | label face | pitch | notes |
|
||||
|---|---|---|---|
|
||||
| `small` | 51 × 19 mm | 51 mm | older stock; the rest of the 24 mm web is blank filler, design is centred across it |
|
||||
| `large` | 55 × 24 mm | 55 mm | current stock (default) |
|
||||
| `xlarge` | 64 × 34 mm | 64 mm | untested |
|
||||
|
||||
Each inherits `margin_mm` (default 1.5), a safe-area inset held back from every edge —
|
||||
without it the design runs edge-to-edge and the least registration drift clips it.
|
||||
Override per request with `size`, or `w`/`h`/`gap` directly.
|
||||
|
||||
## Gotchas confirmed on macOS
|
||||
|
||||
1. **`0x08` "out of ribbon" is a latch.** `SET RIBBON OFF` alone will *not* clear it — it
|
||||
also needs a `FORMFEED` (costs one blank label). `recover()` / `POST /recover` does this.
|
||||
2. **The status byte lies for 1–3 s after a print** (transient `0x20`/`0x31`/`0x45`).
|
||||
Never judge a print by the immediate post-print byte; poll until it settles to `0x00`.
|
||||
3. **`VOID0` stamps on chipless stock.** If RFID encoding is armed, every label without a
|
||||
chip gets voided. `SET RFID OFF` stops it; `/print-test` is the safe route on plain
|
||||
thermal. Note `&DEFAULT,i` appears to *arm* RFID, so don't send it casually.
|
||||
4. **Bidirectional USB is required.** `UHF READ`/`QUERY` reply with raw bytes on the
|
||||
bulk-IN endpoint, so CUPS `lp -o raw` is not sufficient — hence libusb.
|
||||
5. **After changing stock**, calibrate on the printer itself: hold **FEED** while powering
|
||||
on and wait for **5 beeps**.
|
||||
|
||||
## SKU ↔ EPC scheme (⚠ still unverified)
|
||||
|
||||
96-bit EPC = **6 bytes** 14-digit timestamp SKU + **4 bytes** release_id + **2 bytes**
|
||||
`0xEC01` marker — ported byte-for-byte from `daemon.cs`. Confirm against an existing
|
||||
Chafon-written tag before trusting it; if it differs, change **only** `epc_encode` /
|
||||
`epc_decode` in `urovo.py`.
|
||||
252
mac/daemon.py
Normal file
252
mac/daemon.py
Normal file
@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
pricegod-urovo-daemon (macOS) -- localhost:7790 HTTP bridge to the Urovo D812R+.
|
||||
|
||||
The macOS counterpart to daemon.cs. Same port, same routes, same JSON shape, CORS *,
|
||||
so it is a drop-in for the PriceGod extension's existing daemon.js client. The Windows
|
||||
build needs GTSPL_SDK.dll; this one needs nothing but libusb, because the SDK turned out
|
||||
to be a thin string formatter over a raw byte pipe (see urovo.py for the recovered
|
||||
wire format).
|
||||
|
||||
Routes
|
||||
GET /status detect + status -> {ok,ready,status,statusText,printer}
|
||||
POST /print-encode print a label AND encode the chip in one pass
|
||||
POST /write-tag alias of /print-encode (the daemon.js name)
|
||||
POST /print-test print only, no RFID (safe on plain thermal stock)
|
||||
GET /read-tag read the tag at the antenna -> {ok,epc,sku,releaseId}
|
||||
GET /read-raw dump every memory bank
|
||||
GET /preview?... PNG of the rendered label -- costs no labels
|
||||
GET /profiles the media profiles this daemon knows
|
||||
POST /calibrate RFID auto-calibration
|
||||
POST /recover clear a latched fault
|
||||
|
||||
Run: ./venv/bin/python daemon.py (or ./start-daemon.command)
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
import label
|
||||
from urovo import Urovo, UrovoError, epc_encode, epc_decode, status_text
|
||||
|
||||
VERSION = 'pricegod-urovo-daemon-mac/1.0'
|
||||
DENSITY = 15 # max darkness -- anything lower barely marks
|
||||
LOCK = threading.Lock() # one USB handle; serialise all hardware access
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
def geometry(p, prof):
|
||||
"""Apply media geometry. gap>0 = die-cut via the optical gap sensor;
|
||||
gap=0 = continuous, for RFID rolls whose inlay blinds the gap sensor."""
|
||||
p.tear_on()
|
||||
p.gap(prof['gap_mm'], 0)
|
||||
p.size(prof['web_mm'], prof['pitch_mm'])
|
||||
p.density(DENSITY)
|
||||
p.direction(1)
|
||||
|
||||
|
||||
def do_print(body, encode):
|
||||
prof = label.profile(body.get('size'),
|
||||
web_mm=body.get('w'), pitch_mm=body.get('h'),
|
||||
gap_mm=body.get('gap'))
|
||||
sku = str(body.get('sku', '') or '').strip()
|
||||
release_id = str(body.get('releaseId', '') or '').strip()
|
||||
|
||||
epc = None
|
||||
if encode:
|
||||
try:
|
||||
epc = epc_encode(sku, release_id)
|
||||
except ValueError as e:
|
||||
return {'ok': False, 'error': str(e)}
|
||||
|
||||
with LOCK, Urovo() as p:
|
||||
# 1) direct thermal. Wait out transient busy states before feeding to clear,
|
||||
# so we don't waste a label on a momentary post-print code.
|
||||
p.ribbon_off()
|
||||
time.sleep(0.4)
|
||||
if p.wait_ready(3.0) != 0x00:
|
||||
st = p.recover()
|
||||
if st != 0x00:
|
||||
return {'ok': False, 'error': 'printer not ready',
|
||||
'status': st, 'statusText': status_text(st)}
|
||||
|
||||
# 2) geometry, then the artwork
|
||||
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
|
||||
if encode:
|
||||
p.write_uhf(epc)
|
||||
p.write(label.to_tspl(label.render(body, prof)))
|
||||
|
||||
# 4) re-assert ribbon-off immediately before firing
|
||||
p.ribbon_off()
|
||||
time.sleep(0.4)
|
||||
p.printlabel(1, 1)
|
||||
|
||||
# 5) the status byte returns garbage for 1-3 s during the RFID write + feed,
|
||||
# so poll until it settles rather than trusting the first read.
|
||||
st = p.wait_ready(12.0)
|
||||
after = p.read_uhf() if encode else None
|
||||
p.ribbon_off()
|
||||
|
||||
ok = st == 0x00
|
||||
return {
|
||||
'ok': ok, 'sku': sku, 'releaseId': release_id, 'epc': epc,
|
||||
'epcBefore': before, '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',
|
||||
'note': ('printer returned to Ready (no VOID) -- verify the chip with a scan'
|
||||
if ok else 'printer did NOT return to Ready -- check for VOID / jam / tag position'),
|
||||
}
|
||||
|
||||
|
||||
def do_status():
|
||||
with LOCK, Urovo() as p:
|
||||
st = p.status()
|
||||
return {'ok': True, 'ready': st == 0x00, 'status': st,
|
||||
'statusText': status_text(st), 'printer': p.model(),
|
||||
'service': VERSION}
|
||||
|
||||
|
||||
def do_read_tag():
|
||||
with LOCK, Urovo() as p:
|
||||
epc = p.read_uhf()
|
||||
out = {'ok': epc is not None, 'epc': epc}
|
||||
if epc:
|
||||
out.update(epc_decode(epc) or {})
|
||||
out['recognized'] = epc_decode(epc) is not None
|
||||
else:
|
||||
out['error'] = 'no tag at the antenna'
|
||||
return out
|
||||
|
||||
|
||||
def do_read_raw():
|
||||
with LOCK, Urovo() as p:
|
||||
banks = {'query': p.query_uhf()}
|
||||
for code, name in (('E', 'epc'), ('T', 'tid'), ('U', 'user')):
|
||||
banks[name] = p.read_uhf(bank=code, start=0, length=12)
|
||||
return {'ok': any(banks.values()), 'banks': banks}
|
||||
|
||||
|
||||
def do_calibrate():
|
||||
with LOCK, Urovo() as p:
|
||||
p.rfid_autocalibrate()
|
||||
st = p.wait_ready(20.0)
|
||||
return {'ok': st == 0x00, 'status': st, 'statusText': status_text(st)}
|
||||
|
||||
|
||||
def do_recover():
|
||||
with LOCK, Urovo() as p:
|
||||
st = p.recover()
|
||||
return {'ok': st == 0x00, 'status': st, 'statusText': status_text(st)}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = 'HTTP/1.1'
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
sys.stdout.write('%s %s\n' % (time.strftime('%H:%M:%S'), fmt % args))
|
||||
sys.stdout.flush()
|
||||
|
||||
def _cors(self):
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
||||
|
||||
def _json(self, obj, code=200):
|
||||
raw = json.dumps(obj, default=str).encode()
|
||||
self.send_response(code)
|
||||
self._cors()
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
def _png(self, img):
|
||||
buf = io.BytesIO()
|
||||
img.convert('L').save(buf, 'PNG')
|
||||
raw = buf.getvalue()
|
||||
self.send_response(200)
|
||||
self._cors()
|
||||
self.send_header('Content-Type', 'image/png')
|
||||
self.send_header('Content-Length', str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self._cors()
|
||||
self.send_header('Content-Length', '0')
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
u = urlparse(self.path)
|
||||
path = u.path.rstrip('/') or '/'
|
||||
q = {k: v[0] for k, v in parse_qs(u.query).items()}
|
||||
try:
|
||||
if path == '/preview':
|
||||
prof = label.profile(q.get('size'))
|
||||
return self._png(label.render_art(q or label.DEMO, prof))
|
||||
if path == '/profiles':
|
||||
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)
|
||||
if handler:
|
||||
return self._json(handler())
|
||||
if path == '/':
|
||||
return self._json({'ok': True, 'service': VERSION})
|
||||
self._json({'ok': False, 'error': 'unknown route ' + path}, 404)
|
||||
except UrovoError as e:
|
||||
self._json({'ok': False, 'error': str(e)}, 503)
|
||||
except Exception as e:
|
||||
self._json({'ok': False, 'error': '%s: %s' % (type(e).__name__, e)}, 500)
|
||||
|
||||
def do_POST(self):
|
||||
path = urlparse(self.path).path.rstrip('/') or '/'
|
||||
n = int(self.headers.get('Content-Length') or 0)
|
||||
raw = self.rfile.read(n).decode('utf-8', 'replace') if n else ''
|
||||
try:
|
||||
body = json.loads(raw) if raw.strip() else {}
|
||||
except ValueError:
|
||||
body = {}
|
||||
try:
|
||||
if path in ('/print-encode', '/write-tag'):
|
||||
return self._json(do_print(body, True))
|
||||
if path == '/print-test':
|
||||
return self._json(do_print(body, False))
|
||||
handler = {'/calibrate': do_calibrate, '/recover': do_recover}.get(path)
|
||||
if handler:
|
||||
return self._json(handler())
|
||||
self._json({'ok': False, 'error': 'unknown route ' + path}, 404)
|
||||
except UrovoError as e:
|
||||
self._json({'ok': False, 'error': str(e)}, 503)
|
||||
except Exception as e:
|
||||
self._json({'ok': False, 'error': '%s: %s' % (type(e).__name__, e)}, 500)
|
||||
|
||||
|
||||
def main():
|
||||
port = 7790
|
||||
if '--port' in sys.argv:
|
||||
port = int(sys.argv[sys.argv.index('--port') + 1])
|
||||
srv = ThreadingHTTPServer(('127.0.0.1', port), Handler)
|
||||
print('%s listening on http://localhost:%d/ (Ctrl+C to stop)' % (VERSION, port))
|
||||
print('routes: /status /print-encode /write-tag /print-test /read-tag /read-raw '
|
||||
'/preview /profiles /calibrate /recover')
|
||||
try:
|
||||
srv.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print('\nbye')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
216
mac/label.py
Normal file
216
mac/label.py
Normal file
@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Render a record label to a 1-bit bitmap for the Urovo D812R+.
|
||||
|
||||
WHY A BITMAP INSTEAD OF TSPL TEXT/QRCODE COMMANDS
|
||||
-------------------------------------------------
|
||||
The media runs 24 mm across the print head with the long edge along the feed, so a
|
||||
landscape label design has to be rotated 90 degrees onto it. The printer's built-in
|
||||
bitmap fonts are small, blocky, and their anchor under `TEXT ...,90,...` is awkward to
|
||||
lay out against. Rendering with PIL gives real fonts, exact rotation and pixel-level
|
||||
control -- which is also what it takes to match the extension's labels.js design.
|
||||
|
||||
Output goes out via TSPL `BITMAP x,y,<width_bytes>,<height_dots>,<mode>,<data>`.
|
||||
Bit convention: in TSPL BITMAP data a set bit is WHITE and a clear bit is BLACK, which
|
||||
is exactly what PIL mode-'1' `tobytes()` produces -- no inversion needed.
|
||||
|
||||
MEDIA PROFILES
|
||||
--------------
|
||||
Both of the shop's stocks sit on a 24 mm web and feed long-edge-first; they differ in
|
||||
the label pitch and how much of the web is actually printable:
|
||||
|
||||
small 51 x 19 mm design on a 24 mm web (older stock -- the label is 19 mm tall and
|
||||
the rest of the web is blank filler, so the design is centred across the web)
|
||||
large 55 x 24 mm design, full width of the web (current stock)
|
||||
|
||||
Names match the extension's size dropdown. Anything can be overridden per request.
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import segno
|
||||
|
||||
DPMM = 8 # 203 dpi ~= 8 dots/mm
|
||||
|
||||
|
||||
def mm(v):
|
||||
return int(round(v * DPMM))
|
||||
|
||||
|
||||
# web_mm : media width across the print head (what SIZE's first arg must be)
|
||||
# pitch_mm : label length along the feed (SIZE's second arg)
|
||||
# art_mm : (long, short) of the printable label face, centred on the web
|
||||
# margin_mm: safe-area inset held back from every edge of that face. Without it the
|
||||
# design runs edge-to-edge and the smallest registration drift clips it.
|
||||
PROFILES = {
|
||||
'small': dict(web_mm=24, pitch_mm=51, art_mm=(51, 19), gap_mm=2, margin_mm=1.5),
|
||||
'large': dict(web_mm=24, pitch_mm=55, art_mm=(55, 24), gap_mm=2, margin_mm=1.5),
|
||||
'xlarge': dict(web_mm=34, pitch_mm=64, art_mm=(64, 34), gap_mm=2, margin_mm=1.5),
|
||||
}
|
||||
DEFAULT_PROFILE = 'large'
|
||||
|
||||
|
||||
def profile(name=None, **over):
|
||||
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
|
||||
return p
|
||||
|
||||
|
||||
_FONTS = {
|
||||
'bold': ['/System/Library/Fonts/Supplemental/Arial Bold.ttf',
|
||||
'/System/Library/Fonts/Supplemental/Arial Narrow Bold.ttf',
|
||||
'/System/Library/Fonts/HelveticaNeue.ttc'],
|
||||
'regular': ['/System/Library/Fonts/Supplemental/Arial.ttf',
|
||||
'/System/Library/Fonts/Supplemental/Arial Narrow.ttf',
|
||||
'/System/Library/Fonts/HelveticaNeue.ttc'],
|
||||
}
|
||||
_cache = {}
|
||||
|
||||
|
||||
def font(kind, size):
|
||||
size = max(7, int(size))
|
||||
key = (kind, size)
|
||||
if key not in _cache:
|
||||
for path in _FONTS[kind]:
|
||||
try:
|
||||
_cache[key] = ImageFont.truetype(path, size)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
else:
|
||||
_cache[key] = ImageFont.load_default()
|
||||
return _cache[key]
|
||||
|
||||
|
||||
def _w(d, s, f):
|
||||
return d.textbbox((0, 0), s, font=f)[2]
|
||||
|
||||
|
||||
def fit(d, s, f, maxw):
|
||||
"""Trim with an ellipsis until it fits maxw dots."""
|
||||
s = (s or '').strip()
|
||||
if not s or _w(d, s, f) <= maxw:
|
||||
return s
|
||||
while s and _w(d, s + '…', f) > maxw:
|
||||
s = s[:-1]
|
||||
return (s + '…') if s else ''
|
||||
|
||||
|
||||
def _qr(data, maxpx):
|
||||
"""segno has no PIL export without a plugin, so build it from the module matrix."""
|
||||
rows = [list(r) for r in segno.make(data, error='m').matrix]
|
||||
n = len(rows)
|
||||
scale = max(1, maxpx // n)
|
||||
img = Image.new('1', (n * scale, n * scale), 1)
|
||||
px = img.load()
|
||||
for ry, row in enumerate(rows):
|
||||
for rx, on in enumerate(row):
|
||||
if on:
|
||||
for dy in range(scale):
|
||||
for dx in range(scale):
|
||||
px[rx * scale + dx, ry * scale + dy] = 0
|
||||
return img
|
||||
|
||||
|
||||
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)
|
||||
img = Image.new('1', (W, H), 1)
|
||||
d = ImageDraw.Draw(img)
|
||||
g = lambda k: str(fields.get(k, '') or '').strip()
|
||||
|
||||
pad = max(3, H // 24)
|
||||
# Type scale is a fraction of the design height so 19 mm and 24 mm both work.
|
||||
S = lambda frac: max(7, int(H * frac))
|
||||
|
||||
# ---- right column: price (top), condition, QR (bottom) ----
|
||||
price, cond, sku = g('price'), g('condition'), g('sku')
|
||||
f_price = font('bold', S(0.24))
|
||||
f_cond = font('regular', S(0.105))
|
||||
qr_img = _qr(sku, int(H * 0.55)) if sku else None
|
||||
|
||||
right = 0
|
||||
if price:
|
||||
right = max(right, _w(d, price, f_price))
|
||||
if cond:
|
||||
right = max(right, _w(d, cond, f_cond))
|
||||
if qr_img:
|
||||
right = max(right, qr_img.width)
|
||||
if right:
|
||||
right += pad * 2
|
||||
rx_end = W - pad
|
||||
y = pad
|
||||
if price:
|
||||
d.text((rx_end - _w(d, price, f_price), y), price, font=f_price, fill=0)
|
||||
y += S(0.24) + 2
|
||||
if cond:
|
||||
d.text((rx_end - _w(d, cond, f_cond), y), cond, font=f_cond, fill=0)
|
||||
if qr_img:
|
||||
img.paste(qr_img, (rx_end - qr_img.width, H - pad - qr_img.height))
|
||||
|
||||
# ---- left column, mirroring the extension's hierarchy ----
|
||||
lx, avail = pad, W - pad - right
|
||||
info = g('info')
|
||||
info_h = S(0.095) + 2 if info else 0
|
||||
y = pad
|
||||
for key, kind, frac in (('artist', 'regular', 0.155),
|
||||
('title', 'regular', 0.085),
|
||||
('genre', 'regular', 0.125),
|
||||
('style', 'bold', 0.155)):
|
||||
val = g(key)
|
||||
if not val:
|
||||
continue
|
||||
size = S(frac)
|
||||
if y + size > H - pad - info_h:
|
||||
break
|
||||
f = font(kind, size)
|
||||
d.text((lx, y), fit(d, val, f, avail), font=f, fill=0)
|
||||
y += size + 2
|
||||
if info:
|
||||
f = font('regular', S(0.095))
|
||||
d.text((lx, H - pad - S(0.095)), fit(d, info, f, avail), font=f, fill=0)
|
||||
return img
|
||||
|
||||
|
||||
def render(fields, prof=None):
|
||||
"""Full media-sized bitmap, design centred on the web and rotated for the head."""
|
||||
prof = prof or profile()
|
||||
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)))
|
||||
return canvas
|
||||
|
||||
|
||||
def to_tspl(img, x=0, y=0, mode=0):
|
||||
"""Build the TSPL BITMAP command (ASCII header + raw 1-bpp payload)."""
|
||||
if img.mode != '1':
|
||||
img = img.convert('1')
|
||||
if img.width % 8:
|
||||
pad = Image.new('1', (img.width + 8 - img.width % 8, img.height), 1)
|
||||
pad.paste(img, (0, 0))
|
||||
img = pad
|
||||
header = 'BITMAP %d,%d,%d,%d,%d,' % (x, y, img.width // 8, img.height, mode)
|
||||
return header.encode('ascii') + img.tobytes() + b'\r\n'
|
||||
|
||||
|
||||
DEMO = {'artist': 'Lushlife',
|
||||
'title': '& The Age Of Imagination Quartet – Order Of Operations Instrumentals EP',
|
||||
'genre': 'Hip Hop', 'style': 'Instrumental',
|
||||
'info': '2007 | Japan | Miclife Recordings MLR-2016',
|
||||
'price': '$25', 'condition': 'VG+ / VG+',
|
||||
'sku': '20260722153000', 'releaseId': '12345'}
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
for name in (sys.argv[1:] or ['small', 'large']):
|
||||
p = profile(name)
|
||||
art = render_art(DEMO, p)
|
||||
art.save('/tmp/label-%s.png' % name)
|
||||
print('%-7s art %dx%d dots (%.1fmm inset) web %dmm pitch %dmm -> /tmp/label-%s.png'
|
||||
% (name, art.width, art.height, p['margin_mm'], p['web_mm'], p['pitch_mm'], name))
|
||||
14
mac/setup.sh
Executable file
14
mac/setup.sh
Executable file
@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# One-time setup for the macOS daemon. Safe to re-run.
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
command -v brew >/dev/null || { echo "Homebrew required: https://brew.sh"; exit 1; }
|
||||
brew list libusb >/dev/null 2>&1 || brew install libusb
|
||||
|
||||
python3 -m venv venv
|
||||
./venv/bin/pip install --quiet --upgrade pip
|
||||
./venv/bin/pip install --quiet pyusb pillow segno
|
||||
|
||||
echo
|
||||
./venv/bin/python urovo.py && echo "OK -- run ./start-daemon.command"
|
||||
5
mac/start-daemon.command
Executable file
5
mac/start-daemon.command
Executable file
@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Double-click me. Leave the window open while you work.
|
||||
cd "$(dirname "$0")"
|
||||
[ -x venv/bin/python ] || ./setup.sh
|
||||
exec ./venv/bin/python daemon.py "$@"
|
||||
278
mac/urovo.py
Normal file
278
mac/urovo.py
Normal file
@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Urovo D812R+ (Gainscha GTSPL) driver for macOS -- raw TSPL over USB, no vendor SDK.
|
||||
|
||||
WHY THIS WORKS WITHOUT THE WINDOWS DLL
|
||||
--------------------------------------
|
||||
ASSESSMENT.md sec.7 assumed the RFID encode was locked inside GTSPL_SDK.dll and would need
|
||||
a USB sniff or the Java jar to recover. It doesn't. The DLL is a managed .NET assembly and
|
||||
decompiling its IL shows every RFID function is a one-line String.Concat -> ASCII ->
|
||||
WritePrinter. The exact wire format, recovered from ldstr order + argument order:
|
||||
|
||||
writeUHF(fmt, start, len, bank, data) -> 'UHF WRITE <fmt>,<start>,<len>,<bank>,"<data>"'
|
||||
readUHF(fmt, start, len, bank) -> 'UHF READ <fmt>,<start>,<len>,<bank>'
|
||||
query_UHF(fmt, pcStatus, crcStatus) -> 'UHF QUERY <fmt>,<pc>,<crc>'
|
||||
EPCPWD_Action(action, pw) -> 'UHF GEN2 EPC <action>,"<pw>"'
|
||||
Set_RFIDPorcedure(tagType, rw_pos, void_printout, tryEncode, errHandle, speed, retry)
|
||||
-> 'SET RFID <..7 fields..>' (positions in DOTS)
|
||||
rfidSetupDefault() -> '&DEFAULT,i'
|
||||
RFIDAutoCalibration() -> '&CALIBRATE,A,R'
|
||||
printlabel(set, copy) -> 'PRINT <set>, <copy>' (note space after comma)
|
||||
|
||||
Everything is plain ASCII, one command per line, CRLF-terminated. So the whole print+encode
|
||||
recipe is portable to any platform that can push bytes at the printer's bulk-OUT endpoint.
|
||||
|
||||
READ-BACK SEMANTICS (matters -- this is why CUPS 'lp -o raw' is not enough)
|
||||
UHF READ / UHF QUERY do NOT reply with text. The printer returns raw bytes on the bulk-IN
|
||||
endpoint and the SDK hex-formats them itself. A reply whose second byte is 0 is the SDK's
|
||||
"no tag" case (it substitutes 22 zeros). We need a real bidirectional pipe, hence libusb.
|
||||
|
||||
Requires: pyusb + libusb -> brew install libusb && pip install pyusb
|
||||
"""
|
||||
import ctypes.util
|
||||
import os
|
||||
import time
|
||||
|
||||
import usb.backend.libusb1
|
||||
import usb.core
|
||||
import usb.util
|
||||
|
||||
VID, PID = 0x0471, 0x8E92 # Urovo D812R+ ("PRINTER"/"PRINTER", printer class, proto 2)
|
||||
|
||||
# Homebrew installs libusb where the dynamic loader won't look by default.
|
||||
_LIBUSB_HINTS = [
|
||||
'/opt/homebrew/lib/libusb-1.0.dylib',
|
||||
'/usr/local/lib/libusb-1.0.dylib',
|
||||
]
|
||||
|
||||
|
||||
def _backend():
|
||||
for path in _LIBUSB_HINTS:
|
||||
if os.path.exists(path):
|
||||
b = usb.backend.libusb1.get_backend(find_library=lambda _p=path: _p)
|
||||
if b is not None:
|
||||
return b
|
||||
if ctypes.util.find_library('usb-1.0'):
|
||||
return usb.backend.libusb1.get_backend()
|
||||
raise RuntimeError('libusb not found -- run: brew install libusb')
|
||||
|
||||
|
||||
# Status byte -> meaning. 0x08 ("out of ribbon") is a LATCH: SET RIBBON OFF alone will not
|
||||
# clear it, it needs a FORMFEED as well. Verified on this printer from macOS.
|
||||
STATUS_TEXT = {
|
||||
0x00: 'Ready',
|
||||
0x01: 'Head opened',
|
||||
0x02: 'Paper jam',
|
||||
0x03: 'Paper jam and head opened',
|
||||
0x04: 'Out of paper',
|
||||
0x05: 'Out of paper and head opened',
|
||||
0x08: 'Out of ribbon (thermal-transfer mode latched -- needs SET RIBBON OFF + FORMFEED)',
|
||||
0x09: 'Out of ribbon and head opened',
|
||||
0x10: 'Pause',
|
||||
0x20: 'Printing',
|
||||
0x80: 'Other error',
|
||||
}
|
||||
|
||||
|
||||
def status_text(code):
|
||||
if code is None:
|
||||
return 'no response'
|
||||
return STATUS_TEXT.get(code, 'busy/unknown (0x%02X)' % code)
|
||||
|
||||
|
||||
class UrovoError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class Urovo(object):
|
||||
def __init__(self):
|
||||
self.dev = usb.core.find(idVendor=VID, idProduct=PID, backend=_backend())
|
||||
if self.dev is None:
|
||||
raise UrovoError('Urovo D812R+ (%04x:%04x) not found on USB' % (VID, PID))
|
||||
try:
|
||||
self.dev.set_configuration()
|
||||
except usb.core.USBError:
|
||||
pass # already configured is fine
|
||||
intf = next(i for i in self.dev.get_active_configuration()
|
||||
if i.bInterfaceClass == 7) # USB printer class
|
||||
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()
|
||||
|
||||
# ---- raw pipe --------------------------------------------------------
|
||||
def write(self, data):
|
||||
if isinstance(data, str):
|
||||
data = data.encode('latin-1', 'replace')
|
||||
self.out.write(data, timeout=5000)
|
||||
|
||||
def read(self, wait=1.0, size=4096):
|
||||
if self.inp is None:
|
||||
return b''
|
||||
out, end = b'', time.time() + wait
|
||||
while time.time() < end:
|
||||
try:
|
||||
chunk = self.inp.read(size, timeout=300)
|
||||
except usb.core.USBTimeoutError:
|
||||
continue
|
||||
except usb.core.USBError:
|
||||
break
|
||||
if chunk:
|
||||
out += bytes(chunk)
|
||||
end = time.time() + 0.3
|
||||
return out
|
||||
|
||||
def flush_in(self):
|
||||
self.read(0.25)
|
||||
|
||||
def cmd(self, line, wait=0.0):
|
||||
"""Send one TSPL line (CRLF appended); optionally collect a reply."""
|
||||
self.write(line + '\r\n')
|
||||
return self.read(wait) if wait else b''
|
||||
|
||||
# ---- status ----------------------------------------------------------
|
||||
def status(self, wait=0.8):
|
||||
"""Real-time status: <ESC>!? -> one byte. 0x00 = Ready."""
|
||||
self.flush_in()
|
||||
self.write(b'\x1b!?')
|
||||
r = self.read(wait)
|
||||
return r[-1] if r else None
|
||||
|
||||
def model(self):
|
||||
self.flush_in()
|
||||
return self.cmd('~!T', 1.5).decode('latin-1', 'replace').strip()
|
||||
|
||||
def wait_ready(self, timeout=10.0):
|
||||
"""Poll until Ready. The status byte is unreliable for 1-3s after a print
|
||||
(returns transient garbage like 0x20/0x31/0x45), so never judge a print by
|
||||
the immediate post-print byte -- wait for it to settle to 0x00."""
|
||||
end, last = time.time() + timeout, None
|
||||
while time.time() < end:
|
||||
last = self.status(0.5)
|
||||
if last == 0x00:
|
||||
return 0x00
|
||||
time.sleep(0.25)
|
||||
return last
|
||||
|
||||
def recover(self):
|
||||
"""Clear the latched 'out of ribbon' fault. SET RIBBON OFF on its own does not
|
||||
do it -- it takes a FORMFEED too (costs one blank label)."""
|
||||
self.cmd('SET RIBBON OFF')
|
||||
time.sleep(0.4)
|
||||
if self.wait_ready(3.0) == 0x00:
|
||||
return 0x00
|
||||
self.cmd('FORMFEED')
|
||||
time.sleep(1.5)
|
||||
self.cmd('SET RIBBON OFF')
|
||||
time.sleep(0.4)
|
||||
return self.wait_ready(5.0)
|
||||
|
||||
# ---- geometry / drawing (exact GTSPL wire format) --------------------
|
||||
def cls(self): self.cmd('CLS')
|
||||
def formfeed(self): self.cmd('FORMFEED')
|
||||
def ribbon_off(self): self.cmd('SET RIBBON OFF')
|
||||
def tear_on(self): self.cmd('SET TEAR ON')
|
||||
def size(self, w, h): self.cmd('SIZE %s mm,%s mm' % (w, h))
|
||||
def gap(self, g, off=0): self.cmd('GAP %s mm,%s mm' % (g, off))
|
||||
def density(self, d): self.cmd('DENSITY %s' % d)
|
||||
def direction(self, d): self.cmd('DIRECTION %s' % d)
|
||||
def speed(self, s): self.cmd('SPEED %s' % s)
|
||||
|
||||
def text(self, x, y, font, rot, xmul, ymul, s):
|
||||
self.cmd('TEXT %s,%s,"%s",%s,%s,%s,"%s"' % (x, y, font, rot, xmul, ymul, _esc(s)))
|
||||
|
||||
def qrcode(self, x, y, ecc, cell, mode, rot, s):
|
||||
self.cmd('QRCODE %s,%s,%s,%s,%s,%s,"%s"' % (x, y, ecc, cell, mode, rot, _esc(s)))
|
||||
|
||||
def bar(self, x, y, w, h):
|
||||
self.cmd('BAR %s,%s,%s,%s' % (x, y, w, h))
|
||||
|
||||
def printlabel(self, sets=1, copies=1):
|
||||
self.cmd('PRINT %s, %s' % (sets, copies))
|
||||
|
||||
# ---- RFID ------------------------------------------------------------
|
||||
def rfid_setup_default(self): self.cmd('&DEFAULT,i')
|
||||
def rfid_autocalibrate(self): self.cmd('&CALIBRATE,A,R')
|
||||
|
||||
def set_rfid(self, tag_type=0, rw_position=0, void_printout=0,
|
||||
try_encode=3, error_handle=0, speed=1, retry=3):
|
||||
"""SET RFID -- positions are in DOTS (203dpi = 8 dots/mm), never mm."""
|
||||
self.cmd('SET RFID %s,%s,%s,%s,%s,%s,%s' % (
|
||||
tag_type, rw_position, void_printout, try_encode, error_handle, speed, retry))
|
||||
|
||||
def write_uhf(self, hexdata, fmt='H', start=2, length=12, bank='E'):
|
||||
"""Stage an RFID encode. Fires together with the next PRINT, in one pass."""
|
||||
self.cmd('UHF WRITE %s,%s,%s,%s,"%s"' % (fmt, start, length, bank, hexdata))
|
||||
|
||||
def read_uhf(self, fmt='H', start=2, length=12, bank='E', wait=2.5):
|
||||
self.flush_in()
|
||||
return _tag_hex(self.cmd('UHF READ %s,%s,%s,%s' % (fmt, start, length, bank), wait))
|
||||
|
||||
def query_uhf(self, fmt='H', pc=0, crc=0, wait=2.5):
|
||||
self.flush_in()
|
||||
return _tag_hex(self.cmd('UHF QUERY %s,%s,%s' % (fmt, pc, crc), wait))
|
||||
|
||||
|
||||
def _esc(s):
|
||||
"""TSPL quotes strings with " and escapes with \\."""
|
||||
return str(s).replace('\\', '\\\\').replace('"', '\\"')
|
||||
|
||||
|
||||
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)."""
|
||||
if not raw or not any(raw):
|
||||
return None
|
||||
return ''.join('%02X' % b for b in raw)
|
||||
|
||||
|
||||
# ---- SKU <-> EPC (v1 scheme, ported byte-for-byte from daemon.cs Epc) -----
|
||||
# 96-bit EPC = 6 bytes of the 14-digit timestamp SKU + 4 bytes release_id + 0xEC01 marker.
|
||||
# UNVERIFIED against whatever the Chafon-written tags use. If it must change, change ONLY
|
||||
# these two functions -- nothing else depends on the byte layout.
|
||||
EPC_MARKER = b'\xec\x01'
|
||||
|
||||
|
||||
def epc_encode(sku14, release_id):
|
||||
sku14 = (sku14 or '').strip()
|
||||
if len(sku14) != 14 or not sku14.isdigit():
|
||||
raise ValueError('sku must be 14 digits')
|
||||
rel = int(str(release_id or '0').strip() or '0')
|
||||
if not 0 <= rel <= 0xFFFFFFFF:
|
||||
raise ValueError('releaseId must fit in uint32')
|
||||
b = int(sku14).to_bytes(6, 'big') + rel.to_bytes(4, 'big') + EPC_MARKER
|
||||
return b.hex().upper()
|
||||
|
||||
|
||||
def epc_decode(hexstr):
|
||||
hexstr = (hexstr or '').strip()
|
||||
if len(hexstr) != 24:
|
||||
return None
|
||||
try:
|
||||
b = bytes.fromhex(hexstr)
|
||||
except ValueError:
|
||||
return None
|
||||
if b[10:12] != EPC_MARKER:
|
||||
return None # not our scheme
|
||||
return {'sku': str(int.from_bytes(b[:6], 'big')).zfill(14),
|
||||
'releaseId': str(int.from_bytes(b[6:10], 'big'))}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
with Urovo() as p:
|
||||
print('model :', p.model())
|
||||
st = p.status()
|
||||
print('status : 0x%02X %s' % (st, status_text(st)) if st is not None else 'status : none')
|
||||
print('tag :', p.read_uhf() or '(no tag at antenna)')
|
||||
Loading…
Reference in New Issue
Block a user