Files
pm3py/pm3py/lf/fsk.py
michael e4a3aec46f feat(lf): FSK stack + HID Prox demod (faithful lfdemod.c port)
New pm3py/lf/fsk.py — a function-for-function Python port of the Proxmark FSK
stack (firmware/common/lfdemod.c): fsk_wave_demod (count samples between 0->1
transitions: short wave = fc/8, long = fc/10) + aggregate_bits (runs -> clock-
resolution bits) = fskdemod, plus preamble_search, HIDdemodFSK, detectAWID hooks.
Ported to track the C reference (and thus real-tag output), not reinvented.

protocols.decode_hid: FSK2a + Manchester -> Wiegand. Format is told by frame
structure, not parity alone — the 26-bit H10301 wire frame carries a 0x20 header
at bit 37 + a sentinel 1 at bit 26 (per firmware add_HID_preamble); the 37-bit
H10304 frame is header-less (37-bit Manchester count, no 0x20). Each candidate is
still parity-validated by re-encoding through the shipped HIDProxTag, so a false
alignment can't slip through. Added to the identify decoder chain after EM4100.

Synthetic HID tests build the real add_HID_preamble framing (header+sentinel for
26-bit, bare 37-bit) and render fc/8/fc/10 square waves, so a green test means the
demod inverts what a real HID card / PM3 sim emits. Full suite 1232 green.

AWID (96-bit) framing decode + PSK/Indala + biphase/FDX-B queued next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 14:14:54 -07:00

208 lines
7.4 KiB
Python

