From 8833753c45c47121874e6457ad2c69179823d6b6 Mon Sep 17 00:00:00 2001 From: michael Date: Thu, 16 Jul 2026 17:59:14 -0700 Subject: [PATCH] fix(lf): detect_clock keys off the shortest well-supported interval, not the min MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detect_clock estimated the bit clock from the *minimum* edge interval, so a few stray sub-bit edges (which every real ADC capture carries) collapsed the estimate. On a strongly-coupled, rail-clipping EM4100 the true half-bit of 32 dropped to ~3, snapping the data clock to 8 and turning the ASK demod into all phase-error markers — no config, no credential decoded. Base the symbol on the shortest interval with real support (>=20% of the modal count) instead of the raw minimum. Verified: clean synthetic still reads 32; the hardware capture that broke it now clocks correctly. Live on a T5577 emulating EM4100 00FFFFFFFF: DETECT decodes config 00148040, identify returns the credential. Regression test reproduces the exact failure (sparse stray edges): old estimator 3.67 (dead demod), new 31.99. Co-Authored-By: Claude Opus 4.8 --- pm3py/lf/dsp.py | 21 +++++++++++++++------ tests/test_lf_demod.py | 13 +++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/pm3py/lf/dsp.py b/pm3py/lf/dsp.py index a6da2f9..f51fc0d 100644 --- a/pm3py/lf/dsp.py +++ b/pm3py/lf/dsp.py @@ -55,23 +55,32 @@ def _edges(binary: list[int]) -> list[int]: return [i for i in range(1, len(binary)) if binary[i] != binary[i - 1]] -def detect_clock(binary: list[int], min_run: int = 3) -> float | None: +def detect_clock(binary: list[int], min_run: int = 3, support: float = 0.2) -> float | None: """Estimate the shortest symbol width (samples) from the edge-interval distribution. For Manchester this is the *half-bit* (there is always a mid-bit transition, so runs are one or two half-bits); for plain OOK it is the bit itself. Runs shorter than ``min_run`` are treated as noise/jitter and ignored. Returns the mean of the shortest interval cluster, - or ``None`` when there aren't enough clean edges to decide.""" + or ``None`` when there aren't enough clean edges to decide. + + The base symbol is the shortest interval with *real support* — a count at least ``support``× + the most-common interval's count — not the raw minimum. A real ADC capture always carries a + few stray sub-bit edges; keying off the bare minimum let a single 3-sample outlier collapse a + clean RF/64 tag's estimate from 32 to ~3 (hardware-observed on a strongly-coupled EM4100), + snapping the data clock to 8 and killing the demod. The dominant interval is robust to that.""" edges = _edges(binary) if len(edges) < 4: return None intervals = [edges[i] - edges[i - 1] for i in range(1, len(edges)) if edges[i] - edges[i - 1] >= min_run] if len(intervals) < 3: return None - intervals.sort() - base = intervals[0] - # average the cluster within 1.5x of the shortest interval -> the fundamental symbol width - cluster = [d for d in intervals if d <= base * 1.5] + counts: dict[int, int] = {} + for d in intervals: + counts[d] = counts.get(d, 0) + 1 + floor = max(counts.values()) * support + base = min(d for d, n in counts.items() if n >= floor) # shortest well-supported interval + # average the cluster around the base (0.75x–1.5x) -> the fundamental symbol width + cluster = [d for d in intervals if base * 0.75 <= d <= base * 1.5] return sum(cluster) / len(cluster) diff --git a/tests/test_lf_demod.py b/tests/test_lf_demod.py index cfb4f2c..cb4c45a 100644 --- a/tests/test_lf_demod.py +++ b/tests/test_lf_demod.py @@ -88,6 +88,19 @@ class TestDSP: half = dsp.detect_clock(dsp.binarize(env)) assert half is not None and abs(half - 32) < 4 # RF/64 -> 32-sample half-bit + def test_clock_survives_sparse_stray_edges(self): + # hardware regression: a strongly-coupled EM4100 gives a clean RF/64 signal (intervals + # dominated by 32/64) with a few sub-bit stray edges from ADC ripple. The old min-based + # estimator keyed off the bare shortest interval and collapsed to ~3 (-> data clock 8 -> + # dead demod). The dominant interval is still 32, so detect_clock must ride through it. + env = bytearray(_em4100_env(0x0102030405, rf=64, high=255, low=0)) + for i in range(200, len(env), 900): # sparse single-sample spikes + env[i] = 0 if env[i] > 128 else 255 + half = dsp.detect_clock(dsp.binarize(bytes(env))) + assert half is not None and abs(half - 32) < 4 # not dragged down to the ~3 outlier + r = protocols.decode_em4100(bytes(env)) + assert r and r["id_hex"] == "0102030405" # end-to-end decode survives + def test_manchester_roundtrip(self): # (1,0)->1, (0,1)->0 at phase 0 assert dsp.manchester_decode([1, 0, 0, 1, 1, 0]) == [1, 0, 1]