Skip to content

Commit 652f341

Browse files
authored
Merge pull request #399 from nieldk/feat/lf-raw-sniff-v2
feat(lf): add raw LF field ADC capture (lf sniff)
2 parents 0ac25ca + d0a8ade commit 652f341

File tree

5 files changed

+135
-0
lines changed

5 files changed

+135
-0
lines changed

firmware/application/src/app_cmd.c

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1754,6 +1754,24 @@ static data_frame_tx_t *cmd_processor_em4x05_scan(uint16_t cmd, uint16_t status,
17541754
return data_frame_make(cmd, STATUS_LF_TAG_OK, sizeof(payload), (uint8_t *)&payload);
17551755
}
17561756

1757+
static data_frame_tx_t *cmd_processor_lf_sniff(uint16_t cmd, uint16_t status, uint16_t length, uint8_t *data) {
1758+
/* Optional 2-byte big-endian timeout in ms from host (default 2000ms) */
1759+
uint32_t timeout_ms = 2000;
1760+
if (length >= 2) {
1761+
timeout_ms = ((uint32_t)data[0] << 8) | data[1];
1762+
if (timeout_ms == 0 || timeout_ms > 10000) timeout_ms = 2000;
1763+
}
1764+
1765+
static uint8_t sniff_buf[LF_SNIFF_MAX_SAMPLES];
1766+
size_t outlen = 0;
1767+
raw_read_to_buffer(sniff_buf, LF_SNIFF_MAX_SAMPLES, timeout_ms, &outlen);
1768+
1769+
if (outlen == 0) {
1770+
return data_frame_make(cmd, STATUS_LF_TAG_NO_FOUND, 0, NULL);
1771+
}
1772+
return data_frame_make(cmd, STATUS_LF_TAG_OK, (uint16_t)outlen, sniff_buf);
1773+
}
1774+
17571775
#endif
17581776

