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

View File

@@ -164,6 +164,91 @@ class TestHID:
assert r["protocol"] == "HID Prox" and r["card_number"] == 9999
# --- FDX-B (biphase) --------------------------------------------------------------------------
def _fdxb_bits(country, national, animal=0):
bits = [1] * 128
for i in range(10):
bits[i] = 0
bits[10] = 1
def put(val, n, off):
for k in range(n):
bits[off + k] = (val >> k) & 1 # LSB-first, like num_to_bytebitsLSBF
put(national & 0xFF, 8, 11)
put((national >> 8) & 0xFF, 8, 20)
put((national >> 16) & 0xFF, 8, 29)
put((national >> 24) & 0xFF, 8, 38)
put((national >> 32) & 0x3F, 6, 47)
put(country & 0x3, 2, 53)
put((country >> 2) & 0xFF, 8, 56)
bits[65] = 0
put(0, 7, 66)
put(0, 7, 74)
bits[81] = animal
raw = bytes(sum(bits[11 + i * 9 + k] << k for k in range(8)) for i in range(8))
crc = protocols.crc16_fdxb(raw)
put(crc & 0xFF, 8, 83)
put((crc >> 8) & 0xFF, 8, 92)
return bits
def _biphase_env(databits, rf=32, high=200, low=60, repeats=3):
"""Differential-biphase ASK envelope: toggle level at each bit boundary, and for data 1 add
a mid-bit toggle (so the two half-bits differ) — the inverse of biphase_raw_decode."""
half = []
level = 0
for _ in range(repeats):
for d in databits:
level ^= 1
h1 = level
if d:
level ^= 1
half.append(h1)
half.append(level)
sph = rf // 2
env = []
for hb in half:
env += [high if hb else low] * sph
return bytes(env)
def _fdxb_env(country, national, animal=0, **kw):
return _biphase_env(_fdxb_bits(country, national, animal), **kw)
class TestCRC16:
def test_known_vectors(self):
# "123456789" -> CRC-16/XMODEM 0x31C3, CRC-16/KERMIT 0x2189 (anchors the CRC impl)
d = b"123456789"
assert protocols.crc16(d, 0, 0x1021, refin=False, refout=False) == 0x31C3
assert protocols.crc16(d, 0, 0x1021, refin=True, refout=True) == 0x2189
class TestFDXB:
def test_roundtrip(self):
r = protocols.decode_fdxb(_fdxb_env(999, 0x3FA1B2C3D))
assert r and r["protocol"] == "FDX-B"
assert r["country_code"] == 999 and r["national_code"] == 0x3FA1B2C3D
def test_various(self):
for country, national in ((1, 1), (840, 123456789), (0x3FF, 0x3FFFFFFFFF)):
r = protocols.decode_fdxb(_fdxb_env(country, national))
assert r and r["country_code"] == country and r["national_code"] == national
def test_animal_flag(self):
assert protocols.decode_fdxb(_fdxb_env(124, 555, animal=1))["animal"] is True
def test_crc_gate_rejects_noise(self):
assert protocols.decode_fdxb(bytes([128] * 6000)) is None
assert protocols.decode_fdxb(_em4100_env(0x0102030405)) is None
def test_demod_samples_picks_fdxb(self):
r = demod_samples(_fdxb_env(56, 77))
assert r["protocol"] == "FDX-B" and r["country_code"] == 56
# --- T55xx config -----------------------------------------------------------------------------
class TestT55xxConfig: