feat(lf): add raw LF field ADC capture (lf sniff)

This commit is contained in:
Niel Nielsen
2026-04-02 11:16:32 +02:00
parent 0ac25caedc
commit d0a8ade9e4
5 changed files with 135 additions and 0 deletions
+19
View File
@@ -1754,6 +1754,24 @@ static data_frame_tx_t *cmd_processor_em4x05_scan(uint16_t cmd, uint16_t status,
return data_frame_make(cmd, STATUS_LF_TAG_OK, sizeof(payload), (uint8_t *)&payload);
}
static data_frame_tx_t *cmd_processor_lf_sniff(uint16_t cmd, uint16_t status, uint16_t length, uint8_t *data) {
/* Optional 2-byte big-endian timeout in ms from host (default 2000ms) */
uint32_t timeout_ms = 2000;
if (length >= 2) {
timeout_ms = ((uint32_t)data[0] << 8) | data[1];
if (timeout_ms == 0 || timeout_ms > 10000) timeout_ms = 2000;
}
static uint8_t sniff_buf[LF_SNIFF_MAX_SAMPLES];
size_t outlen = 0;
raw_read_to_buffer(sniff_buf, LF_SNIFF_MAX_SAMPLES, timeout_ms, &outlen);
if (outlen == 0) {
return data_frame_make(cmd, STATUS_LF_TAG_NO_FOUND, 0, NULL);
}
return data_frame_make(cmd, STATUS_LF_TAG_OK, (uint16_t)outlen, sniff_buf);
}
#endif
static cmd_data_map_t m_data_cmd_map[] = {
@@ -1836,6 +1854,7 @@ static cmd_data_map_t m_data_cmd_map[] = {
{ DATA_CMD_IOPROX_DECODE_RAW, NULL, cmd_processor_ioprox_decode_raw, NULL },
{ DATA_CMD_IOPROX_COMPOSE_ID, NULL, cmd_processor_ioprox_compose_id, NULL },
{ DATA_CMD_EM4X05_SCAN, before_reader_run, cmd_processor_em4x05_scan, NULL },
{ DATA_CMD_LF_SNIFF, before_reader_run, cmd_processor_lf_sniff, NULL },
#endif
+1
View File
@@ -175,5 +175,6 @@
#define DATA_CMD_EM4X05_SCAN (3030)
#define DATA_CMD_EM4X05_READSNIFF (3032)
#define DATA_CMD_LF_SNIFF (3031)
#endif
+96
View File
@@ -754,6 +754,7 @@ hf_mfu = hf.subgroup("mfu", "MIFARE Ultralight / NTAG commands")
lf = root.subgroup("lf", "Low Frequency commands")
lf_em = lf.subgroup("em", "EM commands")
lf_em_4x05 = lf_em.subgroup("4x05", "EM4x05/EM4x69 commands")
lf_em_410x = lf_em.subgroup("410x", "EM410x commands")
lf_hid = lf.subgroup("hid", "HID commands")
lf_hid_prox = lf_hid.subgroup("prox", "HID Prox commands")
@@ -6767,3 +6768,98 @@ class LFEm4x05Read(ReaderRequiredUnit):
print(f" UID : {CG}{uid:08x}{C0}")
@lf.command('sniff')
class LFSniff(ReaderRequiredUnit):
def args_parser(self) -> ArgumentParserNoExit:
parser = ArgumentParserNoExit()
parser.description = (
"Capture raw LF field ADC samples (125kHz, 8µs/sample). "
"~0x80 = field on, lower values = gap or no field."
)
parser.add_argument(
'--timeout', type=int, default=2000, metavar='MS',
help='Capture duration in milliseconds (default: 2000, max: 10000, firmware blocks for full duration)'
)
parser.add_argument(
'--out', type=str, default=None, metavar='FILE',
help='Save raw samples to binary file (for offline analysis)'
)
parser.add_argument(
'--hex', action='store_true',
help='Print hex dump of samples to screen'
)
return parser
def on_exec(self, args: argparse.Namespace):
timeout = max(1, min(10000, args.timeout))
print(f" Capturing LF field for {timeout}ms at 125kHz (8µs/sample)...")
resp = self.cmd.lf_sniff(timeout_ms=timeout)
if resp.status != Status.LF_TAG_OK or not resp.data:
print(f"{CR}No samples captured{C0}")
return
import chameleon_cli_unit as _self_mod
data = bytes(resp.data)
_self_mod._last_capture = data
n = len(data)
duration_ms = n * 8 / 1000
print(f" Captured : {CG}{n}{C0} bytes ({duration_ms:.1f}ms)")
mn = min(data)
mx = max(data)
mean = sum(data) // len(data)
print(f" Range : {CG}0x{mn:02x}{C0} {CG}0x{mx:02x}{C0} mean: {CG}0x{mean:02x}{C0}")
# Detect real field gaps — they drop to near zero (0x00-0x40),
# well below the steady carrier (~0xb0). Use half of mean as threshold
# to avoid false positives from the antenna startup transient.
gap_threshold = mean // 2
# Skip first 200 samples (1.6ms) to ignore startup ringing
steady_data = data[200:]
gap_count = sum(1 for b in steady_data if b < gap_threshold)
if gap_count > 0:
print(f" Gaps : {CG}{gap_count}{C0} samples below 0x{gap_threshold:02x} (real field drops)")
else:
print(f" Gaps : {CR}none detected — flat carrier (no gap commands sent){C0}")
if args.hex:
print()
print(f" addr {'hex bytes':47s} level")
print(f" ---- {'-'*47} ----------------")
for i in range(0, min(n, 256), 16):
row = data[i:i+16]
hex_part = ' '.join(f'{b:02x}' for b in row)
bar = ''
for b in row:
if b < 0x10:
bar += '_' # gap / field off
elif b < 0x40:
bar += '.' # ringing decay
elif b < 0x80:
bar += '-' # low
elif b < 0xa0:
bar += '+' # mid
elif b < 0xc0:
bar += 'o' # steady carrier
elif b < 0xe0:
bar += 'O' # high
else:
bar += '#' # clipped 0xff
print(f" {i:04x} {hex_part:<47s} {bar}")
if n > 256:
print(f" ... ({n - 256} more bytes, use --out to save all)")
print()
print(" _ gap . ringing - low + mid o carrier O high # clipped")
if args.out:
try:
with open(args.out, 'wb') as f:
f.write(data)
print(f" Saved : {CG}{args.out}{C0} ({n} bytes)")
except Exception as e:
print(f"{CR}Failed to save: {e}{C0}")
+18
View File
@@ -556,6 +556,24 @@ class ChameleonCMD:
return resp
def lf_sniff(self, timeout_ms: int = 2000):
"""
Capture raw LF field ADC samples.
The ChameleonUltra samples the LF antenna at 125kHz (8µs/sample).
Each byte is an 8-bit ADC value: ~0x80 = field on, lower = gap/no field.
:param timeout_ms: Capture duration in ms (1-10000, default 2000)
:return: Raw response object check .status and .data
"""
timeout_ms = max(1, min(10000, timeout_ms))
payload = bytes([(timeout_ms >> 8) & 0xFF, timeout_ms & 0xFF])
timeout_s = (timeout_ms // 1000) + 2
return self.device.send_cmd_sync(Command.LF_SNIFF, payload, timeout=timeout_s)
@expect_response(Status.LF_TAG_OK)
def em4x05_scan(self, pwd: int = 0):
"""
+1
View File
@@ -145,6 +145,7 @@ class Command(enum.IntEnum):
IOPROX_GET_EMU_ID = 5009
EM4X05_SCAN = 3030
EM4X05_READSNIFF = 3032
LF_SNIFF = 3031
@enum.unique