mirror of
https://github.com/RfidResearchGroup/ChameleonUltra.git
synced 2026-05-12 11:22:59 -07:00
Merge pull request #399 from nieldk/feat/lf-raw-sniff-v2
feat(lf): add raw LF field ADC capture (lf sniff)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -175,5 +175,6 @@
|
||||
|
||||
#define DATA_CMD_EM4X05_SCAN (3030)
|
||||
#define DATA_CMD_EM4X05_READSNIFF (3032)
|
||||
#define DATA_CMD_LF_SNIFF (3031)
|
||||
|
||||
#endif
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -145,6 +145,7 @@ class Command(enum.IntEnum):
|
||||
IOPROX_GET_EMU_ID = 5009
|
||||
EM4X05_SCAN = 3030
|
||||
EM4X05_READSNIFF = 3032
|
||||
LF_SNIFF = 3031
|
||||
|
||||
|
||||
@enum.unique
|
||||
|
||||
Reference in New Issue
Block a user