yourovo/mac/chafon.py
type-two 6d9a49ea42 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>
2026-07-22 16:45:07 +10:00

162 lines
5.5 KiB
Python

#!/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)