feat(lf): Python LF demod stack — ASK/EM4100 + T55xx config; wire into identify
The firmware returns raw ADC envelope samples for LF reads (T55xx readbl replies PM3_SUCCESS with NULL data); demodulation was always the C client's job, so pm3py had none and LF identify could never see a tag. This adds the missing DSP stack in Python — new pm3py.lf package: - dsp.py: modulation-agnostic primitives — robust threshold, edge-interval clock recovery, ASK binarize, half-bit resample, Manchester + biphase decode. - protocols.py: EM4100 (ASK/Manchester -> 40-bit ID, validated by EM4100Code's own header/row/col/stop parity checks) and best-effort T55xx block-0 config read (find the repeating 32-bit word, score by T5577Config sanity, name the emulation). - capture.py: live-device orchestration — read the emitted stream + probe a T55xx via block-0 read, combine into "is it a T5577, and what is it (emulating)?". rawcli identify now demodulates LF instead of the dead readbl-only probe: reports "T5577 (EM4100 / EM4102)" for an emulator, "EM4100 <id>" for a bare credential. Fully self-testing without hardware: the LF transponder models already encode these protocols, so a synthetic envelope built from an encoder round-trips through the demod. 17 demod tests (EM4100 across IDs/rates/mid-frame/noise, T55xx config, mocked identify) + updated rawcli LF tests. Full suite 1225 green. FSK/HID + PSK/Indala + biphase/FDX-B decoders queued (same dsp primitives). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -178,8 +178,10 @@ def identify(session, emit=None) -> dict:
|
||||
return {"found": True, "field": "hf", "protocol": "15693",
|
||||
"transponder": "ISO15693", "uid": uid15}
|
||||
|
||||
# LF: probe for a T5577 by reading its config block (block 0). A valid config word confirms
|
||||
# a T5577 and — decoded — tells us what it's emulating. lf.search() can't do this (no demod).
|
||||
# LF: no HF tag, so demodulate the field. pm3py.lf captures the envelope and decodes it —
|
||||
# what the tag emits (EM4100 ID, …) and, via a block-0 read, whether it's actually a T5577
|
||||
# and what it's programmed to emulate. This is the real DSP path; the firmware only hands
|
||||
# back raw samples, so there's no shortcut (lf.search() can't demodulate).
|
||||
lf = _identify_lf(dev, session, emit)
|
||||
if lf:
|
||||
return lf
|
||||
@@ -187,56 +189,19 @@ def identify(session, emit=None) -> dict:
|
||||
return {"found": False}
|
||||
|
||||
|
||||
# Known T5577 config words → the tag it's set up to emulate (Proxmark3 config-block table).
|
||||
_T5577_EMULATION = {
|
||||
0x00148040: "EM4100 / EM4102",
|
||||
0x00107060: "HID Prox",
|
||||
0x00081040: "Indala",
|
||||
0x903F0082: "FDX-B",
|
||||
0x00088040: "Viking",
|
||||
0x000880E8: "raw / default",
|
||||
}
|
||||
|
||||
|
||||
def _infer_emulation(cfg) -> str:
|
||||
"""From a decoded T5577 config, name the emulated tag (exact word, else modulation family)."""
|
||||
exact = _T5577_EMULATION.get(int(cfg))
|
||||
if exact:
|
||||
return exact
|
||||
mod = cfg.modulation
|
||||
if not isinstance(mod, str):
|
||||
return "unknown"
|
||||
family = ("EM/ASK" if mod == "ASK" else "HID/AWID" if mod.startswith("FSK")
|
||||
else "Indala/PSK" if mod.startswith("PSK") else "FDX-B/Nedap" if "biphase" in mod
|
||||
else mod)
|
||||
return f"{family} · {mod} RF/{cfg.data_bit_rate}"
|
||||
|
||||
|
||||
def _identify_lf(dev, session, emit):
|
||||
"""Read T55xx block 0; if it decodes to a plausible config, report the T5577 and what it
|
||||
emulates. Tightly gated so no-tag reads don't false-positive."""
|
||||
r = _try(lambda: dev.lf.t55.readbl(0), None)
|
||||
if not isinstance(r, dict) or r.get("status") != 0:
|
||||
"""Adapter onto :func:`pm3py.lf.identify_lf` — run the LF demod and fold its result into the
|
||||
session + the rawcli result shape. ``label`` is the breadcrumb name (``T5577 (EM4100 /
|
||||
EM4102)`` for an emulator, ``EM4100 <id>`` for a bare credential)."""
|
||||
from pm3py.lf import identify_lf as _lf_identify
|
||||
lf = _try(lambda: _lf_identify(dev, emit), None)
|
||||
if not lf:
|
||||
return None
|
||||
raw = r.get("raw") or b""
|
||||
if len(raw) < 4:
|
||||
return None
|
||||
config = int.from_bytes(raw[:4], "big")
|
||||
if config in (0, 0xFFFFFFFF):
|
||||
return None
|
||||
try:
|
||||
from pm3py.sim import T5577Config
|
||||
cfg = T5577Config(config)
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(cfg.modulation, str) or not (1 <= cfg.max_block <= 8):
|
||||
return None # implausible → probably no T5577
|
||||
emit(f" T55xx block 0 = 0x{config:08X} "
|
||||
f"({cfg.modulation}, RF/{cfg.data_bit_rate}, {cfg.max_block} blocks)")
|
||||
session.field, session.protocol = "lf", "lf"
|
||||
session.transponder = f"T5577 ({_infer_emulation(cfg)})"
|
||||
session.transponder = lf["label"]
|
||||
return {"found": True, "field": "lf", "protocol": "lf",
|
||||
"transponder": session.transponder, "config": f"{config:08X}"}
|
||||
"transponder": lf["label"], "config": lf.get("config"),
|
||||
"id": (lf.get("emitted") or {}).get("id_hex")}
|
||||
|
||||
|
||||
def _probe_version(dev, fmt, emit) -> tuple[str | None, bytes | None]:
|
||||
@@ -273,6 +238,8 @@ def format_summary(result: dict) -> str:
|
||||
detail = f"UID {uid.hex().upper() if isinstance(uid, (bytes, bytearray)) else uid}"
|
||||
elif result.get("config"):
|
||||
detail = f"config 0x{result['config']}"
|
||||
elif result.get("id"):
|
||||
detail = f"id {result['id']}"
|
||||
else:
|
||||
detail = ""
|
||||
head = f"{result['transponder']} ({result['field'].upper()} / {result['protocol']})"
|
||||
|
||||
22
pm3py/lf/__init__.py
Normal file
22
pm3py/lf/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Low-frequency signal demodulation.
|
||||
|
||||
The Proxmark firmware returns *raw ADC envelope samples* for LF reads — the actual
|
||||
demodulation (ASK/FSK/PSK, Manchester/biphase, protocol framing) is the client's job. The C
|
||||
client has a large DSP stack for this; pm3py historically had none, so LF ``identify`` could
|
||||
never see a tag. This package is that missing stack, in Python.
|
||||
|
||||
Layers:
|
||||
|
||||
* :mod:`pm3py.lf.dsp` — modulation-agnostic primitives (threshold, clock recovery, ASK
|
||||
binarize, Manchester/biphase decode) that turn an envelope into a bit stream.
|
||||
* :mod:`pm3py.lf.protocols` — per-tag framing (EM4100, T55xx config, HID, ...) that turns a
|
||||
bit stream into a decoded credential, reusing the transponder models' own validators.
|
||||
* :mod:`pm3py.lf.capture` — pull an envelope off a live device (or accept one for tests) and
|
||||
run every decoder, returning the best match.
|
||||
|
||||
Everything is testable without hardware: the LF transponder models already *encode* these
|
||||
protocols, so a synthetic envelope built from an encoder round-trips through the demod.
|
||||
"""
|
||||
from .capture import demod_samples, read_config, identify_lf
|
||||
|
||||
__all__ = ["demod_samples", "read_config", "identify_lf"]
|
||||
104
pm3py/lf/capture.py
Normal file
104
pm3py/lf/capture.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Live-device LF capture + identify.
|
||||
|
||||
``demod_samples`` is the hardware-free heart: an envelope in, a decoded credential out — every
|
||||
protocol decoder tried in turn. ``identify_lf`` drives a real device: it captures what the tag
|
||||
*emits* (a plain read) and, separately, probes for a T55xx by reading config block 0, then
|
||||
combines the two into "is it a T5577, and what is it (emulating)?".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import protocols
|
||||
|
||||
# how many envelope samples to pull from BigBuf after a capture (a T55xx/EM frame is a few
|
||||
# thousand samples at RF/64; this spans several repeats for a clean lock)
|
||||
_SAMPLE_COUNT = 15000
|
||||
|
||||
# every emitted-credential decoder, tried in order (headered/most-specific first)
|
||||
_DECODERS = (
|
||||
protocols.decode_em4100,
|
||||
)
|
||||
|
||||
|
||||
def demod_samples(samples) -> dict | None:
|
||||
"""Decode a raw LF envelope to a credential by trying each protocol demod in turn.
|
||||
|
||||
Hardware-free and the single point every decoder funnels through — feed it a synthetic
|
||||
envelope from a transponder encoder in tests, or a BigBuf download at runtime."""
|
||||
if not samples:
|
||||
return None
|
||||
for decode in _DECODERS:
|
||||
result = decode(samples)
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
def _download(device, count: int = _SAMPLE_COUNT) -> bytes:
|
||||
"""Pull the last capture's envelope out of the device BigBuf (best-effort -> b'')."""
|
||||
try:
|
||||
return device.lf.download_samples(count=count) or b""
|
||||
except Exception:
|
||||
return b""
|
||||
|
||||
|
||||
def read_emitted(device) -> dict | None:
|
||||
"""Capture and decode whatever the tag is emitting in the field (no downlink) — the EM4100
|
||||
ID a tag (native or T5577-emulated) streams continuously."""
|
||||
try:
|
||||
device.lf.read(samples=_SAMPLE_COUNT)
|
||||
except Exception:
|
||||
return None
|
||||
return demod_samples(_download(device))
|
||||
|
||||
|
||||
def read_config(device) -> dict | None:
|
||||
"""Probe for a T55xx by reading config block 0 and demodulating the response. A clean,
|
||||
sane config both proves the chip is a T5577 and says what it's programmed to emulate."""
|
||||
try:
|
||||
device.lf.t55.readbl(0) # fills BigBuf with the block-0 response envelope
|
||||
except Exception:
|
||||
return None
|
||||
return protocols.decode_t55xx_config(_download(device))
|
||||
|
||||
|
||||
def identify_lf(device, emit=None) -> dict | None:
|
||||
"""Full LF identify against a live device. Returns a result dict (``found``/``field``/
|
||||
``label``/``chip``/``config``/``emulating``/``emitted``) or ``None`` if the device is
|
||||
unusable. ``emit`` streams human-readable progress lines to the caller's trace."""
|
||||
emit = emit or (lambda _l: None)
|
||||
if device is None:
|
||||
return None
|
||||
|
||||
config = read_config(device) # T55xx? -> chip + emulation
|
||||
emitted = read_emitted(device) # what's on the air -> EM4100 ID (etc.)
|
||||
|
||||
if config is None and emitted is None:
|
||||
return None
|
||||
|
||||
chip = None
|
||||
emulating = None
|
||||
if config is not None:
|
||||
chip = "T5577"
|
||||
emulating = config["emulating"]
|
||||
emit(f" T55xx block 0 = 0x{config['config_hex']} "
|
||||
f"({config['modulation']}, RF/{config['data_bit_rate']}, {config['max_block']} blocks)"
|
||||
f" -> {emulating}")
|
||||
if emitted is not None:
|
||||
emit(f" emitted: {emitted['protocol']} id {emitted['id_hex']}")
|
||||
|
||||
# label: prefer the physical chip + what it emulates; else the emitted credential
|
||||
if chip:
|
||||
label = f"{chip} ({emulating})"
|
||||
else:
|
||||
label = f"{emitted['protocol']} {emitted['id_hex']}"
|
||||
|
||||
return {
|
||||
"found": True,
|
||||
"field": "lf",
|
||||
"protocol": "lf",
|
||||
"chip": chip,
|
||||
"config": config["config_hex"] if config else None,
|
||||
"emulating": emulating,
|
||||
"emitted": emitted,
|
||||
"label": label,
|
||||
}
|
||||
131
pm3py/lf/dsp.py
Normal file
131
pm3py/lf/dsp.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Modulation-agnostic LF DSP primitives.
|
||||
|
||||
Input everywhere is a Proxmark LF *envelope*: one unsigned byte (0-255) per carrier period
|
||||
(one "field clock"), as returned by ``lf.download_samples``. So a tag clocked at RF/64 spans
|
||||
64 samples per data bit, RF/32 spans 32, and so on — the RF/n divider *is* the samples-per-bit.
|
||||
|
||||
The functions here recover a bit stream from that envelope without knowing the protocol:
|
||||
threshold to binary, detect the bit clock, then slice/decode (Manchester or biphase). Framing
|
||||
(headers, parity, credential fields) lives in :mod:`pm3py.lf.protocols`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# T55xx standard data-bit-rate set (RF/n) — samples-per-bit candidates when the clock is
|
||||
# unknown. x-mode allows any even n; the reader still recovers those via the generic detector.
|
||||
RF_RATES = (8, 16, 32, 40, 50, 64, 100, 128)
|
||||
|
||||
|
||||
def _percentile(sorted_vals: list[int], frac: float) -> float:
|
||||
"""Value at ``frac`` (0..1) through an already-sorted list; robust to spikes."""
|
||||
if not sorted_vals:
|
||||
return 0.0
|
||||
idx = min(len(sorted_vals) - 1, max(0, round(frac * (len(sorted_vals) - 1))))
|
||||
return float(sorted_vals[idx])
|
||||
|
||||
|
||||
def threshold(samples) -> float:
|
||||
"""A robust high/low decision level for an ASK/OOK envelope.
|
||||
|
||||
The midpoint between the 5th and 95th percentiles, so a few saturated or dropped samples
|
||||
don't drag it (a plain min/max would, and the duty cycle isn't reliably 50% so the mean
|
||||
can bias). Returns 0.0 for an empty/flat capture."""
|
||||
if not samples:
|
||||
return 0.0
|
||||
s = sorted(samples)
|
||||
lo, hi = _percentile(s, 0.05), _percentile(s, 0.95)
|
||||
return (lo + hi) / 2.0
|
||||
|
||||
|
||||
def is_flat(samples, min_swing: int = 16) -> bool:
|
||||
"""True if the capture has too little dynamic range to carry data (no tag / dead field)."""
|
||||
if len(samples) < 8:
|
||||
return True
|
||||
s = sorted(samples)
|
||||
return (_percentile(s, 0.95) - _percentile(s, 0.05)) < min_swing
|
||||
|
||||
|
||||
def binarize(samples, level: float | None = None) -> list[int]:
|
||||
"""Envelope -> list of 0/1 by thresholding (ASK/OOK)."""
|
||||
if level is None:
|
||||
level = threshold(samples)
|
||||
return [1 if v > level else 0 for v in samples]
|
||||
|
||||
|
||||
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:
|
||||
"""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."""
|
||||
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]
|
||||
return sum(cluster) / len(cluster)
|
||||
|
||||
|
||||
def resample(binary: list[int], symbol: float, offset: float = 0.5) -> list[int]:
|
||||
"""Sample ``binary`` once per ``symbol``-wide window (majority vote), starting at
|
||||
``offset`` symbols in. Turns a per-sample stream into a per-symbol (e.g. per-half-bit)
|
||||
stream at the recovered clock."""
|
||||
if symbol <= 0:
|
||||
return []
|
||||
out: list[int] = []
|
||||
n = len(binary)
|
||||
pos = offset * symbol
|
||||
while pos < n:
|
||||
lo = max(0, int(pos - symbol / 4))
|
||||
hi = min(n, int(pos + symbol / 4) + 1)
|
||||
window = binary[lo:hi] or [binary[min(int(pos), n - 1)]]
|
||||
out.append(1 if sum(window) * 2 >= len(window) else 0)
|
||||
pos += symbol
|
||||
return out
|
||||
|
||||
|
||||
def manchester_decode(halfbits: list[int], phase: int = 0, invert: bool = False):
|
||||
"""Decode a half-bit stream as Manchester: each data bit is a pair of opposite half-bits.
|
||||
|
||||
``phase`` (0/1) picks the pairing alignment; ``invert`` swaps the polarity convention
|
||||
(``10`` -> 1 vs ``01`` -> 1). Pairs that are ``00``/``11`` are clock slips — returned as
|
||||
``None`` markers so callers can resync. Returns the decoded bit list (with ``None`` gaps).
|
||||
Callers typically try both phases/polarities and keep the alignment that frames cleanly."""
|
||||
bits: list[int | None] = []
|
||||
for i in range(phase, len(halfbits) - 1, 2):
|
||||
a, b = halfbits[i], halfbits[i + 1]
|
||||
if a == b:
|
||||
bits.append(None) # not a valid Manchester symbol -> clock slip
|
||||
else:
|
||||
bit = 1 if a == 1 else 0 # (1,0) -> 1, (0,1) -> 0
|
||||
bits.append(bit ^ 1 if invert else bit)
|
||||
return bits
|
||||
|
||||
|
||||
def biphase_decode(halfbits: list[int], phase: int = 0, invert: bool = False):
|
||||
"""Decode a half-bit stream as biphase (differential Manchester / FDX-B style): a data
|
||||
``0`` has a mid-bit transition (half-bits differ), a data ``1`` does not (half-bits equal).
|
||||
``phase`` aligns the pairing; ``invert`` swaps the 0/1 convention."""
|
||||
bits: list[int] = []
|
||||
for i in range(phase, len(halfbits) - 1, 2):
|
||||
differ = halfbits[i] != halfbits[i + 1]
|
||||
bit = 0 if differ else 1
|
||||
bits.append(bit ^ 1 if invert else bit)
|
||||
return bits
|
||||
|
||||
|
||||
def bits_to_int(bits: list[int]) -> int:
|
||||
"""MSB-first bit list -> integer."""
|
||||
v = 0
|
||||
for b in bits:
|
||||
v = (v << 1) | (b & 1)
|
||||
return v
|
||||
139
pm3py/lf/protocols.py
Normal file
139
pm3py/lf/protocols.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Per-tag LF framing: bit stream -> decoded credential.
|
||||
|
||||
Each decoder takes a raw envelope, runs the generic :mod:`pm3py.lf.dsp` pipeline (trying both
|
||||
Manchester phase/polarity alignments), and validates candidate frames with the *transponder
|
||||
model's own* checker — ``EM4100Code.valid``, ``T5577Config`` sanity — so framing rules live in
|
||||
exactly one place. A decoder returns a structured dict on a clean, validated frame, else ``None``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import dsp
|
||||
# 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
|
||||
# first thing loaded.
|
||||
from pm3py.sim import EM4100Code, T5577Config
|
||||
|
||||
# config word -> preset name (invert T5577Config._PRESETS) for "emulating ..." labelling
|
||||
_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)
|
||||
|
||||
|
||||
def decode_em4100(samples) -> dict | None:
|
||||
"""Demodulate an EM4100/EM4102 envelope (ASK + Manchester) to its 40-bit ID.
|
||||
|
||||
Searches the demodulated stream for a 64-bit frame whose header, ten row parities, four
|
||||
column parities and stop bit all check out (``EM4100Code.valid``) — the frame repeats, so a
|
||||
clean copy is found even if the capture starts mid-frame."""
|
||||
for bits in _manchester_bits(samples):
|
||||
code = _find_em4100(bits)
|
||||
if code is not None:
|
||||
return {
|
||||
"protocol": "EM4100",
|
||||
"tag_id": code.tag_id,
|
||||
"id_hex": f"{code.tag_id:010X}",
|
||||
"customer_id": code.customer_id,
|
||||
"card_number": code.card_number,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _find_em4100(bits) -> EM4100Code | None:
|
||||
n = len(bits)
|
||||
for start in range(max(0, n - 63)):
|
||||
window = bits[start:start + 64]
|
||||
if len(window) < 64 or None in window:
|
||||
continue
|
||||
code = EM4100Code(dsp.bits_to_int(window))
|
||||
if code.valid:
|
||||
return code
|
||||
return None
|
||||
|
||||
|
||||
def decode_t55xx_config(samples) -> dict | None:
|
||||
"""Recover a T55xx block-0 config word from a block-0 read response (ASK/Manchester modes).
|
||||
|
||||
A block read streams the 32-bit word with no header, so alignment is found by the word
|
||||
repeating (block value at offset N equals offset N+32) and the candidate being a *sane*
|
||||
config (known modulation, valid master key, 1..7 data blocks). Best-effort by nature — the
|
||||
reader labels it as a probable config, not gospel. Returns ``None`` for FSK/PSK-mode tags
|
||||
(handled elsewhere) or when nothing repeats cleanly."""
|
||||
best = None
|
||||
for bits in _manchester_bits(samples):
|
||||
clean = [b for b in bits if b is not None]
|
||||
cand = _find_repeating_word(clean, 32)
|
||||
if cand is None:
|
||||
continue
|
||||
cfg = T5577Config(cand)
|
||||
score = _config_score(cfg)
|
||||
if best is None or score > best[0]:
|
||||
best = (score, cand, cfg)
|
||||
if best is None or best[0] < 2: # need at least a known modulation + sane structure
|
||||
return None
|
||||
_, word, cfg = best
|
||||
return {
|
||||
"protocol": "T5577",
|
||||
"config": word,
|
||||
"config_hex": f"{word:08X}",
|
||||
"modulation": cfg.modulation if isinstance(cfg.modulation, str) else f"0x{cfg.modulation:02X}",
|
||||
"data_bit_rate": cfg.data_bit_rate,
|
||||
"max_block": cfg.max_block,
|
||||
"emulating": emulation_name(word, cfg),
|
||||
}
|
||||
|
||||
|
||||
def _find_repeating_word(bits, width: int) -> int | None:
|
||||
"""Smallest-offset ``width``-bit window that equals the next ``width`` bits (one block
|
||||
period), scored by config sanity; returns the best repeating word's value or ``None``."""
|
||||
n = len(bits)
|
||||
best = None
|
||||
for start in range(max(0, n - 2 * width)):
|
||||
w = bits[start:start + width]
|
||||
if w == bits[start + width:start + 2 * width]:
|
||||
val = dsp.bits_to_int(w)
|
||||
score = _config_score(T5577Config(val))
|
||||
if best is None or score > best[0]:
|
||||
best = (score, val)
|
||||
return best[1] if best else None
|
||||
|
||||
|
||||
def _config_score(cfg: T5577Config) -> int:
|
||||
score = 0
|
||||
if isinstance(cfg.modulation, str): # decoded to a known scheme
|
||||
score += 1
|
||||
if cfg.master_key in (0, 6, 9): # normal / extended-mode keys
|
||||
score += 1
|
||||
if 1 <= cfg.max_block <= 7:
|
||||
score += 1
|
||||
return score
|
||||
|
||||
|
||||
def emulation_name(word: int, cfg: T5577Config | None = None) -> str:
|
||||
"""Human label for what a T5577 config emulates: an exact preset match if we have one
|
||||
(EM4100, HID, Indala, FDX-B, Viking, default), else the modulation/rate family."""
|
||||
exact = _PRESET_BY_WORD.get(word)
|
||||
if exact:
|
||||
return {"em4100": "EM4100 / EM4102", "hid": "HID Prox", "indala": "Indala",
|
||||
"fdxb": "FDX-B", "viking": "Viking", "default": "raw / default"}.get(exact, exact)
|
||||
cfg = cfg or T5577Config(word)
|
||||
mod = cfg.modulation if isinstance(cfg.modulation, str) else "?"
|
||||
family = ("EM/ASK" if mod == "ASK"
|
||||
else "HID/AWID" if mod.startswith("FSK")
|
||||
else "Indala/PSK" if mod.startswith("PSK")
|
||||
else "FDX-B/Nedap" if "biphase" in mod
|
||||
else mod)
|
||||
return f"{family} · {mod} RF/{cfg.data_bit_rate}"
|
||||
Reference in New Issue
Block a user