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>
This commit is contained in:
michael
2026-07-14 21:26:56 -07:00
parent e82b94a490
commit 6c5409c111
3 changed files with 52 additions and 48 deletions

View File

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

View File

@@ -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:

View File

@@ -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):