"""FSK demodulation — a faithful Python port of the Proxmark FSK stack.
Ported function-for-function from ``firmware/common/lfdemod.c`` (``fsk_wave_demod`` +
``aggregate_bits`` = ``fskdemod``, ``preambleSearch``, ``HIDdemodFSK``, ``detectAWID``) so it
tracks the C reference — and therefore real-tag output — rather than being a fresh reinvention.
The C thresholds against the signal mean and counts samples between 0->1 transitions: a short
wave (~fc/8) is one symbol, a long wave (~fc/10) the other; ``aggregate_bits`` then collapses
runs of like waves into clock-resolution bits. HID/AWID are FSK2a (fc/10 high, fc/8 low, RF/50).
Everything downstream (Wiegand fields, parity) is validated by the shipped ``HIDProxTag`` model.
"""
from __future__ import annotations
# lfdemod.h NOISE_AMPLITUDE_THRESHOLD — below this peak-to-mean swing the capture is "noise".
# Conservative; a real hardware capture tunes this alongside the ASK path.
NOISE_AMPLITUDE_THRESHOLD = 8
def _mean(samples) -> float:
return sum(samples) / len(samples) if samples else 0.0
def is_noise(samples) -> bool:
"""Port of signalprop.isnoise: too little amplitude above the mean to carry data."""
if len(samples) < 64:
return True
return (max(samples) - _mean(samples)) < NOISE_AMPLITUDE_THRESHOLD
def find_mod_start(src, mean: float, exp_wave: int) -> int:
"""Port of ``findModStart`` — skip leading noise/slow chip start-up to the first clean wave."""
n = len(src)
if n < 21:
return 0
i = 1
wave_cnt = 0
thresh_cnt = 0
above = src[0] >= mean
while i < n - 20:
if src[i] < mean and above:
thresh_cnt += 1
if thresh_cnt > 2 and wave_cnt < exp_wave + 1:
break
above = False
wave_cnt = 0
elif src[i] >= mean and not above:
thresh_cnt += 1
if thresh_cnt > 2 and wave_cnt < exp_wave + 1:
break
above = True
wave_cnt = 0
else:
wave_cnt += 1
if thresh_cnt > 10:
break
i += 1
return i
def fsk_wave_demod(samples, fchigh: int = 10, fclow: int = 8):
"""Port of ``fsk_wave_demod``: envelope -> one bit per FSK wave (1 = short/fc-low wave,
0 = long/fc-high wave), counting samples between consecutive 0->1 transitions. Returns
``(bits, start_idx)``."""
n = len(samples)
if n < 1024:
return [], 0
mean = _mean(samples)
thr = [0 if s < mean else 1 for s in samples]
idx = find_mod_start(samples, mean, fchigh)
last_transition = idx
idx += 1
out: list[int] = []
start_idx = 0
pre_last = last = curr = 0
while idx < n - 20:
if thr[idx - 1] < thr[idx]: # 0 -> 1 transition
pre_last, last, curr = last, curr, idx - last_transition
if curr < fclow - 2:
pass # garbage noise
elif curr < fchigh - 1: # short wave -> 1
if len(out) > 1 and last > fchigh - 2 and pre_last < fchigh - 1:
out[-1] = 1 # fix a 9 surrounded by 8s
out.append(1)
if out and start_idx == 0:
start_idx = idx - fclow
elif curr > fchigh + 1 and len(out) < 3:
out = [] # leading garbage -> reset
elif curr == fclow + 1 and last == fclow - 1: # 7 then 9 -> two 8s
out.append(1)
if out and start_idx == 0:
start_idx = idx - fclow
else: # long wave -> 0
out.append(0)
if out and start_idx == 0:
start_idx = idx - fchigh
last_transition = idx
idx += 1
return out, start_idx
def aggregate_bits(bits, clk: int, invert: int, fchigh: int, fclow: int):
"""Port of ``aggregate_bits``: collapse runs of like waves into clock-resolution data bits
(a run of n waves at field-clock fc spans n*fc/clk bits)."""
if not bits:
return []
out: list[int] = []
lastval = bits[0]
n = 1
hclk = clk // 2
i = 1
for i in range(1, len(bits)):
n += 1
if bits[i] == lastval:
continue
if bits[i - 1] == 1:
n = (n * fclow + hclk) // clk
else:
n = (n * fchigh + hclk) // clk
if n == 0:
n = 1
out.extend([bits[i - 1] ^ invert] * n)
n = 0
lastval = bits[i]
if n > clk // fchigh and len(bits) >= 2: # trailing same-frequency run
if bits[i - 1] == 1:
n = (n * fclow + hclk) // clk
else:
n = (n * fchigh + hclk) // clk
out.extend([bits[-1] ^ invert] * n)
return out
def fskdemod(samples, rflen: int = 50, invert: int = 1, fchigh: int = 10, fclow: int = 8):
"""Full FSK demod: envelope -> decoded 1s/0s (``fsk_wave_demod`` + ``aggregate_bits``)."""
if is_noise(samples):
return []
waves, _ = fsk_wave_demod(list(samples), fchigh, fclow)
return aggregate_bits(waves, rflen, invert, fchigh, fclow)
def preamble_search(bits, preamble):
"""Port of ``preambleSearch``: find ``preamble`` in ``bits``. Returns ``(found, start_idx,
size)`` where ``size`` is the gap to the second occurrence (the frame length) when there is
one, else the remaining length."""
plen = len(preamble)
size = len(bits)
if size <= plen:
return (False, 0, size)
found = 0
start = 0
for idx in range(size - plen):
if bits[idx:idx + plen] == preamble:
found += 1
if found == 1:
start = idx
elif found == 2:
return (True, start, idx - start)
return (found > 0, start, size)
# HID: 00011101 start-of-frame, then Manchester (10 -> 1, 01 -> 0)
HID_PREAMBLE = [0, 0, 0, 1, 1, 1, 0, 1]
# AWID: 00000001 preamble, 96-bit frame
AWID_PREAMBLE = [0, 0, 0, 0, 0, 0, 0, 1]
_M32 = 0xFFFFFFFF
def hid_demod_fsk(samples):
"""Port of ``HIDdemodFSK``: FSK2a demod + preamble + Manchester -> ``(hi2, hi, lo, nbits)``
96-bit shift register plus the number of data bits Manchester-decoded (needed to tell the
header-less 37-bit format from the 26-bit one), or ``None``. Field extraction is the caller's."""
bits = fskdemod(samples, 50, 1, 10, 8)
if len(bits) < 96 * 2:
return None
found, start, size = preamble_search(bits, HID_PREAMBLE)
if not found:
return None
num_start = start + len(HID_PREAMBLE)
hi2 = hi = lo = 0
nbits = 0
idx = num_start
while (idx - num_start) < size - len(HID_PREAMBLE) and idx + 1 < len(bits):
a, b = bits[idx], bits[idx + 1]
if a == b:
break # not Manchester -> end of frame
hi2 = ((hi2 << 1) | (hi >> 31)) & _M32
hi = ((hi << 1) | (lo >> 31)) & _M32
lo = ((lo << 1) | (1 if (a and not b) else 0)) & _M32
nbits += 1
idx += 2
if hi == 0 and lo == 0:
return None
return (hi2, hi, lo, nbits)
def awid_demod_fsk(samples):
"""Port of ``detectAWID``: FSK2a demod + 00000001 preamble -> the 96 frame bits, or ``None``."""
bits = fskdemod(samples, 50, 1, 10, 8)
if len(bits) < 96:
return None
found, start, size = preamble_search(bits, AWID_PREAMBLE)
if not found or size != 96:
return None
return bits[start:start + 96]