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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-22 16:45:07 +10:00
parent d254d16278
commit 6d9a49ea42
5 changed files with 236 additions and 23 deletions

View File

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

View File

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

161
mac/chafon.py Normal file
View File

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

View File

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

View File

@ -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__':