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:
parent
d254d16278
commit
6d9a49ea42
@ -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`.
|
||||
|
||||
@ -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
161
mac/chafon.py
Normal 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)
|
||||
@ -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"
|
||||
|
||||
43
mac/urovo.py
43
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__':
|
||||
|
||||
Loading…
Reference in New Issue
Block a user