Files
pm3py/pm3py/core/hf.py
michael 75bf0e19cb feat(hf): rename mfc -> mf (MIFARE Classic), add mfu (Ultralight/NTAG)
hf.mfc renamed to hf.mf to match the C client's 'hf mf'. New hf.mfu (HFMFUCommands) provides rdbl/wrbl/dump over the dedicated CMD_HF_MIFAREU_READBL/WRITEBL using the firmware mful_readblock_t / mful_writeblock_t structs; a READ returns 4 pages (16 bytes) and the firmware replies them directly. keytype selects auth (0=none, 1=UL-C, 2=EV1/NTAG pwd, 3=UL-AES). Wire formats verified against firmware structs; needs a bench pass for the auth/write-length paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:40:07 -07:00

101 lines
3.9 KiB
Python

"""High frequency commands: hf.*"""
import struct
import asyncio
from .protocol import Cmd
from .transport import PM3Transport, PM3Error, ProgressCallback
class HFCommands:
"""High frequency commands."""
def __init__(self, transport: PM3Transport):
self._t = transport
# Sub-modules attached by client.py
self.iso14a = None # type: ignore
self.iso15 = None # type: ignore
self.mf = None # MIFARE Classic # type: ignore
self.mfu = None # MIFARE Ultralight / NTAG # type: ignore
async def tune(self, on_progress: ProgressCallback = None) -> dict:
"""Measure HF antenna voltage.
CMD_MEASURE_ANTENNA_TUNING_HF is a START/MEASURE/STOP state machine that
requires a 1-byte mode arg (firmware rejects length!=1 with EINVARG):
mode 1 = START (spins up the HF FPGA), mode 2 = MEASURE (returns uint16 mV),
mode 3 = STOP. Voltage is a uint16_t, not uint32_t.
"""
await self._t.send_ng(Cmd.MEASURE_ANTENNA_TUNING_HF, bytes([1]), timeout=10.0) # START
resp = await self._t.send_ng(Cmd.MEASURE_ANTENNA_TUNING_HF, bytes([2]), timeout=10.0) # MEASURE
voltage = struct.unpack_from("<H", resp.data, 0)[0] if len(resp.data) >= 2 else 0
await self._t.send_ng(Cmd.MEASURE_ANTENNA_TUNING_HF, bytes([3]), timeout=10.0) # STOP
result = {
"voltage_mV": voltage,
"voltage_V": round(voltage / 1000.0, 3),
}
if on_progress:
ret = on_progress({"type": "hf_tune", "result": result})
if asyncio.iscoroutine(ret):
await ret
return result
async def search(self, on_progress: ProgressCallback = None) -> dict:
"""Search for HF tags. Tries 14a, then 15."""
results = {}
if on_progress:
ret = on_progress({"type": "hf_search", "phase": "14a"})
if asyncio.iscoroutine(ret):
await ret
if self.iso14a:
try:
card = await self.iso14a.scan()
if card.get("uid"):
results["iso14443a"] = card
except PM3Error:
pass
if on_progress:
ret = on_progress({"type": "hf_search", "phase": "15693"})
if asyncio.iscoroutine(ret):
await ret
if self.iso15:
try:
tag = await self.iso15.scan()
if tag.get("uid"):
results["iso15693"] = tag
except PM3Error:
pass
results["found"] = len(results) > 0
return results
async def sniff(self, samples_to_skip: int = 0, triggers_to_skip: int = 0,
skip_mode: int = 0, skip_ratio: int = 0,
on_progress: ProgressCallback = None) -> dict:
"""Sniff HF traffic.
Firmware reads a 10-byte PACKED struct
``{uint32 samplesToSkip; uint32 triggersToSkip; uint8 skipMode;
uint8 skipRatio}`` and, when the capture ends, replies with a
``{uint16 len}`` sample count. The struct must always be sent whole
(matching the C client's ``sizeof(params)``); under-sending leaves
the firmware reading uninitialised BigBuf bytes.
"""
payload = struct.pack("<IIBB", samples_to_skip, triggers_to_skip,
skip_mode, skip_ratio)
resp = await self._t.send_ng(Cmd.HF_SNIFF, payload, timeout=30.0)
samples = struct.unpack_from("<H", resp.data, 0)[0] if len(resp.data) >= 2 else 0
return {"status": resp.status, "samples": samples}
async def dropfield(self) -> dict:
"""Turn off HF field.
The firmware handler is a bare ``hf_field_off(); break;`` with no
``reply_ng``, so waiting for a response just blocks until timeout.
Fire-and-forget instead.
"""
await self._t.send_ng_no_response(Cmd.HF_DROPFIELD)
return {"status": 0}