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

@@ -17,6 +17,6 @@ Layers:
Everything is testable without hardware: the LF transponder models already *encode* these Everything is testable without hardware: the LF transponder models already *encode* these
protocols, so a synthetic envelope built from an encoder round-trips through the demod. protocols, so a synthetic envelope built from an encoder round-trips through the demod.
""" """
from .capture import demod_samples, read_config, identify_lf from .capture import demod_samples, demod_raw, read_config, identify_lf
__all__ = ["demod_samples", "read_config", "identify_lf"] __all__ = ["demod_samples", "demod_raw", "read_config", "identify_lf"]

View File

@@ -127,3 +127,41 @@ def manchester_bits(samples):
for phase in (0, 1): for phase in (0, 1):
for minv in (False, True): for minv in (False, True):
yield dsp.manchester_decode(raw, phase, minv) yield dsp.manchester_decode(raw, phase, minv)
def nrz_bits(samples):
"""Yield candidate raw NRZ / direct bit streams. In NRZ (the T5577 "direct" line code) the
data bit is output with no coding — the envelope sampled once per bit *is* the data — so the
bit clock is the shortest run itself, not twice a half-bit as in Manchester. Yields both
polarities over a few phase offsets; the caller keeps whichever repeats cleanly."""
core = trim_to_signal(samples)
if len(core) < 64 or dsp.is_flat(core):
return
binary = dsp.binarize(core)
half = dsp.detect_clock(binary)
if not half or half < 8:
return
clk = min(_CLOCKS, key=lambda c: abs(c - half)) # NRZ: one bit = the shortest run
for offset in (0.5, 0.0, 0.25, 0.75):
raw = dsp.resample(binary, clk, offset)
if len(raw) < 32:
continue
yield raw
yield [b ^ 1 for b in raw]
def biphase_bits(samples):
"""Yield candidate raw bit streams for a biphase / differential-Manchester ASK envelope — the
FDX-B line code (also Indala, Gallagher, …). Resample to half-bit resolution and run the
biphase decoder for both polarities; phase-error markers are dropped."""
core = trim_to_signal(samples)
if len(core) < 64 or dsp.is_flat(core):
return
binary = dsp.binarize(core)
half = dsp.detect_clock(binary)
if not half or half < 4:
return
raw = dsp.resample(binary, half)
for invert in (0, 1):
bits, _err = dsp.biphase_raw_decode(raw, 0, invert)
yield [b for b in bits if b != 7]

View File

@@ -10,6 +10,7 @@ from __future__ import annotations
import inspect import inspect
from . import ask from . import ask
from . import dsp
from . import protocols from . import protocols
# how many envelope samples to pull from BigBuf after a capture (a T55xx/EM frame is a few # how many envelope samples to pull from BigBuf after a capture (a T55xx/EM frame is a few
@@ -53,6 +54,70 @@ def demod_samples(samples) -> dict | None:
return None return None
def _transition_density(bits) -> float:
return sum(1 for i in range(1, len(bits)) if bits[i] != bits[i - 1]) / max(1, len(bits) - 1)
def _frame_period(bits, min_p: int = 16) -> int | None:
"""The smallest bit-period the stream repeats at (its frame length), or None.
Guards against the failure mode where the wrong line code decodes a signal into a mostly
*constant* run (e.g. biphase-decoding a non-biphase tag emits a short prefix then all-1s):
such a stream trivially "repeats" at any period. So we reject a near-constant stream up front,
require room for a few real repeats, and reject a frame that is itself near-constant or
trivially sub-periodic (an alternating 0101 fill)."""
n = len(bits)
if n < 3 * min_p:
return None
if _transition_density(bits) < 0.10: # mostly constant -> not a real frame
return None
# find the *smallest true* period, from 2 up — so a trivial fill (0101 -> period 2, 0011 ->
# period 4) is caught as tiny and rejected, instead of being reported at the first p >= min_p.
for p in range(2, n // 3 + 1):
span = n - p
if sum(1 for i in range(span) if bits[i] == bits[i + p]) >= span * 0.97:
return p if p >= min_p else None # real tag frame is >= min_p bits
return None
def demod_raw(samples) -> dict | None:
"""Physical-layer fallback for an ASK tag no named decoder recognises: recover the raw
repeating frame under the NRZ and biphase line codes so the tag still yields its bits.
Returns whichever encodings produced a clean repeating frame (bit string + hex + period in
bits) plus the recovered bit clock, or None if nothing repeats cleanly. This is deliberately
*below* the credential decoders — it names no format, it just hands back the bits an expert
(or a future format decoder) can read."""
core = ask.trim_to_signal(samples)
if len(core) < 64 or dsp.is_flat(core):
return None
half = dsp.detect_clock(dsp.binarize(core))
if not half:
return None
encs: dict = {}
for name, gen in (("nrz", ask.nrz_bits), ("biphase", ask.biphase_bits)):
best = None
for bits in gen(samples):
p = _frame_period(bits)
if p and (best is None or p < best[0]):
best = (p, bits[:p])
if best:
p, frame = best
s = "".join(str(b) for b in frame)
encs[name] = {"period": p, "bits": s, "hex": f"{int(s, 2):0{-(-p // 4)}X}"}
if not encs:
return None
clk = min(ask._CLOCKS, key=lambda c: abs(c - half))
return {"protocol": "raw", "shortest_run": round(half, 1), "rf": f"RF/{clk}",
"encodings": encs}
def raw_label(raw: dict) -> str:
"""One-line name for a raw physical-layer dump (no credential decoded)."""
parts = [f"{k} {v['hex']} ({v['period']}b)" for k, v in raw["encodings"].items()]
return f"unknown LF ({raw['rf']}) — " + "; ".join(parts)
# download a fixed, safe span rather than trusting lf.read()'s reported count — on a flaky NG # download a fixed, safe span rather than trusting lf.read()'s reported count — on a flaky NG
# link that count is sometimes garbage, and over-reading spills into BigBuf memory past the # link that count is sometimes garbage, and over-reading spills into BigBuf memory past the
# samples. 12000 is under a normal acquisition, enough frames for any LF frame to repeat. # samples. 12000 is under a normal acquisition, enough frames for any LF frame to repeat.
@@ -159,7 +224,14 @@ def identify_lf(device, emit=None) -> dict | None:
config = read_config(device) # T55xx? -> chip + emulation config = read_config(device) # T55xx? -> chip + emulation
emitted = read_emitted(device) # what's on the air -> EM4100 ID (etc.) emitted = read_emitted(device) # what's on the air -> EM4100 ID (etc.)
if config is None and emitted is None: # nothing named decoded off the air -> try the physical-layer fallback (NRZ/biphase) so a tag
# no credential decoder handles still yields its raw bits rather than reading as "nothing".
raw = None
if emitted is None:
data = _capture(device, lambda: _try(lambda: device.lf.read(samples=_SAMPLE_COUNT), None))
raw = demod_raw(data) if data else None
if config is None and emitted is None and raw is None:
return None return None
chip = None chip = None
@@ -172,15 +244,21 @@ def identify_lf(device, emit=None) -> dict | None:
f" -> {emulating}") f" -> {emulating}")
if emitted is not None: if emitted is not None:
emit(f" emitted: {credential_label(emitted)}") emit(f" emitted: {credential_label(emitted)}")
elif raw is not None:
emit(f" emitted (raw, no format decoded): {raw_label(raw)}")
# label: name the chip and what it's actually emitting (the reliable, decoded credential); # 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. # fall back to the raw physical dump, then the config's emulation guess.
if chip and emitted: if chip and emitted:
label = f"{chip} ({credential_label(emitted)})" label = f"{chip} ({credential_label(emitted)})"
elif chip and raw:
label = f"{chip} ({raw_label(raw)})"
elif chip: elif chip:
label = f"{chip} ({emulating})" label = f"{chip} ({emulating})"
else: elif emitted:
label = credential_label(emitted) label = credential_label(emitted)
else:
label = raw_label(raw)
return { return {
"found": True, "found": True,
@@ -190,5 +268,6 @@ def identify_lf(device, emit=None) -> dict | None:
"config": config["config_hex"] if config else None, "config": config["config_hex"] if config else None,
"emulating": emulating, "emulating": emulating,
"emitted": emitted, "emitted": emitted,
"raw": raw,
"label": label, "label": label,
} }

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 dsp, fsk, demod_samples, read_config, identify_lf
from pm3py.lf import protocols from pm3py.lf import protocols
from pm3py.lf import capture
from pm3py.transponders.lf.em.em4100 import EM4100Tag from pm3py.transponders.lf.em.em4100 import EM4100Tag
from pm3py.transponders.lf.hid.hid import HIDProxTag from pm3py.transponders.lf.hid.hid import HIDProxTag
from pm3py.transponders.lf.atmel.t5577 import T5577Config, CONFIG_EM4100 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) 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: class TestCRC16:
def test_known_vectors(self): def test_known_vectors(self):
# "123456789" -> CRC-16/XMODEM 0x31C3, CRC-16/KERMIT 0x2189 (anchors the CRC impl) # "123456789" -> CRC-16/XMODEM 0x31C3, CRC-16/KERMIT 0x2189 (anchors the CRC impl)