From d0a8ade9e4ab5ab8f5fa090992129625e0097d9a Mon Sep 17 00:00:00 2001 From: Niel Nielsen Date: Thu, 2 Apr 2026 11:16:32 +0200 Subject: [PATCH] feat(lf): add raw LF field ADC capture (lf sniff) --- firmware/application/src/app_cmd.c | 19 ++++++ firmware/application/src/data_cmd.h | 1 + software/script/chameleon_cli_unit.py | 96 +++++++++++++++++++++++++++ software/script/chameleon_cmd.py | 18 +++++ software/script/chameleon_enum.py | 1 + 5 files changed, 135 insertions(+) diff --git a/firmware/application/src/app_cmd.c b/firmware/application/src/app_cmd.c index 536c56f..4d7c4cd 100644 --- a/firmware/application/src/app_cmd.c +++ b/firmware/application/src/app_cmd.c @@ -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 diff --git a/firmware/application/src/data_cmd.h b/firmware/application/src/data_cmd.h index 56c414d..52ce6b4 100644 --- a/firmware/application/src/data_cmd.h +++ b/firmware/application/src/data_cmd.h @@ -175,5 +175,6 @@ #define DATA_CMD_EM4X05_SCAN (3030) #define DATA_CMD_EM4X05_READSNIFF (3032) +#define DATA_CMD_LF_SNIFF (3031) #endif diff --git a/software/script/chameleon_cli_unit.py b/software/script/chameleon_cli_unit.py index 5122ed9..b78ed5c 100644 --- a/software/script/chameleon_cli_unit.py +++ b/software/script/chameleon_cli_unit.py @@ -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}") + + + diff --git a/software/script/chameleon_cmd.py b/software/script/chameleon_cmd.py index 5b06eef..ec1ba12 100644 --- a/software/script/chameleon_cmd.py +++ b/software/script/chameleon_cmd.py @@ -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): """ diff --git a/software/script/chameleon_enum.py b/software/script/chameleon_enum.py index f4253e1..9d2dc26 100644 --- a/software/script/chameleon_enum.py +++ b/software/script/chameleon_enum.py @@ -145,6 +145,7 @@ class Command(enum.IntEnum): IOPROX_GET_EMU_ID = 5009 EM4X05_SCAN = 3030 EM4X05_READSNIFF = 3032 + LF_SNIFF = 3031 @enum.unique