feat(rawcli): command catalogs + hints for identified LF chips
catalog_for() returned None for LF, so after identifying a T5577/EM4100 the completer and input_hint had nothing — no command suggestions, no hex/binary opcode hints. Now LF chips get catalogs like the HF ones: - T5577 catalog: READ(block) / WRITE(block, data) / WAKE(password) / DETECT(), with the downlink opcode exposed via build() for hex+binary completion, block-role hints (0=config, 7=password, 1-6=data), and execution via a new run() path (LF isn't a raw-byte exchange — it drives lf.t55 / the demod). READ(0)/DETECT use the reliable rotation-fixed config read; data-block reads are best-effort and labelled as such. capture.read_t55xx_block added. - LF read-only credentials (native EM4100/HID/AWID/FDX-B) get a minimal catalog (INFO -> re-read). - catalog_for dispatches protocol "lf": "T5577" in the label -> T5577, else read-only. - TagCommand gains an optional run(device, *args); completer/_opcode tolerate build=None. Hardware-verified: identify -> catalog T5577, help lists the commands, DETECT()/READ(0) return config 00148040 (EM4100). Tests: LF resolver, T5577 opcodes, T5577 block-role hints. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -143,6 +143,21 @@ def _handle_call(state: RawSession, cmd, out=print) -> None:
|
|||||||
if tc is None:
|
if tc is None:
|
||||||
out(f"unknown command {cmd.name!r} for {catalog.name}; try 'help'")
|
out(f"unknown command {cmd.name!r} for {catalog.name}; try 'help'")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# LF commands aren't a raw-byte exchange (14a/15693) — they drive the device via lf.t55 etc.
|
||||||
|
# and return a human result line. (build() still exists for the opcode hint in completion.)
|
||||||
|
if tc.run is not None:
|
||||||
|
if state.device is None:
|
||||||
|
out("no device connected")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
out(str(tc.run(state.device, *cmd.args)))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
out(f"usage: {tc.usage()} ({exc})")
|
||||||
|
except Exception as exc: # a flaky device shouldn't kill the REPL
|
||||||
|
out(f"rawcli: {tc.name} failed ({type(exc).__name__}: {exc})")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
built = tc.build(*cmd.args)
|
built = tc.build(*cmd.args)
|
||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ from typing import Callable
|
|||||||
class TagCommand:
|
class TagCommand:
|
||||||
name: str
|
name: str
|
||||||
params: tuple[str, ...]
|
params: tuple[str, ...]
|
||||||
build: Callable[..., bytes] # string args -> raw payload bytes
|
build: Callable[..., bytes] | None = None # HF: string args -> raw payload bytes to exchange
|
||||||
help: str = ""
|
help: str = ""
|
||||||
|
run: Callable[..., object] | None = None # LF: (device, *args) -> a human result line
|
||||||
|
|
||||||
def usage(self) -> str:
|
def usage(self) -> str:
|
||||||
return f"{self.name}({', '.join(self.params)})"
|
return f"{self.name}({', '.join(self.params)})"
|
||||||
@@ -47,8 +48,8 @@ def _catalog(name: str, protocol: str, *cmds: TagCommand) -> Catalog:
|
|||||||
return Catalog(name, protocol, {c.name.upper(): c for c in cmds})
|
return Catalog(name, protocol, {c.name.upper(): c for c in cmds})
|
||||||
|
|
||||||
|
|
||||||
def _c(name, params, build, help="") -> TagCommand:
|
def _c(name, params, build=None, help="", run=None) -> TagCommand:
|
||||||
return TagCommand(name, tuple(params), build, help)
|
return TagCommand(name, tuple(params), build, help, run)
|
||||||
|
|
||||||
|
|
||||||
# ---- ISO 14443-A Type 2 (NTAG / Ultralight) ----
|
# ---- ISO 14443-A Type 2 (NTAG / Ultralight) ----
|
||||||
@@ -98,6 +99,70 @@ ISO15 = _catalog(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- LF T5577 / ATA5577 (block read/write over the T55xx downlink; executes via lf.t55) ----
|
||||||
|
# build() exists only to expose the downlink opcode for hex/binary completion; execution goes
|
||||||
|
# through run() (the device method), since LF isn't a raw-byte exchange like 14a.
|
||||||
|
def _t55_read(dev, block):
|
||||||
|
b = _int(block)
|
||||||
|
if b == 0: # config block — the reliable one
|
||||||
|
from pm3py.lf.capture import read_config
|
||||||
|
r = read_config(dev)
|
||||||
|
if r:
|
||||||
|
return (f"block 0 = {r['config_hex']} ({r['modulation']}, RF/{r['data_bit_rate']}, "
|
||||||
|
f"{r['max_block']} blocks) -> {r['emulating']}")
|
||||||
|
return "READ block 0: no clean config recovered"
|
||||||
|
from pm3py.lf.capture import read_t55xx_block
|
||||||
|
word = read_t55xx_block(dev, b)
|
||||||
|
if word is None:
|
||||||
|
return f"READ block {b}: no clean response (LF block reads are demod-dependent)"
|
||||||
|
return f"block {b} = {word:08X} (best-effort — bit alignment not verified)"
|
||||||
|
|
||||||
|
|
||||||
|
def _t55_write(dev, block, data):
|
||||||
|
r = dev.lf.t55.writebl(_int(block), _int(data))
|
||||||
|
ok = isinstance(r, dict) and r.get("status") == 0
|
||||||
|
return f"WRITE block {_int(block)} <- {_int(data):08X}: {'ok' if ok else 'failed'}"
|
||||||
|
|
||||||
|
|
||||||
|
def _t55_wake(dev, password):
|
||||||
|
r = dev.lf.t55.wakeup(_int(password))
|
||||||
|
return f"WAKE: {'ok' if isinstance(r, dict) and r.get('status') == 0 else 'failed'}"
|
||||||
|
|
||||||
|
|
||||||
|
def _t55_detect(dev):
|
||||||
|
from pm3py.lf.capture import read_config
|
||||||
|
r = read_config(dev)
|
||||||
|
if not r:
|
||||||
|
return "DETECT: no T55xx config recovered"
|
||||||
|
return (f"config {r['config_hex']} ({r['modulation']}, RF/{r['data_bit_rate']}, "
|
||||||
|
f"{r['max_block']} blocks) -> {r['emulating']}")
|
||||||
|
|
||||||
|
|
||||||
|
def _lf_reread(dev):
|
||||||
|
from pm3py.lf import identify_lf
|
||||||
|
r = identify_lf(dev)
|
||||||
|
return r["label"] if r else "no LF credential decoded"
|
||||||
|
|
||||||
|
|
||||||
|
T5577 = _catalog(
|
||||||
|
"T5577 / ATA5577", "lf",
|
||||||
|
_c("READ", ["block"], build=lambda block: bytes([0x01, _int(block)]), run=_t55_read,
|
||||||
|
help="read a 32-bit block (0=config, 7=password, 1-6=data) — best-effort demod"),
|
||||||
|
_c("WRITE", ["block", "data"], build=lambda block, data: bytes([0x02, _int(block)]),
|
||||||
|
run=_t55_write, help="write 32-bit <data> to <block> (0x02)"),
|
||||||
|
_c("WAKE", ["password"], build=lambda password: bytes([0x03]), run=_t55_wake,
|
||||||
|
help="wake a password-protected tag with the block-7 password (0x03)"),
|
||||||
|
_c("DETECT", [], build=lambda: bytes([0x01, 0x00]), run=_t55_detect,
|
||||||
|
help="read + decode the config block (block 0)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- LF read-only credentials (EM4100/HID/AWID/FDX-B): broadcast tags with no command set ----
|
||||||
|
LF_READONLY = _catalog(
|
||||||
|
"LF credential (read-only)", "lf",
|
||||||
|
_c("INFO", [], run=_lf_reread, help="re-read and decode the broadcast credential"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def catalog_for(session) -> Catalog | None:
|
def catalog_for(session) -> Catalog | None:
|
||||||
"""Resolve the command catalog for the session's identified transponder, or None."""
|
"""Resolve the command catalog for the session's identified transponder, or None."""
|
||||||
name = (session.transponder or "").upper()
|
name = (session.transponder or "").upper()
|
||||||
@@ -107,4 +172,8 @@ def catalog_for(session) -> Catalog | None:
|
|||||||
if not session.transponder:
|
if not session.transponder:
|
||||||
return None
|
return None
|
||||||
return MFC if "CLASSIC" in name else TYPE2
|
return MFC if "CLASSIC" in name else TYPE2
|
||||||
|
if session.protocol == "lf":
|
||||||
|
if not session.transponder:
|
||||||
|
return None
|
||||||
|
return T5577 if "T5577" in name else LF_READONLY
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ _BINDIGITS = set("01")
|
|||||||
|
|
||||||
|
|
||||||
def _opcode(tc) -> int | None:
|
def _opcode(tc) -> int | None:
|
||||||
"""The command's first byte (opcode), obtained by building it with placeholder args."""
|
"""The command's first byte (opcode), obtained by building it with placeholder args. Returns
|
||||||
|
None for commands with no ``build`` (read-only credential ops that carry no opcode)."""
|
||||||
|
if tc.build is None:
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
built = tc.build(*(["0"] * len(tc.params)))
|
built = tc.build(*(["0"] * len(tc.params)))
|
||||||
return built[0] if built else None
|
return built[0] if built else None
|
||||||
|
|||||||
@@ -35,8 +35,24 @@ _ROLES = {
|
|||||||
_CALL_HEAD = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*([^,)]*)")
|
_CALL_HEAD = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*([^,)]*)")
|
||||||
|
|
||||||
|
|
||||||
|
def _t5577_block_role(block: int | None) -> str | None:
|
||||||
|
"""Role of a T5577 block: 0 = config, 7 = password, 1-6 = data (the emulated tag content)."""
|
||||||
|
if block is None:
|
||||||
|
return None
|
||||||
|
if block == 0:
|
||||||
|
return "config block (modulation / bit-rate / block count)"
|
||||||
|
if block == 7:
|
||||||
|
return "password block (32-bit)"
|
||||||
|
if 1 <= block <= 6:
|
||||||
|
return "data block (emulated tag content)"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def page_role(transponder: str | None, page: int | None) -> str | None:
|
def page_role(transponder: str | None, page: int | None) -> str | None:
|
||||||
"""The role of ``page`` on ``transponder`` (user memory / CC / AUTH0 / PWD / …), or None."""
|
"""The role of ``page``/``block`` on ``transponder`` (user memory / CC / AUTH0 / PWD /
|
||||||
|
T5577 config / password / …), or None."""
|
||||||
|
if transponder and "T5577" in transponder.upper():
|
||||||
|
return _t5577_block_role(page)
|
||||||
layout = _LAYOUTS.get(transponder or "")
|
layout = _LAYOUTS.get(transponder or "")
|
||||||
if layout is None or page is None:
|
if layout is None or page is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
|
from . import ask
|
||||||
from . import protocols
|
from . import protocols
|
||||||
|
|
||||||
# how many envelope samples to pull from BigBuf after a capture (a T55xx/EM frame is a few
|
# how many envelope samples to pull from BigBuf after a capture (a T55xx/EM frame is a few
|
||||||
@@ -114,6 +115,21 @@ def read_config(device) -> dict | None:
|
|||||||
return protocols.decode_t55xx_config(data) if data else None
|
return protocols.decode_t55xx_config(data) if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def read_t55xx_block(device, block: int) -> int | None:
|
||||||
|
"""Best-effort read of a T55xx 32-bit data block: send the block read, demod the first
|
||||||
|
repeating 32-bit word. A bare block has no header/CRC, so the bit alignment (rotation) can't
|
||||||
|
be verified — this is inherently best-effort. Block 0 (config) is the reliable one; read it
|
||||||
|
via read_config, which resolves the rotation against known presets."""
|
||||||
|
data = _capture(device, lambda: _try(lambda: device.lf.t55.readbl(int(block)), None))
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
for bits in ask.manchester_bits(data):
|
||||||
|
clean = [b for b in bits if b is not None]
|
||||||
|
for word in protocols._repeating_words(clean, 32):
|
||||||
|
return word
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def identify_lf(device, emit=None) -> dict | None:
|
def identify_lf(device, emit=None) -> dict | None:
|
||||||
"""Full LF identify against a live device. Returns a result dict (``found``/``field``/
|
"""Full LF identify against a live device. Returns a result dict (``found``/``field``/
|
||||||
``label``/``chip``/``config``/``emulating``/``emitted``) or ``None`` if the device is
|
``label``/``chip``/``config``/``emulating``/``emitted``) or ``None`` if the device is
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from pm3py.cli.rawcli.trace_view import render_exchange
|
|||||||
from pm3py.cli.rawcli.parser import parse_token, parse_bytes, parse_line
|
from pm3py.cli.rawcli.parser import parse_token, parse_bytes, parse_line
|
||||||
from pm3py.cli.rawcli.entry import byte_space
|
from pm3py.cli.rawcli.entry import byte_space
|
||||||
from pm3py.cli.rawcli.identify import identify, clear, name_14a, format_summary
|
from pm3py.cli.rawcli.identify import identify, clear, name_14a, format_summary
|
||||||
from pm3py.cli.rawcli.catalog import TYPE2, ISO15, catalog_for, catalog_for as _cf
|
from pm3py.cli.rawcli.catalog import TYPE2, ISO15, T5577, LF_READONLY, catalog_for, catalog_for as _cf
|
||||||
from pm3py.cli.rawcli.tlv import parse_tlv, format_tlv
|
from pm3py.cli.rawcli.tlv import parse_tlv, format_tlv
|
||||||
from pm3py.cli.rawcli.completer import RawCompleter
|
from pm3py.cli.rawcli.completer import RawCompleter
|
||||||
from pm3py.cli.rawcli.app import dispatch
|
from pm3py.cli.rawcli.app import dispatch
|
||||||
@@ -349,6 +349,22 @@ class TestCatalog:
|
|||||||
s.protocol, s.transponder = "hf15", "ISO15693"
|
s.protocol, s.transponder = "hf15", "ISO15693"
|
||||||
assert catalog_for(s) is ISO15
|
assert catalog_for(s) is ISO15
|
||||||
|
|
||||||
|
def test_resolver_lf(self):
|
||||||
|
# identify sets an LF transponder -> the completer/hints get a catalog, not None
|
||||||
|
s = RawSession()
|
||||||
|
s.protocol, s.transponder = "lf", "T5577 (EM4100 20260716FF)"
|
||||||
|
assert catalog_for(s) is T5577 # command-rich emulator
|
||||||
|
s.transponder = "EM4100 20260716FF" # a native read-only credential
|
||||||
|
assert catalog_for(s) is LF_READONLY
|
||||||
|
|
||||||
|
def test_t5577_opcodes(self):
|
||||||
|
# build() exposes the downlink opcode for hex/binary completion (execution is via run)
|
||||||
|
assert T5577.get("READ").build("0")[0] == 0x01
|
||||||
|
assert T5577.get("WRITE").build("0", "0")[0] == 0x02
|
||||||
|
assert T5577.get("WAKE").build("0")[0] == 0x03
|
||||||
|
assert {c.name for c in T5577.commands.values()} == {"READ", "WRITE", "WAKE", "DETECT"}
|
||||||
|
assert T5577.get("READ").run is not None and LF_READONLY.get("INFO").run is not None
|
||||||
|
|
||||||
|
|
||||||
class TestFunctionCalls:
|
class TestFunctionCalls:
|
||||||
def _id(self, sak=0x00):
|
def _id(self, sak=0x00):
|
||||||
@@ -508,6 +524,17 @@ class TestMemoryHint:
|
|||||||
from pm3py.cli.rawcli.memory import input_hint
|
from pm3py.cli.rawcli.memory import input_hint
|
||||||
assert input_hint(RawSession(), "READ(4)") is None
|
assert input_hint(RawSession(), "READ(4)") is None
|
||||||
|
|
||||||
|
def test_t5577_block_roles(self):
|
||||||
|
from pm3py.cli.rawcli.memory import page_role, input_hint
|
||||||
|
tp = "T5577 (EM4100 20260716FF)"
|
||||||
|
assert "config" in page_role(tp, 0)
|
||||||
|
assert "password" in page_role(tp, 7)
|
||||||
|
assert "data" in page_role(tp, 3)
|
||||||
|
s = RawSession()
|
||||||
|
s.protocol, s.transponder = "lf", tp
|
||||||
|
assert "config" in input_hint(s, "READ(0") # block-role relevance for LF too
|
||||||
|
assert "password" in input_hint(s, "WRITE(7,")
|
||||||
|
|
||||||
def test_layouts_match_models(self):
|
def test_layouts_match_models(self):
|
||||||
from pm3py.cli.rawcli.memory import _LAYOUTS
|
from pm3py.cli.rawcli.memory import _LAYOUTS
|
||||||
from pm3py.sim import NTAG210, NTAG212, NTAG213, NTAG215, NTAG216
|
from pm3py.sim import NTAG210, NTAG212, NTAG213, NTAG215, NTAG216
|
||||||
|
|||||||
Reference in New Issue
Block a user