From 6d9a49ea425d22c9822f0d252559faf9c33a654d Mon Sep 17 00:00:00 2001 From: type-two Date: Wed, 22 Jul 2026 16:45:07 +1000 Subject: [PATCH] Fix the EPC scheme; verify encode independently with the Chafon The SKU<->EPC layout inherited from daemon.cs was WRONG. daemon.cs packs 6 bytes of SKU + 4 bytes of release_id + an 0xEC01 marker, and flagged itself as unverified. The shop's actual scheme -- in pliceclogs/rfid-daemon/index.js (buildEpcPayload/parseEpcData), which is what the extension and every existing tag use -- is a single 96-bit big-endian integer: value = sku(14 digits) * 10^9 + releaseId Checked against that file's own worked example: 20260321001001 + 35332 -> 20260321001001000035332. epc_decode also reproduces its guard, returning None when value/10^9 exceeds 14 digits, so a factory tag (E28069...) can never be reported to the operator as a real SKU. This matters: parseEpcData rejects the EC01 layout outright, so every tag the Windows daemon has written is invisible to the extension. ASSESSMENT.md now carries that as a red open item against daemon.cs. Verified end-to-end with an INDEPENDENT reader. ASSESSMENT.md sec.4 parked the Chafon as HID-keyboard-only, but that is Windows-specific -- on macOS it exposes a CH340 bridge and its documented serial protocol works. New chafon.py (read-only: inventory + read EPC, CRC-16/0x8408 framing) scans the field and sees: 0000044A5600D4CDA1FAB932 -> sku=20260722180000 rel=424242 (Urovo-written) E28069950000600625F600D3 -> factory/blank (x12, rest of roll) 126D5125FFA000067932EC01 -> factory/blank (earlier EC01 tag, now rejected) That last line is the bug demonstrated: a tag written earlier today under the old scheme is unrecognised, exactly as the extension would have treated it. Co-Authored-By: Claude Opus 4.8 --- ASSESSMENT.md | 13 +++- mac/README.md | 40 +++++++++++-- mac/chafon.py | 161 ++++++++++++++++++++++++++++++++++++++++++++++++++ mac/setup.sh | 2 +- mac/urovo.py | 43 +++++++++----- 5 files changed, 236 insertions(+), 23 deletions(-) create mode 100644 mac/chafon.py diff --git a/ASSESSMENT.md b/ASSESSMENT.md index 79cf846..c7efa4a 100644 --- a/ASSESSMENT.md +++ b/ASSESSMENT.md @@ -209,11 +209,18 @@ geometry, density, one-pass timing — is documented above and identical. - [ ] 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. - [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. +- [x] (Mac) ~~Prove the RFID **encode** end-to-end.~~ Done — chip written by the Urovo and + read back by the **Chafon** (independent reader): `0000044A5600D4CDA1FAB932` → + `sku=20260722180000, releaseId=424242`. +- [ ] 🔴 **Fix the `Epc` class in `daemon.cs` — it is WRONG.** The shop's real scheme (used by + `pliceclogs/rfid-daemon` and now by `mac/urovo.py`) is a single 96-bit big-endian + integer `sku * 10^9 + releaseId`, **not** 6B sku + 4B releaseId + `0xEC01`. The Node + daemon's `parseEpcData` rejects the `EC01` layout outright, so **every tag the Windows + daemon has written is invisible to the extension** and needs rewriting. - [ ] 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. +- [ ] Resolve the `:7790` collision between `mac/daemon.py` and the Node `rfid-daemon` + (both bind it; the Node one owns the Chafon write path, scale and inventory routes). 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`. diff --git a/mac/README.md b/mac/README.md index 1a29941..8e1af5e 100644 --- a/mac/README.md +++ b/mac/README.md @@ -139,9 +139,39 @@ Override per request with `size`, or `w`/`h`/`gap` directly. 5. **After changing stock**, calibrate on the printer itself: hold **FEED** while powering on and wait for **5 beeps**. -## SKU ↔ EPC scheme (⚠ still unverified) +## SKU ↔ EPC scheme (✅ verified — and `daemon.cs` has it wrong) -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`. +The shop's real scheme, matching `pliceclogs/rfid-daemon/index.js` +(`buildEpcPayload` / `parseEpcData`), is a **single 96-bit big-endian integer**: + +``` +value = sku(14 digits) * 10^9 + releaseId +``` + +In decimal that reads as `[digits 1-14 = SKU][digits 15-23 = releaseId]`, so any scanner +showing hex can be converted to decimal and split at digit 14. Verified against that +file's own worked example: `20260321001001` + `35332` → `20260321001001000035332`. + +> ⚠️ **`daemon.cs` (Windows) uses a different, incompatible layout** — 6 bytes SKU + +> 4 bytes release_id + an `0xEC01` marker. It was never checked against a real tag. +> `parseEpcData` rejects it outright, so **any tag the Windows daemon has written is +> invisible to the extension**. Its `Epc` class needs updating to the scheme above. + +`epc_decode` returns `None` for foreign/factory EPCs (e.g. `E28069…`), which is what +stops a stray tag on the shelf being reported as a real product SKU. + +## Verifying a tag independently + +`chafon.py` is a read-only client for the Chafon gun over its CH340 serial bridge — an +*independent* check that a chip the Urovo wrote holds what we think it does: + +```bash +./venv/bin/python chafon.py # inventory the field +``` + +Confirmed working: after programming, the Chafon reads back +`0000044A5600D4CDA1FAB932` → `sku=20260722180000, releaseId=424242`, alongside the +untouched factory tags on the rest of the roll. + +⚠️ Only one process can hold the serial port, so don't run this while the Node +rfid-daemon is up. diff --git a/mac/chafon.py b/mac/chafon.py new file mode 100644 index 0000000..df0fc9f --- /dev/null +++ b/mac/chafon.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +""" +Chafon H-102 UHF reader client (macOS) -- read-only verification path. + +ASSESSMENT.md sec.4 found the gun was HID-keyboard-only on Windows and parked it. On +macOS it enumerates a CH340 serial bridge at /dev/cu.usbserial-*, so the documented +serial protocol works fine and it can be used as an INDEPENDENT check that a chip the +Urovo programmed really holds what we think it does. + +Protocol (Chafon H100/H102/H103/H104 manual, Appendix B), matching the existing Node +rfid-daemon byte-for-byte: + + frame CF [ADDR] [CMD_H] [CMD_L] [LEN] [DATA...] [CRC_H] [CRC_L] + response CF [ADDR] [CMD_H] [CMD_L] [LEN] [STATUS] [DATA...] [CRC_H] [CRC_L] + LEN counts STATUS + DATA. CRC covers CF through the last data byte. + crc16 poly 0x8408, init 0xFFFF, LSB-first (CRC-16/IBM) + 115200 8N1 + +Deliberately read-only: inventory and read. Writing is left to the Urovo (and to the +Node daemon, which already owns the write path). +""" +import glob +import time + +import serial + +BAUD = 115200 +ADDR = 0x00 +CMD_INVENTORY = (0x00, 0x01) +CMD_STOP = (0x00, 0x02) +CMD_READ = (0x00, 0x03) +MEM_EPC = 0x01 +DEFAULT_ACCESS_PWD = b'\x00\x00\x00\x00' + +STATUS = {0x00: 'Tag found', 0x01: 'Parameter error', 0x12: 'Inventory complete', + 0x13: 'No tags found', 0x14: 'Tag timeout'} + + +class ChafonError(RuntimeError): + pass + + +def crc16(buf): + crc = 0xFFFF + for byte in buf: + crc ^= byte + for _ in range(8): + crc = (crc >> 1) ^ 0x8408 if crc & 1 else crc >> 1 + return crc + + +def frame(cmd, data=b''): + body = bytes([0xCF, ADDR, cmd[0], cmd[1], len(data)]) + data + c = crc16(body) + return body + bytes([(c >> 8) & 0xFF, c & 0xFF]) + + +def find_port(): + ports = sorted(glob.glob('/dev/cu.usbserial*')) + if not ports: + raise ChafonError('no CH340 serial port found (/dev/cu.usbserial*) -- ' + 'is the Chafon plugged in and awake?') + return ports[0] + + +class Chafon(object): + def __init__(self, port=None, timeout=0.3): + self.port = port or find_port() + self.ser = serial.Serial(self.port, BAUD, timeout=timeout) + + def close(self): + try: + self.ser.close() + except Exception: + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + self.close() + + def command(self, cmd, data=b'', timeout=6.0): + """Send a frame and return the first CRC-valid response matching cmd. + + Frames with a bad CRC are resynced past rather than trusted -- a corrupt LEN + or a 0xCF inside a payload can otherwise fabricate a false frame boundary and + swallow the real response queued behind it. + """ + self.ser.reset_input_buffer() + self.ser.write(frame(cmd, data)) + self.ser.flush() + buf, end = bytearray(), time.time() + timeout + while time.time() < end: + chunk = self.ser.read(256) + if chunk: + buf += chunk + while len(buf) >= 7: + start = buf.find(0xCF) + if start < 0: + buf.clear() + break + if start: + del buf[:start] + if len(buf) < 7: + break + total = 5 + buf[4] + 2 + if len(buf) < total: + break + f = bytes(buf[:total]) + if ((f[-2] << 8) | f[-1]) != crc16(f[:-2]): + del buf[0] + continue + del buf[:total] + if (f[2], f[3]) != cmd: + continue # streaming frame for another command + return {'status': f[5], 'data': f[6:-2]} + raise ChafonError('no response from reader within %.1fs -- pull the trigger to ' + 'wake it, or it may be in HID mode' % timeout) + + def stop(self): + try: + self.command(CMD_STOP, timeout=1.0) + except ChafonError: + pass + + def inventory(self, seconds=1): + """Scan the field. Returns {status, statusText, epc, rssi, antenna}.""" + r = self.command(CMD_INVENTORY, bytes([0x00, 0, 0, 0, seconds & 0xFF]), timeout=6.0) + self.stop() + d = r['data'] + found = r['status'] == 0x00 + # inventory payload: RSSI(1) ANT(1) PC(2) EPC_LEN(1) EPC(12) + epc = d[5:17] if found and len(d) >= 17 else None + return {'status': r['status'], + 'statusText': STATUS.get(r['status'], '0x%02X' % r['status']), + 'found': found, + 'epc': epc.hex().upper() if epc else None, + 'rssi': (d[0] - 256 if d[0] >= 128 else d[0]) if found and len(d) >= 2 else None, + 'antenna': d[1] if found and len(d) >= 2 else None, + 'rawHex': d.hex().upper()} + + def read_epc(self, words=6): + """Read the EPC bank directly (6 words = 12 bytes).""" + data = b'\x00' + DEFAULT_ACCESS_PWD + bytes([MEM_EPC, 0x00, 0x02, words]) + r = self.command(CMD_READ, data, timeout=12.0) + self.stop() + if r['status'] != 0x00: + return {'ok': False, 'status': r['status'], + 'statusText': STATUS.get(r['status'], '0x%02X' % r['status'])} + d = r['data'] + return {'ok': True, 'epc': d[-words * 2:].hex().upper(), + 'rssi': d[0] - 256 if d[0] >= 128 else d[0], + 'antenna': d[1], 'rawHex': d.hex().upper()} + + +if __name__ == '__main__': + with Chafon() as c: + print('port :', c.port) + inv = c.inventory() + print('inventory:', inv) diff --git a/mac/setup.sh b/mac/setup.sh index f8bc575..364b30b 100755 --- a/mac/setup.sh +++ b/mac/setup.sh @@ -8,7 +8,7 @@ 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 +./venv/bin/pip install --quiet pyusb pillow segno pyserial echo ./venv/bin/python urovo.py && echo "OK -- run ./start-daemon.command" diff --git a/mac/urovo.py b/mac/urovo.py index b17abdf..0263559 100644 --- a/mac/urovo.py +++ b/mac/urovo.py @@ -250,11 +250,22 @@ def _tag_hex(raw): return s.upper() -# ---- 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' +# ---- SKU <-> EPC --------------------------------------------------------- +# THE SHOP'S REAL SCHEME, matching the Chafon rfid-daemon (pliceclogs/rfid-daemon +# index.js buildEpcPayload/parseEpcData). The 96-bit EPC is ONE big-endian integer: +# +# value = sku(14 digits) * 10^9 + releaseId +# +# In decimal that reads as [digits 1-14 = SKU][digits 15-23 = releaseId], so any +# scanner that shows the EPC as hex can be converted to decimal and split at digit 14. +# +# NOTE: daemon.cs used a DIFFERENT layout (6 bytes SKU + 4 bytes releaseId + 0xEC01 +# marker). That layout was never verified against a real tag and is NOT what the shop +# uses -- parseEpcData rejects it outright, so tags written that way are invisible to +# the extension. This module follows the Chafon scheme; the Windows daemon is the one +# that needs changing, not this. +RELID_FACTOR = 10 ** 9 # 9 digits: enough for any Discogs release id +_MAX_SKU = 10 ** 14 def epc_encode(sku14, release_id): @@ -262,24 +273,28 @@ def epc_encode(sku14, release_id): 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() + if not 0 <= rel < RELID_FACTOR: + raise ValueError('releaseId must be under 9 digits') + return (int(sku14) * RELID_FACTOR + rel).to_bytes(12, 'big').hex().upper() def epc_decode(hexstr): + """Returns None for anything that is not one of ours -- a malformed read, or a + foreign/factory EPC such as E28069... whose value/10^9 exceeds 14 digits. That + guard is what stops a stray tag being reported to the operator as a real SKU.""" hexstr = (hexstr or '').strip() if len(hexstr) != 24: return None try: - b = bytes.fromhex(hexstr) + value = int.from_bytes(bytes.fromhex(hexstr), 'big') 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'))} + sku_num = value // RELID_FACTOR + if sku_num >= _MAX_SKU: + return None # not a PriceGod SKU + rel = value % RELID_FACTOR + return {'sku': str(sku_num).zfill(14), + 'releaseId': str(rel) if rel else None} if __name__ == '__main__':