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:
michael
2026-07-14 13:58:51 -07:00
parent 8726681e06
commit 3c56d40667
7 changed files with 591 additions and 67 deletions

View File

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