diff --git a/pm3py/lf/capture.py b/pm3py/lf/capture.py index a160de0..e84ecf4 100644 --- a/pm3py/lf/capture.py +++ b/pm3py/lf/capture.py @@ -154,8 +154,11 @@ def identify_lf(device, emit=None) -> dict | None: if emitted is not None: emit(f" emitted: {credential_label(emitted)}") - # label: prefer the physical chip + what it emulates; else the emitted credential - if chip: + # label: name the chip and what it's actually emitting (the reliable, decoded credential); + # fall back to the config's emulation guess, or just the emitted credential. + if chip and emitted: + label = f"{chip} ({credential_label(emitted)})" + elif chip: label = f"{chip} ({emulating})" else: label = credential_label(emitted) diff --git a/pm3py/lf/protocols.py b/pm3py/lf/protocols.py index 5a02381..3c0ad8b 100644 --- a/pm3py/lf/protocols.py +++ b/pm3py/lf/protocols.py @@ -240,61 +240,48 @@ def _fdxb_parse(frame) -> dict | None: def decode_t55xx_config(samples) -> dict | None: - """Recover a T55xx block-0 config word from a block-0 read response (ASK/Manchester modes). + """Recover a T55xx block-0 config from a block-0 read response (ASK/Manchester modes). - A block read streams the 32-bit word with no header, so alignment is found by the word - repeating (block value at offset N equals offset N+32) and the candidate being a *sane* - config (known modulation, valid master key, 1..7 data blocks). Best-effort by nature — the - reader labels it as a probable config, not gospel. Returns ``None`` for FSK/PSK-mode tags - (handled elsewhere) or when nothing repeats cleanly.""" - best = None + 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] - cand = _find_repeating_word(clean, 32) - if cand is None: - continue - cfg = T5577Config(cand) - score = _config_score(cfg) - if best is None or score > best[0]: - best = (score, cand, cfg) - if best is None or best[0] < 2: # need at least a known modulation + sane structure - return None - _, word, cfg = best - return { - "protocol": "T5577", - "config": word, - "config_hex": f"{word: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(word, cfg), - } + 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 _find_repeating_word(bits, width: int) -> int | None: - """Smallest-offset ``width``-bit window that equals the next ``width`` bits (one block - period), scored by config sanity; returns the best repeating word's value or ``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) - best = None + 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) - score = _config_score(T5577Config(val)) - if best is None or score > best[0]: - best = (score, val) - return best[1] if best else None - - -def _config_score(cfg: T5577Config) -> int: - score = 0 - if isinstance(cfg.modulation, str): # decoded to a known scheme - score += 1 - if cfg.master_key in (0, 6, 9): # normal / extended-mode keys - score += 1 - if 1 <= cfg.max_block <= 7: - score += 1 - return score + if val not in seen: + seen.add(val) + yield val def emulation_name(word: int, cfg: T5577Config | None = None) -> str: diff --git a/tests/test_lf_demod.py b/tests/test_lf_demod.py index b301f96..cfb4f2c 100644 --- a/tests/test_lf_demod.py +++ b/tests/test_lf_demod.py @@ -300,6 +300,19 @@ class TestT55xxConfig: assert r["modulation"] == "ASK" assert r["emulating"] == "EM4100 / EM4102" + def test_config_rotation_recovers_preset(self): + # a block read recovers the config at some bit rotation (hardware gave 0x000A4020 = + # 0x00148040 >> 1); rotate-matching must still resolve it to the EM4100 preset + rotated = ((CONFIG_EM4100 >> 1) | ((CONFIG_EM4100 & 1) << 31)) & 0xFFFFFFFF + assert rotated == 0x000A4020 + r = protocols.decode_t55xx_config(_t55xx_config_env(rotated)) + assert r and r["config"] == CONFIG_EM4100 and r["emulating"] == "EM4100 / EM4102" + + def test_config_no_false_positive(self): + # repeating words that aren't any preset rotation (all-1s, random) -> None, not a bogus config + assert protocols.decode_t55xx_config(_t55xx_config_env(0xFFFFFFFF)) is None + assert protocols.decode_t55xx_config(_t55xx_config_env(0x12345678)) is None + def test_preset_labels(self): assert protocols.emulation_name(T5577Config._PRESETS["hid"]) == "HID Prox" assert protocols.emulation_name(T5577Config._PRESETS["indala"]) == "Indala" @@ -332,7 +345,8 @@ class TestIdentifyLF: assert r["chip"] == "T5577" assert r["emulating"] == "EM4100 / EM4102" assert r["emitted"]["id_hex"] == "0102030405" - assert r["label"] == "T5577 (EM4100 / EM4102)" + # label names the chip + the actual decoded credential (not just the config's guess) + assert r["label"] == "T5577 (EM4100 0102030405)" assert any("T55xx block 0" in l for l in lines) def test_native_em4100_no_config(self):