feat(lf): Python LF demod stack — ASK/EM4100 + T55xx config; wire into identify
The firmware returns raw ADC envelope samples for LF reads (T55xx readbl replies PM3_SUCCESS with NULL data); demodulation was always the C client's job, so pm3py had none and LF identify could never see a tag. This adds the missing DSP stack in Python — new pm3py.lf package: - dsp.py: modulation-agnostic primitives — robust threshold, edge-interval clock recovery, ASK binarize, half-bit resample, Manchester + biphase decode. - protocols.py: EM4100 (ASK/Manchester -> 40-bit ID, validated by EM4100Code's own header/row/col/stop parity checks) and best-effort T55xx block-0 config read (find the repeating 32-bit word, score by T5577Config sanity, name the emulation). - capture.py: live-device orchestration — read the emitted stream + probe a T55xx via block-0 read, combine into "is it a T5577, and what is it (emulating)?". rawcli identify now demodulates LF instead of the dead readbl-only probe: reports "T5577 (EM4100 / EM4102)" for an emulator, "EM4100 <id>" for a bare credential. Fully self-testing without hardware: the LF transponder models already encode these protocols, so a synthetic envelope built from an encoder round-trips through the demod. 17 demod tests (EM4100 across IDs/rates/mid-frame/noise, T55xx config, mocked identify) + updated rawcli LF tests. Full suite 1225 green. FSK/HID + PSK/Indala + biphase/FDX-B decoders queued (same dsp primitives). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
151
tests/test_lf_demod.py
Normal file
151
tests/test_lf_demod.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""LF demod tests — hardware-free.
|
||||
|
||||
Every case builds a synthetic Proxmark-style envelope from a transponder *encoder*, runs it
|
||||
through the demod, and asserts the credential comes back. Because the encode side is the
|
||||
shipped model, a green test means the demod is the true inverse of what the tag emits.
|
||||
"""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from pm3py.lf import dsp, demod_samples, read_config, identify_lf
|
||||
from pm3py.lf import protocols
|
||||
from pm3py.transponders.lf.em.em4100 import EM4100Tag
|
||||
from pm3py.transponders.lf.atmel.t5577 import T5577Config, CONFIG_EM4100
|
||||
|
||||
|
||||
# --- synthetic envelope builders (inverse of the demod) ---------------------------------------
|
||||
|
||||
def _manchester_env(frame_bits, rf=64, high=200, low=60, repeats=4, start_offset=0, noise=0):
|
||||
"""Manchester-encode a bit list into an RF/n ASK envelope (data 1 -> half-bits 1,0)."""
|
||||
half = []
|
||||
for d in frame_bits * repeats:
|
||||
half += [1, 0] if d else [0, 1]
|
||||
sph = rf // 2
|
||||
env = []
|
||||
for hb in half:
|
||||
env += [high if hb else low] * sph
|
||||
if noise:
|
||||
# deterministic ± ripple, no RNG (Math.random is unavailable and tests must be stable)
|
||||
env = [max(0, min(255, v + (noise if (i % 3 == 0) else -noise))) for i, v in enumerate(env)]
|
||||
return bytes(env[start_offset:])
|
||||
|
||||
|
||||
def _em4100_env(tag_id, **kw):
|
||||
return _manchester_env(EM4100Tag._encode(tag_id), **kw)
|
||||
|
||||
|
||||
def _t55xx_config_env(word, rf=64, **kw):
|
||||
bits = [(word >> (31 - i)) & 1 for i in range(32)]
|
||||
return _manchester_env(bits, rf=rf, **kw)
|
||||
|
||||
|
||||
# --- DSP primitives ---------------------------------------------------------------------------
|
||||
|
||||
class TestDSP:
|
||||
def test_threshold_midpoint(self):
|
||||
assert 100 < dsp.threshold([60] * 50 + [200] * 50) < 160
|
||||
|
||||
def test_flat_capture_detected(self):
|
||||
assert dsp.is_flat([128] * 200)
|
||||
assert not dsp.is_flat([60] * 100 + [200] * 100)
|
||||
|
||||
def test_clock_detects_halfbit(self):
|
||||
env = _em4100_env(0x0102030405, rf=64)
|
||||
half = dsp.detect_clock(dsp.binarize(env))
|
||||
assert half is not None and abs(half - 32) < 4 # RF/64 -> 32-sample half-bit
|
||||
|
||||
def test_manchester_roundtrip(self):
|
||||
# (1,0)->1, (0,1)->0 at phase 0
|
||||
assert dsp.manchester_decode([1, 0, 0, 1, 1, 0]) == [1, 0, 1]
|
||||
|
||||
|
||||
# --- EM4100 -----------------------------------------------------------------------------------
|
||||
|
||||
class TestEM4100:
|
||||
def test_roundtrip(self):
|
||||
r = protocols.decode_em4100(_em4100_env(0x0102030405))
|
||||
assert r and r["protocol"] == "EM4100"
|
||||
assert r["tag_id"] == 0x0102030405 and r["id_hex"] == "0102030405"
|
||||
|
||||
def test_various_ids_and_rates(self):
|
||||
for tid in (0x0000000001, 0xAB12CD34EF, 0xFFFFFFFFFF):
|
||||
for rf in (32, 64):
|
||||
r = protocols.decode_em4100(_em4100_env(tid, rf=rf))
|
||||
assert r and r["tag_id"] == tid, f"{tid:010X} @ RF/{rf}"
|
||||
|
||||
def test_starts_mid_frame(self):
|
||||
# capture that begins partway through a frame still locks (frame repeats)
|
||||
r = protocols.decode_em4100(_em4100_env(0x1234567890, start_offset=777))
|
||||
assert r and r["tag_id"] == 0x1234567890
|
||||
|
||||
def test_survives_noise(self):
|
||||
r = protocols.decode_em4100(_em4100_env(0x00DEADBEEF, noise=25))
|
||||
assert r and r["tag_id"] == 0x00DEADBEEF
|
||||
|
||||
def test_flat_is_none(self):
|
||||
assert protocols.decode_em4100(bytes([128] * 4000)) is None
|
||||
|
||||
def test_customer_and_card_fields(self):
|
||||
r = protocols.decode_em4100(_em4100_env(0xAB01020304))
|
||||
assert r["customer_id"] == 0xAB
|
||||
assert r["card_number"] == 0x01020304
|
||||
|
||||
|
||||
# --- T55xx config -----------------------------------------------------------------------------
|
||||
|
||||
class TestT55xxConfig:
|
||||
def test_em4100_config_roundtrip(self):
|
||||
r = protocols.decode_t55xx_config(_t55xx_config_env(CONFIG_EM4100))
|
||||
assert r and r["protocol"] == "T5577"
|
||||
assert r["config"] == CONFIG_EM4100
|
||||
assert r["modulation"] == "ASK"
|
||||
assert r["emulating"] == "EM4100 / EM4102"
|
||||
|
||||
def test_preset_labels(self):
|
||||
assert protocols.emulation_name(T5577Config._PRESETS["hid"]) == "HID Prox"
|
||||
assert protocols.emulation_name(T5577Config._PRESETS["indala"]) == "Indala"
|
||||
assert protocols.emulation_name(T5577Config._PRESETS["fdxb"]) == "FDX-B"
|
||||
|
||||
def test_unknown_config_family_label(self):
|
||||
# an ASK config that isn't a known preset -> family label, not a crash
|
||||
label = protocols.emulation_name(0x00148000)
|
||||
assert "ASK" in label or "EM" in label
|
||||
|
||||
|
||||
# --- device orchestration (mocked) ------------------------------------------------------------
|
||||
|
||||
def _mock_lf_device(config_env=b"", emitted_env=b""):
|
||||
dev = MagicMock()
|
||||
dev.lf.t55.readbl.return_value = {"status": 0}
|
||||
dev.lf.read.return_value = {"status": 0, "samples": len(emitted_env)}
|
||||
# identify_lf downloads config first, then the emitted stream
|
||||
dev.lf.download_samples.side_effect = [config_env, emitted_env]
|
||||
return dev
|
||||
|
||||
|
||||
class TestIdentifyLF:
|
||||
def test_t5577_emulating_em4100(self):
|
||||
dev = _mock_lf_device(config_env=_t55xx_config_env(CONFIG_EM4100),
|
||||
emitted_env=_em4100_env(0x0102030405))
|
||||
lines = []
|
||||
r = identify_lf(dev, emit=lines.append)
|
||||
assert r["found"] and r["field"] == "lf"
|
||||
assert r["chip"] == "T5577"
|
||||
assert r["emulating"] == "EM4100 / EM4102"
|
||||
assert r["emitted"]["id_hex"] == "0102030405"
|
||||
assert r["label"] == "T5577 (EM4100 / EM4102)"
|
||||
assert any("T55xx block 0" in l for l in lines)
|
||||
|
||||
def test_native_em4100_no_config(self):
|
||||
# a real EM4100 chip: nothing answers the T55xx read, but it emits its ID
|
||||
dev = _mock_lf_device(config_env=bytes([128] * 4000),
|
||||
emitted_env=_em4100_env(0x1122334455))
|
||||
r = identify_lf(dev)
|
||||
assert r["found"] and r["chip"] is None
|
||||
assert r["label"] == "EM4100 1122334455"
|
||||
|
||||
def test_nothing_on_antenna(self):
|
||||
dev = _mock_lf_device(config_env=bytes([128] * 4000), emitted_env=bytes([128] * 4000))
|
||||
assert identify_lf(dev) is None
|
||||
|
||||
def test_no_device(self):
|
||||
assert identify_lf(None) is None
|
||||
@@ -10,7 +10,7 @@ from pm3py.cli.rawcli.session import RawSession
|
||||
from pm3py.cli.rawcli.trace_view import render_exchange
|
||||
from pm3py.cli.rawcli.parser import parse_token, parse_bytes, parse_line
|
||||
from pm3py.cli.rawcli.entry import byte_space
|
||||
from pm3py.cli.rawcli.identify import identify, clear, name_14a
|
||||
from pm3py.cli.rawcli.identify import identify, clear, name_14a, format_summary
|
||||
from pm3py.cli.rawcli.catalog import TYPE2, ISO15, catalog_for, catalog_for as _cf
|
||||
from pm3py.cli.rawcli.tlv import parse_tlv, format_tlv
|
||||
from pm3py.cli.rawcli.completer import RawCompleter
|
||||
@@ -182,7 +182,9 @@ def _mock_device(scan14=None, scan15=None, version=None):
|
||||
dev.hf.iso14a.scan.return_value = scan14 or {"found": False}
|
||||
dev.hf.iso15.scan.return_value = scan15 or {"found": False}
|
||||
dev.lf.search.return_value = None
|
||||
dev.lf.t55.readbl.return_value = {"status": 1, "raw": b""} # no T55xx by default
|
||||
dev.lf.t55.readbl.return_value = {"status": 0}
|
||||
dev.lf.read.return_value = {"status": 0}
|
||||
dev.lf.download_samples.return_value = bytes([128] * 2000) # flat -> no LF tag by default
|
||||
dev.hf.iso14a.raw.return_value = {"raw": (version + b"\x99\x88") if version else None}
|
||||
return dev
|
||||
|
||||
@@ -222,27 +224,36 @@ class TestIdentify:
|
||||
r = identify(s)
|
||||
assert r["found"] is False and s.transponder is None
|
||||
|
||||
def test_no_false_lf(self):
|
||||
# nothing on HF and lf.search() truthy must report not-found, not a bogus "LF tag"
|
||||
dev = _mock_device()
|
||||
dev.lf.search.return_value = object() # always-truthy search result
|
||||
s = RawSession(device=dev)
|
||||
assert identify(s)["found"] is False and s.transponder is None
|
||||
def test_no_flat_lf(self):
|
||||
# nothing on HF and a flat LF envelope must report not-found, not a bogus "LF tag"
|
||||
assert identify(RawSession(device=_mock_device()))["found"] is False
|
||||
|
||||
def test_lf_t5577_reports_emulation(self):
|
||||
# T55xx block 0 = EM4100 config -> "T5577 (EM4100 / EM4102)"
|
||||
dev = _mock_device()
|
||||
dev.lf.t55.readbl.return_value = {"status": 0, "raw": bytes.fromhex("00148040")}
|
||||
s = RawSession(device=dev)
|
||||
def test_lf_wiring_sets_label(self, monkeypatch):
|
||||
# the LF path folds pm3py.lf.identify_lf's result into the session (demod itself is
|
||||
# covered in test_lf_demod.py); here we assert the rawcli wiring/label/summary.
|
||||
import pm3py.lf
|
||||
monkeypatch.setattr(pm3py.lf, "identify_lf", lambda dev, emit=None: {
|
||||
"found": True, "field": "lf", "protocol": "lf", "chip": "T5577",
|
||||
"config": "00148040", "emulating": "EM4100 / EM4102",
|
||||
"emitted": {"protocol": "EM4100", "id_hex": "0102030405"},
|
||||
"label": "T5577 (EM4100 / EM4102)"})
|
||||
s = RawSession(device=_mock_device()) # HF finds nothing -> LF path
|
||||
r = identify(s)
|
||||
assert r["found"] and s.field == "lf" and s.protocol == "lf"
|
||||
assert s.transponder == "T5577 (EM4100 / EM4102)"
|
||||
assert r["config"] == "00148040"
|
||||
assert r["config"] == "00148040" and r["id"] == "0102030405"
|
||||
assert "config 0x00148040" in format_summary(r)
|
||||
|
||||
def test_lf_no_t5577_not_found(self):
|
||||
dev = _mock_device()
|
||||
dev.lf.t55.readbl.return_value = {"status": 1, "raw": b""} # no T55xx response
|
||||
assert identify(RawSession(device=dev))["found"] is False
|
||||
def test_lf_native_credential_label(self, monkeypatch):
|
||||
import pm3py.lf
|
||||
monkeypatch.setattr(pm3py.lf, "identify_lf", lambda dev, emit=None: {
|
||||
"found": True, "field": "lf", "protocol": "lf", "chip": None, "config": None,
|
||||
"emulating": None, "emitted": {"protocol": "EM4100", "id_hex": "1122334455"},
|
||||
"label": "EM4100 1122334455"})
|
||||
s = RawSession(device=_mock_device())
|
||||
r = identify(s)
|
||||
assert s.transponder == "EM4100 1122334455"
|
||||
assert "id 1122334455" in format_summary(r)
|
||||
|
||||
def test_identify_clears_stale_result(self):
|
||||
# a second identify that finds nothing must clear the previous tag from the session
|
||||
@@ -251,7 +262,6 @@ class TestIdentify:
|
||||
identify(s)
|
||||
assert s.transponder == "MIFARE Classic 1K"
|
||||
dev.hf.iso14a.scan.return_value = {"found": False} # tag removed
|
||||
dev.lf.t55.readbl.return_value = {"status": 1, "raw": b""}
|
||||
identify(s)
|
||||
assert s.transponder is None and s.field is None # no stale data
|
||||
|
||||
|
||||
Reference in New Issue
Block a user