Files
pm3py/pm3py/lf/protocols.py
michael 6c5409c111 fix(lf): T55xx config via rotation-to-preset match; label the emitted credential
Hardware-validated. Two real bugs in the T55xx config decode, both fixed by requiring a
bit-rotation of the recovered repeating word to match a known preset:

- Rotation ambiguity: a block read recovers the 32-bit config at an arbitrary bit offset.
  A real capture came back 0x000A4020 = the EM4100 word 0x00148040 rotated by one; the old
  "looks sane" scorer accepted the rotation verbatim and mislabeled it FSK1/RF-6. Now every
  rotation is tried and only a preset match (EM4100/HID/Indala/FDX-B/Viking/default) is
  accepted -> resolves to 0x00148040 = EM4100.
- False positives: garbage config captures (all-0, all-1, a rotation of the tag's own
  emission) no longer pass -> decode returns None and the reliable emitted-stream decode
  carries the result.

identify_lf label now names the chip + the actual decoded credential:
"T5577 (EM4100 00FFFFFFFF)" instead of the config's guess. On real hardware this is now
stable 4/4: config 00148040, emitted EM4100 00FFFFFFFF.

Tests: rotation recovery (0x000A4020 -> EM4100), no-false-positive on all-1s/random,
updated label expectation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:26:56 -07:00

302 lines
12 KiB
Python

