feat(lf): AWID FSK decode (parity-validated Wiegand)

protocols.decode_awid: FSK2a (no Manchester) via the ported detectAWID path, then
removeParity — the 88 bits after the preamble are 22 groups of 3 data + 1 odd-parity
bit, so stripping parity gives 66 bits whose first byte is the format length
(26/34/36/37/50) and the rest the facility/card fields (cmdlfawid.c index map). The 22
parity checks stand in for AWID's missing CRC, so it can't false-positive. Added to the
identify decoder chain.

Tests: AWID-26 round-trip across fc/card, parity gate rejects noise/EM4100. Full suite green.

LF stack now covers EM4100, HID, AWID, FDX-B + T55xx config. Indala (PSK) queued — the
one path needing real PSK phase-tracking (DetectPSKClock/pskRawDemod), best tuned against
a hardware capture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-14 15:58:38 -07:00
parent 7e8d924045
commit 42d5f800cb
3 changed files with 94 additions and 0 deletions

View File

@@ -17,6 +17,7 @@ _SAMPLE_COUNT = 15000
_DECODERS = (
protocols.decode_em4100,
protocols.decode_hid,
protocols.decode_awid,
protocols.decode_fdxb,
)

View File

@@ -103,6 +103,58 @@ def decode_hid(samples) -> dict | None:
return None
# AWID format length -> (facility offset/width, card offset/width) in the 66 de-parity'd bits
# (cmdlfawid.c index map). fmtLen is bits[0:8]; bit 8 is a Wiegand parity.
_AWID_FORMATS = {
26: ((9, 8), (17, 16)),
34: ((9, 8), (17, 24)),
36: ((14, 11), (25, 18)),
37: ((9, 13), (22, 18)),
50: ((9, 16), (25, 32)),
}
def _strip_parity(bits, group: int = 4, want_odd: bool = True):
"""Port of ``removeParity`` for AWID: each ``group`` bits are (group-1) data + 1 parity;
verify the parity and keep the data. Returns the compacted bits, or ``None`` on a parity
fault (the check that makes AWID trustworthy without a CRC)."""
out = []
for i in range(0, len(bits) - group + 1, group):
g = bits[i:i + group]
if (sum(g) & 1) != (1 if want_odd else 0):
return None
out.extend(g[:group - 1])
return out
def decode_awid(samples) -> dict | None:
"""Demodulate an AWID envelope (FSK2a, no Manchester) to its Wiegand credential.
``detectAWID`` gives the 96 frame bits; the 88 after the preamble are 22 groups of 3 data +
1 odd-parity bit, so stripping parity yields 66 bits whose first byte is the format length
(26/34/36/37/50). The 22 parity checks stand in for AWID's missing CRC — a bad frame fails
them — so this doesn't false-positive."""
frame = fsk.awid_demod_fsk(samples)
if frame is None:
return None
data = _strip_parity(frame[8:8 + 88], group=4, want_odd=True)
if data is None or len(data) != 66:
return None
fmt_len = dsp.bits_to_int(data[0:8])
spec = _AWID_FORMATS.get(fmt_len)
if spec is None:
return None
(fc_off, fc_w), (cn_off, cn_w) = spec
fc = dsp.bits_to_int(data[fc_off:fc_off + fc_w])
cardnum = dsp.bits_to_int(data[cn_off:cn_off + cn_w])
return {
"protocol": "AWID",
"format": f"AWID-{fmt_len}",
"facility_code": fc,
"card_number": cardnum,
}
def _reflect(v: int, width: int) -> int:
r = 0
for i in range(width):

View File

@@ -164,6 +164,47 @@ class TestHID:
assert r["protocol"] == "HID Prox" and r["card_number"] == 9999
# --- AWID (FSK, no Manchester) ----------------------------------------------------------------
def _awid_env(fc, cn, fmt_len=26, repeats=4, **kw):
"""A faithful AWID envelope: 66-bit Wiegand (fmtLen + fc + card) re-parity'd into 88 bits
(3 data + 1 odd parity per group), preambled, FSK2a-modulated (one symbol per bit)."""
(fc_off, fc_w), (cn_off, cn_w) = protocols._AWID_FORMATS[fmt_len]
data = [0] * 66
for k in range(8):
data[k] = (fmt_len >> (7 - k)) & 1
for k in range(fc_w):
data[fc_off + k] = (fc >> (fc_w - 1 - k)) & 1
for k in range(cn_w):
data[cn_off + k] = (cn >> (cn_w - 1 - k)) & 1
parity88 = [] # re-insert odd parity every 3 bits
for i in range(0, 66, 3):
g = data[i:i + 3]
parity88 += g + [1 - (sum(g) & 1)] # odd parity of the 3 data bits
frame = fsk.AWID_PREAMBLE + parity88 # 8 + 88 = 96 bits
return _fsk_env(frame, repeats=repeats, **kw)
class TestAWID:
def test_awid26_roundtrip(self):
r = protocols.decode_awid(_awid_env(12, 34567))
assert r and r["protocol"] == "AWID" and r["format"] == "AWID-26"
assert r["facility_code"] == 12 and r["card_number"] == 34567
def test_awid_various(self):
for fc, cn in ((0, 1), (255, 65535), (200, 9999)):
r = protocols.decode_awid(_awid_env(fc, cn))
assert r and r["facility_code"] == fc and r["card_number"] == cn, f"{fc}/{cn}"
def test_parity_gate_rejects(self):
assert protocols.decode_awid(bytes([128] * 6000)) is None
assert protocols.decode_awid(_em4100_env(0x0102030405)) is None
def test_demod_samples_picks_awid(self):
r = demod_samples(_awid_env(7, 4242))
assert r["protocol"] == "AWID" and r["card_number"] == 4242
# --- FDX-B (biphase) --------------------------------------------------------------------------
def _fdxb_bits(country, national, animal=0):