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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user