feat(lf): NRZ + biphase raw physical-layer demod, with a fallback in identify_lf

Add the two missing ASK line codes so an unrecognised tag yields its raw bits
instead of reading as "nothing":

- ask.nrz_bits: NRZ/direct demod. Keys the bit clock off the shortest run (not
  2x a half-bit as in Manchester), resamples, yields both polarities/phases.
- ask.biphase_bits: biphase/differential-Manchester, reusing dsp.biphase_raw_decode
  (the FDX-B path).
- capture.demod_raw: runs both, finds the shortest clean repeating frame, returns
  {period, bits, hex} per encoding + the recovered clock. Sits BELOW the credential
  decoders — it names no format, just hands back the bits.
- identify_lf falls back to demod_raw when no credential decodes off the air, so a
  strong-but-unknown LF signal surfaces its frame instead of None.

Hardware-hardened: on a live non-NRZ/biphase tag the first cut reported a spurious
16-bit "frame" — biphase-decoding a non-biphase signal emits a short prefix then a
constant run that trivially repeats at any period. _frame_period now rejects
near-constant streams and finds the smallest *true* period (so 0101 fills read as
period 2 and are rejected, not reported at min_p). Regression tests cover all three.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-16 19:13:36 -07:00
parent e3a394859c
commit a990d725ef
4 changed files with 188 additions and 5 deletions

View File

@@ -8,6 +8,7 @@ from unittest.mock import MagicMock
from pm3py.lf import dsp, fsk, demod_samples, read_config, identify_lf
from pm3py.lf import protocols
from pm3py.lf import capture
from pm3py.transponders.lf.em.em4100 import EM4100Tag
from pm3py.transponders.lf.hid.hid import HIDProxTag
from pm3py.transponders.lf.atmel.t5577 import T5577Config, CONFIG_EM4100
@@ -272,6 +273,71 @@ def _fdxb_env(country, national, animal=0, **kw):
return _biphase_env(_fdxb_bits(country, national, animal), **kw)
# --- NRZ / raw physical-layer fallback --------------------------------------------------------
def _nrz_env(databits, rf=32, high=200, low=60, repeats=8):
"""Direct/NRZ ASK envelope: each data bit is output as-is, held for one bit-width (rf
samples). The inverse of ask.nrz_bits."""
env = []
for d in databits * repeats:
env += [high if d else low] * rf
return bytes(env)
def _cyclic_match(bitstr, frame) -> bool:
"""Is ``bitstr`` a rotation of ``frame`` or its bitwise inverse? Raw demod carries polarity
and start-of-frame ambiguity, so cyclic-plus-invert is the right equivalence."""
f = "".join(str(b) for b in frame)
if len(bitstr) != len(f):
return False
inv = "".join("1" if c == "0" else "0" for c in f)
return bitstr in (f + f) or bitstr in (inv + inv)
class TestRawDemod:
# a 32-bit frame with no short internal period (so the recovered frame length is 32)
FRAME = [1, 0, 1, 1, 0, 0, 1, 0, 1, 1, 1, 0, 0, 0, 1, 0,
0, 1, 1, 0, 1, 0, 0, 1, 1, 1, 0, 1, 0, 0, 0, 1]
def test_nrz_round_trips(self):
r = capture.demod_raw(_nrz_env(self.FRAME, rf=32))
assert r and "nrz" in r["encodings"]
enc = r["encodings"]["nrz"]
assert enc["period"] == 32
assert _cyclic_match(enc["bits"], self.FRAME)
assert r["rf"] == "RF/32"
def test_nrz_various_rates(self):
for rf in (32, 64):
r = capture.demod_raw(_nrz_env(self.FRAME, rf=rf))
assert r and _cyclic_match(r["encodings"]["nrz"]["bits"], self.FRAME)
def test_biphase_round_trips(self):
r = capture.demod_raw(_biphase_env(self.FRAME, rf=32))
assert r and "biphase" in r["encodings"]
assert _cyclic_match(r["encodings"]["biphase"]["bits"], self.FRAME)
def test_noise_returns_none(self):
assert capture.demod_raw(bytes([128] * 4000)) is None
assert capture.demod_raw(b"") is None
def test_raw_is_below_credentials(self):
# a genuine EM4100 must decode as a credential; the raw fallback is only for the unnamed
assert demod_samples(_em4100_env(0x0102030405))["protocol"] == "EM4100"
def test_generators_yield_bits(self):
from pm3py.lf import ask
assert any(len(b) >= 32 for b in ask.nrz_bits(_nrz_env(self.FRAME, rf=32)))
assert any(len(b) >= 32 for b in ask.biphase_bits(_biphase_env(self.FRAME, rf=32)))
def test_rejects_constant_tail(self):
# hardware regression: biphase-decoding a non-biphase tag emits a short prefix then a long
# constant run, which trivially "repeats" at any period. That must NOT report a frame.
assert capture._frame_period([0, 0, 0, 1, 0, 1, 0, 1] + [1] * 200) is None
assert capture._frame_period([1] * 200) is None # fully constant
assert capture._frame_period(([0, 1] * 100)) is None # alternating fill, sub-periodic
class TestCRC16:
def test_known_vectors(self):
# "123456789" -> CRC-16/XMODEM 0x31C3, CRC-16/KERMIT 0x2189 (anchors the CRC impl)