"""Per-tag LF framing: bit stream -> decoded credential.
Each decoder takes a raw envelope, runs the generic :mod:`pm3py.lf.dsp` pipeline (trying both
Manchester phase/polarity alignments), and validates candidate frames with the *transponder
model's own* checker — ``EM4100Code.valid``, ``T5577Config`` sanity — so framing rules live in
exactly one place. A decoder returns a structured dict on a clean, validated frame, else ``None``.
"""
from __future__ import annotations
from . import dsp
from . import fsk
from . import ask
# Import the models through the ``pm3py.sim`` package (not their deep module paths): sim's
# __init__ re-exports them, and going through it forces sim to fully initialize first, avoiding
# a circular import (em4100 -> pm3py.sim.frame -> sim.__init__ -> em4100) when pm3py.lf is the
# first thing loaded.
from pm3py.sim import EM4100Code, T5577Config, HIDProxTag
# config word -> preset name (invert T5577Config._PRESETS) for "emulating ..." labelling
_PRESET_BY_WORD = {word: name for name, word in T5577Config._PRESETS.items()}
def _manchester_bits(samples):
"""Yield candidate data-bit streams for an ASK/Manchester envelope. Delegates to the faithful
C ASK port (:mod:`pm3py.lf.ask`) — hysteresis rail-to-rail demod that survives the asymmetric,
ramped, sometimes-saturated shape of real LF captures, where a naive resample bit-slips."""
yield from ask.manchester_bits(samples)
def decode_em4100(samples) -> dict | None:
"""Demodulate an EM4100/EM4102 envelope (ASK + Manchester) to its 40-bit ID.
Searches the demodulated stream for a 64-bit frame whose header, ten row parities, four
column parities and stop bit all check out (``EM4100Code.valid``) — the frame repeats, so a
clean copy is found even if the capture starts mid-frame."""
for bits in _manchester_bits(samples):
code = _find_em4100(bits)
if code is not None:
return {
"protocol": "EM4100",
"tag_id": code.tag_id,
"id_hex": f"{code.tag_id:010X}",
"customer_id": code.customer_id,
"card_number": code.card_number,
}
return None
def _find_em4100(bits) -> EM4100Code | None:
n = len(bits)
for start in range(max(0, n - 63)):
window = bits[start:start + 64]
if len(window) < 64 or None in window:
continue
code = EM4100Code(dsp.bits_to_int(window))
if code.valid:
return code
return None
def _hid_field(full: int, fmt: str, nbits: int) -> dict | None:
"""Extract the low ``nbits`` as a ``fmt`` Wiegand credential, accepting only if re-encoding
the decoded facility+card reproduces the bits exactly (proves both parities)."""
wiegand = [(full >> (nbits - 1 - i)) & 1 for i in range(nbits)]
dec = HIDProxTag.decode_wiegand(wiegand, fmt)
fc, cn = dec["facility_code"], dec["card_number"]
if HIDProxTag._encode_wiegand(fc, cn, fmt) != wiegand:
return None
return {
"protocol": "HID Prox", "format": fmt, "facility_code": fc, "card_number": cn,
"wiegand": f"{full & ((1 << nbits) - 1):0{(nbits + 3) // 4}X}",
}
def decode_hid(samples) -> dict | None:
"""Demodulate an HID Prox envelope (FSK2a + Manchester) to its Wiegand credential.
Format is told by frame *structure*, not parity alone (2 parity bits can't separate 26- from
37-bit): the 26-bit H10301 wire frame carries a 0x20 header at bit 37 and a sentinel 1 at
bit 26 with the Wiegand below; the 37-bit H10304 frame is header-less, so a 37-bit Manchester
count with no 0x20 marks it. Each candidate is still parity-validated by re-encoding through
the shipped ``HIDProxTag``."""
r = fsk.hid_demod_fsk(samples)
if r is None:
return None
hi2, hi, lo, nbits = r
full = (hi << 32) | lo # low 64 bits cover 26- and 37-bit formats
if (hi & 0x20) and (lo & (1 << 26)): # 26-bit standard header + sentinel
got = _hid_field(full, "H10301", 26)
if got:
return got
if nbits == 37 and not (hi & 0x20): # header-less 37-bit frame
got = _hid_field(full, "H10304", 37)
if got:
return got
return None
# AWID format length -> (facility offset/width, card offset/width) in the 66 de-parity'd bits
# (cmdlfawid.c index map). fmtLen is bits[0:8]; bit 8 is a Wiegand parity.
_AWID_FORMATS = {
26: ((9, 8), (17, 16)),
34: ((9, 8), (17, 24)),
36: ((14, 11), (25, 18)),
37: ((9, 13), (22, 18)),
50: ((9, 16), (25, 32)),
}
def _strip_parity(bits, group: int = 4, want_odd: bool = True):
"""Port of ``removeParity`` for AWID: each ``group`` bits are (group-1) data + 1 parity;
verify the parity and keep the data. Returns the compacted bits, or ``None`` on a parity
fault (the check that makes AWID trustworthy without a CRC)."""
out = []
for i in range(0, len(bits) - group + 1, group):
g = bits[i:i + group]
if (sum(g) & 1) != (1 if want_odd else 0):
return None
out.extend(g[:group - 1])
return out
def decode_awid(samples) -> dict | None:
"""Demodulate an AWID envelope (FSK2a, no Manchester) to its Wiegand credential.
``detectAWID`` gives the 96 frame bits; the 88 after the preamble are 22 groups of 3 data +
1 odd-parity bit, so stripping parity yields 66 bits whose first byte is the format length
(26/34/36/37/50). The 22 parity checks stand in for AWID's missing CRC — a bad frame fails
them — so this doesn't false-positive."""
frame = fsk.awid_demod_fsk(samples)
if frame is None:
return None
data = _strip_parity(frame[8:8 + 88], group=4, want_odd=True)
if data is None or len(data) != 66:
return None
fmt_len = dsp.bits_to_int(data[0:8])
spec = _AWID_FORMATS.get(fmt_len)
if spec is None:
return None
(fc_off, fc_w), (cn_off, cn_w) = spec
fc = dsp.bits_to_int(data[fc_off:fc_off + fc_w])
cardnum = dsp.bits_to_int(data[cn_off:cn_off + cn_w])
return {
"protocol": "AWID",
"format": f"AWID-{fmt_len}",
"facility_code": fc,
"card_number": cardnum,
}
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 from a block-0 read response (ASK/Manchester modes).
A block read streams the 32-bit config with no header, so the demod recovers it at *some* bit
rotation. We collect every 32-bit word that repeats one block-period later and accept one only
when a bit-rotation of it exactly matches a known config preset (EM4100, HID, Indala, FDX-B,
Viking, default). That both resolves the rotation ambiguity (a real capture came back as
0x000A4020 = the EM4100 word 0x00148040 rotated by one) and rejects the spurious repeats
(all-0, all-1, a rotation of the tag's own emission) that a bare "looks like a sane config"
score waved through as false positives. Returns ``None`` when nothing rotates to a preset —
the emitted-stream decode carries the result instead."""
for bits in _manchester_bits(samples):
clean = [b for b in bits if b is not None]
for word in _repeating_words(clean, 32):
for rot in range(32):
rv = ((word << rot) | (word >> (32 - rot))) & 0xFFFFFFFF
if rv in _PRESET_BY_WORD:
cfg = T5577Config(rv)
return {
"protocol": "T5577",
"config": rv,
"config_hex": f"{rv:08X}",
"modulation": cfg.modulation if isinstance(cfg.modulation, str)
else f"0x{cfg.modulation:02X}",
"data_bit_rate": cfg.data_bit_rate,
"max_block": cfg.max_block,
"emulating": emulation_name(rv, cfg),
}
return None
def _repeating_words(bits, width: int):
"""Yield the distinct ``width``-bit windows that equal the next ``width`` bits (a block-read
candidate repeating one block-period later)."""
n = len(bits)
seen = set()
for start in range(max(0, n - 2 * width)):
w = bits[start:start + width]
if w == bits[start + width:start + 2 * width]:
val = dsp.bits_to_int(w)
if val not in seen:
seen.add(val)
yield val
def emulation_name(word: int, cfg: T5577Config | None = None) -> str:
"""Human label for what a T5577 config emulates: an exact preset match if we have one
(EM4100, HID, Indala, FDX-B, Viking, default), else the modulation/rate family."""
exact = _PRESET_BY_WORD.get(word)
if exact:
return {"em4100": "EM4100 / EM4102", "hid": "HID Prox", "indala": "Indala",
"fdxb": "FDX-B", "viking": "Viking", "default": "raw / default"}.get(exact, exact)
cfg = cfg or T5577Config(word)
mod = cfg.modulation if isinstance(cfg.modulation, str) else "?"
family = ("EM/ASK" if mod == "ASK"
else "HID/AWID" if mod.startswith("FSK")
else "Indala/PSK" if mod.startswith("PSK")
else "FDX-B/Nedap" if "biphase" in mod
else mod)
return f"{family} · {mod} RF/{cfg.data_bit_rate}"