From 7e8d924045d0c51ddab6f14d5aec6d0463a7c4a8 Mon Sep 17 00:00:00 2001 From: michael Date: Tue, 14 Jul 2026 15:55:45 -0700 Subject: [PATCH] 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 --- pm3py/lf/capture.py | 1 + pm3py/lf/dsp.py | 48 +++++++++++++++++----- pm3py/lf/protocols.py | 91 ++++++++++++++++++++++++++++++++++++++++++ tests/test_lf_demod.py | 85 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 215 insertions(+), 10 deletions(-) diff --git a/pm3py/lf/capture.py b/pm3py/lf/capture.py index d388b62..c047718 100644 --- a/pm3py/lf/capture.py +++ b/pm3py/lf/capture.py @@ -17,6 +17,7 @@ _SAMPLE_COUNT = 15000 _DECODERS = ( protocols.decode_em4100, protocols.decode_hid, + protocols.decode_fdxb, ) diff --git a/pm3py/lf/dsp.py b/pm3py/lf/dsp.py index 014d94b..a6da2f9 100644 --- a/pm3py/lf/dsp.py +++ b/pm3py/lf/dsp.py @@ -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: diff --git a/pm3py/lf/protocols.py b/pm3py/lf/protocols.py index abacb8c..17a9824 100644 --- a/pm3py/lf/protocols.py +++ b/pm3py/lf/protocols.py @@ -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). diff --git a/tests/test_lf_demod.py b/tests/test_lf_demod.py index 821c766..8ce19b1 100644 --- a/tests/test_lf_demod.py +++ b/tests/test_lf_demod.py @@ -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: