feat(lf): faithful ASK demod (cleanAskRawDemod) for real-world envelopes
Real LF captures are asymmetric — sharp load-mod fall, slow RC recovery ramp, often saturated rails — and a naive threshold + fixed-step resample drifts and bit-slips across a frame (validated against a real T5577 dump: header aligned but 7/10 parity rows failed). New pm3py/lf/ask.py ports the Proxmark ASK stack (lfdemod.c): - signal_props (computeSignalProperties): rails + robust middle-80% mean. - get_hilo (getHiLo 75%): clip thresholds 75% of the way mean->rail, giving hysteresis. - clean_ask_raw_demod (cleanAskRawDemod): measure rail-to-rail wave widths, quantise each to a full- or half-clock symbol so the ramp between rails never triggers a false edge and every wave re-syncs the clock. - trim_to_signal: drop the leading field-settle transient + trailing unfilled zeros that otherwise wreck thresholding/clock detection. protocols._manchester_bits now delegates here, so EM4100 + T55xx-config demod get the robust path over every raw-invert/Manchester-phase/polarity alignment (framing still validates each candidate). Synthetic round-trips still green; the earlier blank/undecodable T5577 correctly stays None (the C client couldn't read it either). Real decodable-tag confirmation (EM4100 4C007B2FD5) pending the hardware dump. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
129
pm3py/lf/ask.py
Normal file
129
pm3py/lf/ask.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""ASK demodulation — a faithful port of the Proxmark ASK stack (firmware/common/lfdemod.c).
|
||||
|
||||
Real LF envelopes are asymmetric: the tag load-modulation gives a sharp fall but the antenna
|
||||
recovers on a slow RC ramp, and heavily-coupled tags saturate the rails. A naive threshold +
|
||||
fixed-step resample drifts and bit-slips on that. The C client instead:
|
||||
|
||||
1. computeSignalProperties -> high/low/mean (robust middle mean).
|
||||
2. getHiLo(75) -> clip thresholds 75% of the way mean->rail (25% clip), giving hysteresis.
|
||||
3. cleanAskRawDemod -> walk rail-to-rail waves, quantising each to a full- or half-clock symbol
|
||||
(so the ramp between rails never triggers a false edge, and every wave re-syncs the clock).
|
||||
4. manrawdecode -> Manchester the raw stream to data bits.
|
||||
|
||||
This module ports 1-4. Framing (EM4100 header/parity, T55xx config) stays in protocols.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import dsp
|
||||
|
||||
_CLOCKS = (8, 16, 32, 40, 50, 64, 100, 128)
|
||||
|
||||
|
||||
def trim_to_signal(samples, win: int = 256, thresh: int = 40):
|
||||
"""Drop the leading field-settle transient (low-swing) and trailing unfilled zeros — the
|
||||
parts of a live capture that carry no modulation and would poison thresholding/clocking."""
|
||||
n = len(samples)
|
||||
start = 0
|
||||
for i in range(0, max(1, n - win), 32):
|
||||
w = samples[i:i + win]
|
||||
if w and max(w) - min(w) > thresh:
|
||||
start = i
|
||||
break
|
||||
end = n
|
||||
while end > start and samples[end - 1] == 0:
|
||||
end -= 1
|
||||
return samples[start:end]
|
||||
|
||||
|
||||
def signal_props(samples):
|
||||
"""Port of computeSignalProperties: (high, low, mean) where high/low are the rails and mean
|
||||
is the robust mean of the middle 80% (ignoring the top/bottom 10% so rails don't skew it)."""
|
||||
if not samples:
|
||||
return 0, 0, 0.0
|
||||
s = sorted(samples)
|
||||
n = len(s)
|
||||
lo10 = s[int(n * 0.1)]
|
||||
hi90 = s[int(n * 0.9)]
|
||||
mid = [v for v in samples if lo10 <= v <= hi90]
|
||||
mean = (sum(mid) / len(mid)) if mid else (sum(samples) / n)
|
||||
return max(samples), min(samples), mean
|
||||
|
||||
|
||||
def get_hilo(high, low, mean, pct: int = 75):
|
||||
"""Port of getHiLo: clip the thresholds ``pct``% of the way from the mean to each rail (the
|
||||
default 25% clip), so partially-unclipped waves still register as high/low."""
|
||||
clip_hi = mean + (high - mean) * pct / 100.0
|
||||
clip_lo = mean - (mean - low) * pct / 100.0
|
||||
return clip_hi, clip_lo
|
||||
|
||||
|
||||
def detect_clk(samples) -> int:
|
||||
"""Full-bit clock (samples per data bit), snapped to the T55xx rate set. ``dsp.detect_clock``
|
||||
finds the shortest symbol (a Manchester half-bit); the data-bit clock is twice that."""
|
||||
half = dsp.detect_clock(dsp.binarize(samples))
|
||||
if not half:
|
||||
return 0
|
||||
return min(_CLOCKS, key=lambda c: abs(c - 2 * half))
|
||||
|
||||
|
||||
def clean_ask_raw_demod(samples, clk: int, high: float, low: float, invert: int = 0):
|
||||
"""Port of ``cleanAskRawDemod``: envelope -> raw half-bit stream by measuring rail-to-rail
|
||||
wave widths (full clock -> 2 like symbols, half clock -> 1), ignoring the ramp between rails.
|
||||
Phase errors (over-long waves) become ``7`` markers."""
|
||||
out: list[int] = []
|
||||
n = len(samples)
|
||||
cl_4, cl_2 = clk // 4, clk // 2
|
||||
wave_high = True
|
||||
pos = 0
|
||||
while pos < n and samples[pos] < high: # getNextHigh
|
||||
pos += 1
|
||||
smpl = 1
|
||||
if cl_2 - cl_4 - 1 < pos <= clk + cl_4 + 1: # do not skip the first transition
|
||||
out.append(invert ^ 1)
|
||||
for i in range(pos, n):
|
||||
v = samples[i]
|
||||
if v >= high and wave_high:
|
||||
smpl += 1
|
||||
elif v <= low and not wave_high:
|
||||
smpl += 1
|
||||
elif (v >= high and not wave_high) or (v <= low and wave_high):
|
||||
if smpl > clk - cl_4 - 1: # full-clock wave -> two like symbols
|
||||
if smpl > clk + cl_4 + 1:
|
||||
out.append(7)
|
||||
elif wave_high:
|
||||
out += [invert, invert]
|
||||
else:
|
||||
out += [invert ^ 1, invert ^ 1]
|
||||
wave_high = not wave_high
|
||||
smpl = 0
|
||||
elif smpl > cl_2 - cl_4 - 1: # half-clock wave -> one symbol
|
||||
if smpl > cl_2 + cl_4 + 1:
|
||||
out.append(7)
|
||||
out.append(invert if wave_high else invert ^ 1)
|
||||
wave_high = not wave_high
|
||||
smpl = 0
|
||||
else:
|
||||
smpl += 1
|
||||
else:
|
||||
smpl += 1 # in the ramp — haven't reached a rail yet
|
||||
return out
|
||||
|
||||
|
||||
def manchester_bits(samples):
|
||||
"""Yield candidate data-bit streams for an ASK/Manchester envelope, over every raw-invert and
|
||||
Manchester phase/polarity alignment — the caller keeps whichever frames + validates cleanly.
|
||||
Empty if the capture is noise or won't clock."""
|
||||
core = trim_to_signal(samples)
|
||||
if len(core) < 64 or dsp.is_flat(core):
|
||||
return
|
||||
high, low, mean = signal_props(core)
|
||||
clip_hi, clip_lo = get_hilo(high, low, mean)
|
||||
clk = detect_clk(core)
|
||||
if clk < 8:
|
||||
return
|
||||
for cinv in (0, 1):
|
||||
raw = clean_ask_raw_demod(core, clk, clip_hi, clip_lo, cinv)
|
||||
raw = [b for b in raw if b != 7] # drop phase-error markers before pairing
|
||||
for phase in (0, 1):
|
||||
for minv in (False, True):
|
||||
yield dsp.manchester_decode(raw, phase, minv)
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
from . import dsp
|
||||
from . import fsk
|
||||
from . import ask
|
||||
# Import the models through the ``pm3py.sim`` package (not their deep module paths): sim's
|
||||
# __init__ re-exports them, and going through it forces sim to fully initialize first, avoiding
|
||||
# a circular import (em4100 -> pm3py.sim.frame -> sim.__init__ -> em4100) when pm3py.lf is the
|
||||
@@ -20,18 +21,10 @@ _PRESET_BY_WORD = {word: name for name, word in T5577Config._PRESETS.items()}
|
||||
|
||||
|
||||
def _manchester_bits(samples):
|
||||
"""Envelope -> (data-bit stream, half-bit width). Yields each (phase, polarity) alignment's
|
||||
bit list so a caller can pick whichever frames cleanly. Empty if the capture can't clock."""
|
||||
if dsp.is_flat(samples):
|
||||
return
|
||||
binary = dsp.binarize(samples)
|
||||
half = dsp.detect_clock(binary)
|
||||
if not half:
|
||||
return
|
||||
halfbits = dsp.resample(binary, half)
|
||||
for phase in (0, 1):
|
||||
for invert in (False, True):
|
||||
yield dsp.manchester_decode(halfbits, phase, invert)
|
||||
"""Yield candidate data-bit streams for an ASK/Manchester envelope. Delegates to the faithful
|
||||
C ASK port (:mod:`pm3py.lf.ask`) — hysteresis rail-to-rail demod that survives the asymmetric,
|
||||
ramped, sometimes-saturated shape of real LF captures, where a naive resample bit-slips."""
|
||||
yield from ask.manchester_bits(samples)
|
||||
|
||||
|
||||
def decode_em4100(samples) -> dict | None:
|
||||
|
||||
Reference in New Issue
Block a user