feat(lf): FDX-B (ISO 11784/85) biphase demod + CRC-validated framing
- dsp.biphase_raw_decode: faithful port of lfdemod.c BiphaseRawDecode (pairwise differential-Manchester decode with phase-fault offset nudge + error markers). - protocols.decode_fdxb: ASK + biphase, find the 00000000001 preamble, de-interleave the 8-bit data groups (every 9th bit a control 1), reassemble the 10-bit country + 38-bit national code. Accepted only when the CRC-16 over the eight data bytes matches the stored one, so it can't false-positive. Both biphase polarities tried. - protocols.crc16 / crc16_fdxb: parametric bitwise CRC-16 (Rocksoft model) matching the firmware crc16_fast; FDX-B is poly 0x1021, init 0, refin=false, refout=true. Added to the identify decoder chain (FDX-B is common in implants). Tests: FDX-B round-trip (country/national/animal), CRC gate rejects noise/EM4100, and a CRC known-answer test pinning the impl to CRC-16/XMODEM (0x31C3) and KERMIT (0x2189). Full suite green. Indala (PSK) + AWID queued. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ _SAMPLE_COUNT = 15000
|
||||
_DECODERS = (
|
||||
protocols.decode_em4100,
|
||||
protocols.decode_hid,
|
||||
protocols.decode_fdxb,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -111,16 +111,44 @@ def manchester_decode(halfbits: list[int], phase: int = 0, invert: bool = False)
|
||||
return bits
|
||||
|
||||
|
||||
def biphase_decode(halfbits: list[int], phase: int = 0, invert: bool = False):
|
||||
"""Decode a half-bit stream as biphase (differential Manchester / FDX-B style): a data
|
||||
``0`` has a mid-bit transition (half-bits differ), a data ``1`` does not (half-bits equal).
|
||||
``phase`` aligns the pairing; ``invert`` swaps the 0/1 convention."""
|
||||
bits: list[int] = []
|
||||
for i in range(phase, len(halfbits) - 1, 2):
|
||||
differ = halfbits[i] != halfbits[i + 1]
|
||||
bit = 0 if differ else 1
|
||||
bits.append(bit ^ 1 if invert else bit)
|
||||
return bits
|
||||
def biphase_raw_decode(bits: list[int], offset: int = 0, invert: int = 0):
|
||||
"""Faithful port of ``lfdemod.c`` ``BiphaseRawDecode`` — decode biphase / differential
|
||||
Manchester (FDX-B, Indala). Consumes the half-bit stream in pairs: ``01``/``10`` -> ``1``,
|
||||
``00``/``11`` -> ``0`` (xor ``invert``); a missing bit-boundary transition marks a phase
|
||||
error (emitted as ``7``). Auto-nudges the pairing offset by one when the first 48 samples
|
||||
show the other phase is clean. Returns ``(databits, err_count)``."""
|
||||
n = len(bits)
|
||||
if n < 51:
|
||||
return [], -1
|
||||
if offset < 0:
|
||||
offset = 0
|
||||
|
||||
# phase-fault check: if pairing at offset is faulty but offset+1 is clean, shift by one
|
||||
offset_a = offset_b = True
|
||||
i = offset
|
||||
while i < offset + 48 and i + 3 < n:
|
||||
if bits[i + 1] == bits[i + 2]:
|
||||
offset_a = False
|
||||
if bits[i + 2] == bits[i + 3]:
|
||||
offset_b = False
|
||||
i += 2
|
||||
if not offset_a and offset_b:
|
||||
offset += 1
|
||||
|
||||
out: list[int] = []
|
||||
err = 0
|
||||
i = offset
|
||||
while i < n - 1:
|
||||
if i + 2 < n and bits[i + 1] == bits[i + 2]:
|
||||
out.append(7) # missing boundary transition -> phase error
|
||||
err += 1
|
||||
a, b = bits[i], bits[i + 1]
|
||||
if a != b:
|
||||
out.append(1 ^ invert)
|
||||
else:
|
||||
out.append(invert)
|
||||
i += 2
|
||||
return out, err
|
||||
|
||||
|
||||
def bits_to_int(bits: list[int]) -> int:
|
||||
|
||||
@@ -103,6 +103,97 @@ def decode_hid(samples) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def _reflect(v: int, width: int) -> int:
|
||||
r = 0
|
||||
for i in range(width):
|
||||
if v & (1 << i):
|
||||
r |= 1 << (width - 1 - i)
|
||||
return r
|
||||
|
||||
|
||||
def crc16(data: bytes, init: int = 0, poly: int = 0x1021, refin: bool = False,
|
||||
refout: bool = False) -> int:
|
||||
"""Parametric bitwise CRC-16 (Rocksoft model), matching the firmware ``crc16_fast``."""
|
||||
crc = init
|
||||
for byte in data:
|
||||
b = _reflect(byte, 8) if refin else byte
|
||||
crc ^= b << 8
|
||||
for _ in range(8):
|
||||
crc = ((crc << 1) ^ poly) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
|
||||
return _reflect(crc, 16) if refout else crc
|
||||
|
||||
|
||||
def crc16_fdxb(data: bytes) -> int:
|
||||
"""FDX-B (ISO 11784/85) CRC: poly 0x1021, init 0, refin=false, refout=true."""
|
||||
return crc16(data, 0x0000, 0x1021, refin=False, refout=True)
|
||||
|
||||
|
||||
_FDXB_PREAMBLE = [0] * 10 + [1] # ten 0x00 then 0x01
|
||||
|
||||
|
||||
def decode_fdxb(samples) -> dict | None:
|
||||
"""Demodulate an FDX-B (ISO 11784/85) envelope — ASK + biphase, 128-bit frame.
|
||||
|
||||
After biphase decode we find the ``00000000001`` preamble, de-interleave the 8-bit data
|
||||
groups (every 9th bit is a control 1), and reassemble the 10-bit country + 38-bit national
|
||||
code. Accepted only if the CRC-16 over the eight data bytes matches the stored one — a strong
|
||||
check, so FDX-B never false-positives. Tries both biphase polarities."""
|
||||
if dsp.is_flat(samples):
|
||||
return None
|
||||
binary = dsp.binarize(samples)
|
||||
half = dsp.detect_clock(binary)
|
||||
if not half:
|
||||
return None
|
||||
raw = dsp.resample(binary, half) # half-bit-resolution ASK bits
|
||||
for invert in (0, 1):
|
||||
bits, _err = dsp.biphase_raw_decode(raw, 0, invert)
|
||||
got = _fdxb_frame(bits)
|
||||
if got is not None:
|
||||
return got
|
||||
return None
|
||||
|
||||
|
||||
def _fdxb_frame(bits) -> dict | None:
|
||||
n = len(bits)
|
||||
for start in range(max(0, n - 128)):
|
||||
if bits[start:start + 11] != _FDXB_PREAMBLE:
|
||||
continue
|
||||
frame = bits[start:start + 128]
|
||||
if len(frame) < 100 or 7 in frame[:100]: # need the data+CRC region clean
|
||||
continue
|
||||
got = _fdxb_parse(frame)
|
||||
if got is not None:
|
||||
return got
|
||||
return None
|
||||
|
||||
|
||||
def _group(frame, i: int) -> int | None:
|
||||
"""The i-th 8-bit data group (LSB-first), skipping the control bit before each group."""
|
||||
seg = frame[11 + i * 9: 11 + i * 9 + 8]
|
||||
if len(seg) < 8 or 7 in seg:
|
||||
return None
|
||||
return sum(bit << k for k, bit in enumerate(seg))
|
||||
|
||||
|
||||
def _fdxb_parse(frame) -> dict | None:
|
||||
groups = [_group(frame, i) for i in range(10)] # 0-7 data, 8-9 CRC
|
||||
if any(g is None for g in groups):
|
||||
return None
|
||||
national = (groups[0] | groups[1] << 8 | groups[2] << 16 | groups[3] << 24
|
||||
| (groups[4] & 0x3F) << 32)
|
||||
country = ((groups[4] >> 6) | (groups[5] << 2)) & 0x3FF
|
||||
stored_crc = groups[8] | (groups[9] << 8)
|
||||
if stored_crc != crc16_fdxb(bytes(groups[0:8])):
|
||||
return None # CRC mismatch -> not a valid FDX-B frame
|
||||
return {
|
||||
"protocol": "FDX-B",
|
||||
"country_code": country,
|
||||
"national_code": national,
|
||||
"id": f"{country:03d}-{national:012d}",
|
||||
"animal": bool(frame[81]),
|
||||
}
|
||||
|
||||
|
||||
def decode_t55xx_config(samples) -> dict | None:
|
||||
"""Recover a T55xx block-0 config word from a block-0 read response (ASK/Manchester modes).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user