17591777
static cmd_data_map_t m_data_cmd_map[] = {
@@ -1836,6 +1854,7 @@ static cmd_data_map_t m_data_cmd_map[] = {
18361854
{ DATA_CMD_IOPROX_DECODE_RAW, NULL, cmd_processor_ioprox_decode_raw, NULL },
18371855
{ DATA_CMD_IOPROX_COMPOSE_ID, NULL, cmd_processor_ioprox_compose_id, NULL },
18381856
{ DATA_CMD_EM4X05_SCAN, before_reader_run, cmd_processor_em4x05_scan, NULL },
1857+
{ DATA_CMD_LF_SNIFF, before_reader_run, cmd_processor_lf_sniff, NULL },
18391858

18401859
#endif
18411860

firmware/application/src/data_cmd.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,5 +175,6 @@
175175

176176
#define DATA_CMD_EM4X05_SCAN (3030)
177177
#define DATA_CMD_EM4X05_READSNIFF (3032)
178+
#define DATA_CMD_LF_SNIFF (3031)
178179

179180
#endif

software/script/chameleon_cli_unit.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,7 @@ def on_exec(self, args: argparse.Namespace):
754754
lf = root.subgroup("lf", "Low Frequency commands")
755755
lf_em = lf.subgroup("em", "EM commands")
756756
lf_em_4x05 = lf_em.subgroup("4x05", "EM4x05/EM4x69 commands")
757+
757758
lf_em_410x = lf_em.subgroup("410x", "EM410x commands")
758759
lf_hid = lf.subgroup("hid", "HID commands")
759760
lf_hid_prox = lf_hid.subgroup("prox", "HID Prox commands")
@@ -6767,3 +6768,98 @@ def on_exec(self, args: argparse.Namespace):
67676768
print(f" UID : {CG}{uid:08x}{C0}")
67686769

67696770

6771+
@lf.command('sniff')
6772+
class LFSniff(ReaderRequiredUnit):
6773+
def args_parser(self) -> ArgumentParserNoExit:
6774+
parser = ArgumentParserNoExit()
6775+
parser.description = (
6776+
"Capture raw LF field ADC samples (125kHz, 8µs/sample). "
6777+
"~0x80 = field on, lower values = gap or no field."
6778+
)
6779+
parser.add_argument(
6780+
'--timeout', type=int, default=2000, metavar='MS',
6781+
help='Capture duration in milliseconds (default: 2000, max: 10000, firmware blocks for full duration)'
6782+
)
6783+
parser.add_argument(
6784+
'--out', type=str, default=None, metavar='FILE',
6785+
help='Save raw samples to binary file (for offline analysis)'
6786+
)
6787+
parser.add_argument(
6788+
'--hex', action='store_true',
6789+
help='Print hex dump of samples to screen'
6790+
)
6791+
return parser
6792+
6793+
def on_exec(self, args: argparse.Namespace):
6794+
timeout = max(1, min(10000, args.timeout))
6795+
print(f" Capturing LF field for {timeout}ms at 125kHz (8µs/sample)...")
6796+
resp = self.cmd.lf_sniff(timeout_ms=timeout)
6797+
6798+
if resp.status != Status.LF_TAG_OK or not resp.data:
6799+
print(f"{CR}No samples captured{C0}")
6800+
return
6801+
6802+
import chameleon_cli_unit as _self_mod
6803+
data = bytes(resp.data)
6804+
_self_mod._last_capture = data
6805+
6806+
n = len(data)
6807+
duration_ms = n * 8 / 1000
6808+
print(f" Captured : {CG}{n}{C0} bytes ({duration_ms:.1f}ms)")
6809+
6810+
mn = min(data)
6811+
mx = max(data)
6812+
mean = sum(data) // len(data)
6813+
print(f" Range : {CG}0x{mn:02x}{C0}{CG}0x{mx:02x}{C0} mean: {CG}0x{mean:02x}{C0}")
6814+
6815+
# Detect real field gaps — they drop to near zero (0x00-0x40),
6816+
# well below the steady carrier (~0xb0). Use half of mean as threshold
6817+
# to avoid false positives from the antenna startup transient.
6818+
gap_threshold = mean // 2
6819+
# Skip first 200 samples (1.6ms) to ignore startup ringing
6820+
steady_data = data[200:]
6821+
gap_count = sum(1 for b in steady_data if b < gap_threshold)
6822+
if gap_count > 0:
6823+
print(f" Gaps : {CG}{gap_count}{C0} samples below 0x{gap_threshold:02x} (real field drops)")
6824+
else:
6825+
print(f" Gaps : {CR}none detected — flat carrier (no gap commands sent){C0}")
6826+
6827+
if args.hex:
6828+
print()
6829+
print(f" addr {'hex bytes':47s} level")
6830+
print(f" ---- {'-'*47} ----------------")
6831+
for i in range(0, min(n, 256), 16):
6832+
row = data[i:i+16]
6833+
hex_part = ' '.join(f'{b:02x}' for b in row)
6834+
bar = ''
6835+
for b in row:
6836+
if b < 0x10:
6837+
bar += '_' # gap / field off
6838+
elif b < 0x40:
6839+
bar += '.' # ringing decay
6840+
elif b < 0x80:
6841+
bar += '-' # low
6842+
elif b < 0xa0:
6843+
bar += '+' # mid
6844+
elif b < 0xc0:
6845+
bar += 'o' # steady carrier
6846+
elif b < 0xe0:
6847+
bar += 'O' # high
6848+
else:
6849+
bar += '#' # clipped 0xff
6850+
print(f" {i:04x} {hex_part:<47s} {bar}")
6851+
if n > 256:
6852+
print(f" ... ({n - 256} more bytes, use --out to save all)")
6853+
print()
6854+
print(" _ gap . ringing - low + mid o carrier O high # clipped")
6855+
6856+
if args.out:
6857+
try:
6858+
with open(args.out, 'wb') as f:
6859+
f.write(data)
6860+
print(f" Saved : {CG}{args.out}{C0} ({n} bytes)")
6861+
except Exception as e:
6862+
print(f"{CR}Failed to save: {e}{C0}")
6863+
6864+
6865+

software/script/chameleon_cmd.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,24 @@ def ioprox_compose_id(self, ver, fc, cn):
556556
return resp
557557

558558

559+
560+
def lf_sniff(self, timeout_ms: int = 2000):
561+
"""
562+
Capture raw LF field ADC samples.
563+
564+
The ChameleonUltra samples the LF antenna at 125kHz (8µs/sample).
565+
Each byte is an 8-bit ADC value: ~0x80 = field on, lower = gap/no field.
566+
567+
:param timeout_ms: Capture duration in ms (1-10000, default 2000)
568+
:return: Raw response object — check .status and .data
569+
"""
570+
timeout_ms = max(1, min(10000, timeout_ms))
571+
payload = bytes([(timeout_ms >> 8) & 0xFF, timeout_ms & 0xFF])
572+
timeout_s = (timeout_ms // 1000) + 2
573+
return self.device.send_cmd_sync(Command.LF_SNIFF, payload, timeout=timeout_s)
574+
575+
576+
559577
@expect_response(Status.LF_TAG_OK)
560578
def em4x05_scan(self, pwd: int = 0):
561579
"""

software/script/chameleon_enum.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ class Command(enum.IntEnum):
145145
IOPROX_GET_EMU_ID = 5009
146146
EM4X05_SCAN = 3030
147147
EM4X05_READSNIFF = 3032
148+
LF_SNIFF = 3031
148149

149150

150151
@enum.unique

0 commit comments

Comments
 (0)