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>
302 lines
12 KiB
Python
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}"
|