From 81625a0e9334d9e6b4721801cd16ae49b85c8190 Mon Sep 17 00:00:00 2001 From: michael Date: Tue, 14 Jul 2026 16:46:41 -0700 Subject: [PATCH] feat(lf): faithful ASK demod (cleanAskRawDemod) for real-world envelopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pm3py/lf/ask.py | 129 ++++++++++++++++++++++++++++++++++++++++++ pm3py/lf/protocols.py | 17 ++---- 2 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 pm3py/lf/ask.py diff --git a/pm3py/lf/ask.py b/pm3py/lf/ask.py new file mode 100644 index 0000000..13bb38d --- /dev/null +++ b/pm3py/lf/ask.py @@ -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) diff --git a/pm3py/lf/protocols.py b/pm3py/lf/protocols.py index 898f04e..5a02381 100644 --- a/pm3py/lf/protocols.py +++ b/pm3py/lf/protocols.py @@ -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: