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:
michael
2026-07-14 15:55:45 -07:00
parent e4a3aec46f
commit 7e8d924045
4 changed files with 215 additions and 10 deletions

View File

@@ -17,6 +17,7 @@ _SAMPLE_COUNT = 15000
_DECODERS = (
protocols.decode_em4100,
protocols.decode_hid,
protocols.decode_fdxb,
)

View File

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

View File

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