feat(rawcli): ICODE SLIX datasheet catalog — 15693 header builder + position-aware completion
Adds ISO 15693 / ICODE support in the datasheet-catalog style: - iso15_frame() header builder assembles a request (flags | command | [NXP mfg 0x04] | [UID LSByte-first] | params); hf.iso15.raw() sends it (CRC host-side, auto long-wait for write/lock). - ICODE SLIX (SL2S2002) catalog: the datasheet command set — standard 15693 (READ/WRITE/LOCK block, READ_MULTIPLE, AFI/DSFID, GET_SYSTEM_INFO, security, select/reset/stay-quiet) and NXP customs (GET_NXP_SYSTEM_INFO, GET_RANDOM, SET/WRITE/LOCK_PASSWORD, PROTECT_PAGE, EAS set/reset/ lock/alarm/write-id, ENABLE_PRIVACY, DESTROY). catalog_for routes SLIX/ICODE names here. - TagCommand gains an explicit opcode= : in 15693 the frame's first byte is the request FLAGS, not the command, so completion keys on the command byte (byte 1). - Position-aware raw completion for hf15: byte 0 = the flags menu with a live decode of each value, byte 1 = the command, byte 2 = the 04 mfg code on custom commands (previewed with the user). Function-call form and command-name completion unchanged. - Per-chip identify: E0 04 -> NXP ICODE; a full 32-byte READ_SIGNATURE distinguishes SLIX2 from SLIX. Robust UID fetch (inventory retry + a validated direct GET_SYSTEM_INFO fallback), since vicinity anticollision misses often. Hardware-verified on an ICODE SLIX: identify -> "ICODE SLIX" (reliably), the frames build correctly (02 2B / 02 20 00 / 02 B2 04), and the flags/command/mfg menus + block map resolve. (Command *responses* intermittently desync on this specific flaky PM3 link — a known device issue across protocols, not the framing.) 1376 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -20,13 +20,18 @@ class TagCommand:
|
||||
build: Callable[..., bytes] | None = None # HF: string args -> raw payload bytes to exchange
|
||||
help: str = ""
|
||||
run: Callable[..., object] | None = None # LF: (device, *args) -> a human result line
|
||||
opcode_byte: int | None = None # explicit wire opcode. ISO15693 sets this to the
|
||||
# COMMAND byte (frame byte 1), since byte 0 is flags
|
||||
|
||||
def usage(self) -> str:
|
||||
return f"{self.name}({', '.join(self.params)})"
|
||||
|
||||
def opcode(self) -> int | None:
|
||||
"""The command's first sent byte, by building it with placeholder args — used to match raw
|
||||
input back to a command. None when there's no build (LF run-only commands)."""
|
||||
"""The command's wire opcode — an explicit ``opcode_byte`` if given (ISO15693: the command
|
||||
byte, not the flags-first frame byte), else the first built byte. None for run-only LF
|
||||
commands with no build."""
|
||||
if self.opcode_byte is not None:
|
||||
return self.opcode_byte
|
||||
if self.build is None:
|
||||
return None
|
||||
try:
|
||||
@@ -71,8 +76,8 @@ def _catalog(name: str, protocol: str, *cmds: TagCommand) -> Catalog:
|
||||
return Catalog(name, protocol, {c.name.upper(): c for c in cmds})
|
||||
|
||||
|
||||
def _c(name, params, build=None, help="", run=None) -> TagCommand:
|
||||
return TagCommand(name, tuple(params), build, help, run)
|
||||
def _c(name, params, build=None, help="", run=None, opcode=None) -> TagCommand:
|
||||
return TagCommand(name, tuple(params), build, help, run, opcode)
|
||||
|
||||
|
||||
# ---- ISO 14443-A Type 2 (NTAG / Ultralight) ----
|
||||
@@ -220,16 +225,130 @@ MFC = _catalog(
|
||||
_c("HALT", [], lambda: bytes([0x50, 0x00]), "halt the card (0x50 00)"),
|
||||
)
|
||||
|
||||
# ---- ISO 15693 (flags byte 0x02 = high data rate, single tag) ----
|
||||
# ---- ISO 15693 request framing --------------------------------------------------------------
|
||||
# A 15693 request is flags | command | [mfg] | [UID LSB-first] | params | CRC. The first byte is
|
||||
# the request-FLAGS bitfield, NOT the command — so 15693 TagCommands carry an explicit opcode=
|
||||
# (the command byte) for completion, and iso15_frame() assembles the wire frame (CRC added by
|
||||
# hf.iso15.raw()).
|
||||
F15_HIGH_RATE = 0x02
|
||||
F15_INVENTORY = 0x04
|
||||
F15_PROTO_EXT = 0x08
|
||||
F15_SELECT = 0x10
|
||||
F15_ADDRESSED = 0x20
|
||||
F15_1SLOT = 0x20
|
||||
F15_OPTION = 0x40
|
||||
NXP_MFG = 0x04
|
||||
|
||||
|
||||
def iso15_frame(command: int, params: bytes = b"", *, uid=None, addressed: bool = False,
|
||||
high_rate: bool = True, option: bool = False, mfg: int | None = None) -> bytes:
|
||||
"""Assemble an ISO 15693 request frame (pre-CRC): flags | command | [mfg] | [UID] | params."""
|
||||
flags = F15_HIGH_RATE if high_rate else 0
|
||||
if option:
|
||||
flags |= F15_OPTION
|
||||
if addressed and uid:
|
||||
flags |= F15_ADDRESSED
|
||||
out = bytearray([flags, command])
|
||||
if mfg is not None:
|
||||
out.append(mfg)
|
||||
if addressed and uid:
|
||||
u = bytes.fromhex(uid) if isinstance(uid, str) else bytes(uid)
|
||||
out += u[::-1] # UID is transmitted LSByte-first
|
||||
return bytes(out) + bytes(params)
|
||||
|
||||
|
||||
def iso15_flags_decode(flags: int) -> str:
|
||||
"""Human decode of a 15693 request-flags byte (byte 0), for the raw-entry hint."""
|
||||
if flags & F15_INVENTORY:
|
||||
parts = ["inventory", "high rate" if flags & F15_HIGH_RATE else "low rate",
|
||||
"1 slot" if flags & F15_1SLOT else "16 slots"]
|
||||
else:
|
||||
parts = ["high rate" if flags & F15_HIGH_RATE else "low rate",
|
||||
"addressed" if flags & F15_ADDRESSED else "non-addressed"]
|
||||
if flags & F15_SELECT:
|
||||
parts.append("select")
|
||||
if flags & F15_PROTO_EXT:
|
||||
parts.append("protocol-ext")
|
||||
if flags & F15_OPTION:
|
||||
parts.append("option")
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
# common flags bytes offered at frame byte 0
|
||||
ISO15_FLAG_LANDMARKS = [0x02, 0x22, 0x42, 0x62, 0x0A, 0x26, 0x06, 0x00]
|
||||
|
||||
|
||||
# ---- ISO 15693 (generic) ----
|
||||
ISO15 = _catalog(
|
||||
"ISO 15693", "hf15",
|
||||
_c("INVENTORY", [], lambda: bytes([0x26, 0x01, 0x00]), "anticollision inventory (0x01)"),
|
||||
_c("READ_BLOCK", ["block"], lambda block: bytes([0x02, 0x20, _int(block)]),
|
||||
"read a single block (0x20)"),
|
||||
_c("READ_BLOCK", ["block"], lambda block: iso15_frame(0x20, bytes([_int(block)])),
|
||||
"read a single block (0x20)", opcode=0x20),
|
||||
_c("WRITE_BLOCK", ["block", "data"],
|
||||
lambda block, data: bytes([0x02, 0x21, _int(block)]) + _hex(data),
|
||||
"write a single block (0x21)"),
|
||||
_c("GET_SYSTEM_INFO", [], lambda: bytes([0x02, 0x2B]), "get system information (0x2B)"),
|
||||
lambda block, data: iso15_frame(0x21, bytes([_int(block)]) + _hex(data)),
|
||||
"write a single 4-byte block (0x21)", opcode=0x21),
|
||||
_c("GET_SYSTEM_INFO", [], lambda: iso15_frame(0x2B), "get system information (0x2B)", opcode=0x2B),
|
||||
)
|
||||
|
||||
|
||||
# ---- ICODE SLIX (NXP SL2S2002) — datasheet command set via the header builder ----
|
||||
_SLIX = _catalog(
|
||||
"ICODE SLIX", "hf15",
|
||||
# standard ISO 15693
|
||||
_c("READ_BLOCK", ["block"], lambda block: iso15_frame(0x20, bytes([_int(block)])),
|
||||
"read a single 4-byte block (0x20)", opcode=0x20),
|
||||
_c("WRITE_BLOCK", ["block", "data"],
|
||||
lambda block, data: iso15_frame(0x21, bytes([_int(block)]) + _hex(data).ljust(4, b"\x00")[:4]),
|
||||
"write a single 4-byte block (0x21)", opcode=0x21),
|
||||
_c("LOCK_BLOCK", ["block"], lambda block: iso15_frame(0x22, bytes([_int(block)])),
|
||||
"permanently lock a block (0x22)", opcode=0x22),
|
||||
_c("READ_MULTIPLE", ["first", "count"],
|
||||
lambda first, count: iso15_frame(0x23, bytes([_int(first), _int(count) - 1])),
|
||||
"read <count> blocks from <first> (0x23)", opcode=0x23),
|
||||
_c("SELECT", [], lambda: iso15_frame(0x25), "put the tag in Selected state (0x25)", opcode=0x25),
|
||||
_c("RESET_TO_READY", [], lambda: iso15_frame(0x26), "reset the tag to Ready (0x26)", opcode=0x26),
|
||||
_c("STAY_QUIET", [], lambda: iso15_frame(0x02), "silence the tag until reset (0x02)", opcode=0x02),
|
||||
_c("WRITE_AFI", ["afi"], lambda afi: iso15_frame(0x27, bytes([_int(afi)])),
|
||||
"write the AFI byte (0x27)", opcode=0x27),
|
||||
_c("LOCK_AFI", [], lambda: iso15_frame(0x28), "lock the AFI (0x28)", opcode=0x28),
|
||||
_c("WRITE_DSFID", ["dsfid"], lambda dsfid: iso15_frame(0x29, bytes([_int(dsfid)])),
|
||||
"write the DSFID byte (0x29)", opcode=0x29),
|
||||
_c("LOCK_DSFID", [], lambda: iso15_frame(0x2A), "lock the DSFID (0x2A)", opcode=0x2A),
|
||||
_c("GET_SYSTEM_INFO", [], lambda: iso15_frame(0x2B), "get system information (0x2B)", opcode=0x2B),
|
||||
_c("GET_SECURITY", ["first", "count"],
|
||||
lambda first, count: iso15_frame(0x2C, bytes([_int(first), _int(count) - 1])),
|
||||
"get block security (lock) status (0x2C)", opcode=0x2C),
|
||||
# NXP custom (manufacturer code 0x04 inserted by the builder)
|
||||
_c("GET_NXP_SYSTEM_INFO", [], lambda: iso15_frame(0xAB, mfg=NXP_MFG),
|
||||
"NXP system info — protection pointer, lock, features (0xAB)", opcode=0xAB),
|
||||
_c("GET_RANDOM", [], lambda: iso15_frame(0xB2, mfg=NXP_MFG),
|
||||
"get a 16-bit challenge to XOR the password with (0xB2)", opcode=0xB2),
|
||||
_c("SET_PASSWORD", ["pwd_id", "xor_pwd"],
|
||||
lambda pwd_id, xor_pwd: iso15_frame(0xB3, bytes([_int(pwd_id)]) + _hex(xor_pwd), mfg=NXP_MFG),
|
||||
"present a password (4 bytes XOR'd with GET_RANDOM) (0xB3)", opcode=0xB3),
|
||||
_c("WRITE_PASSWORD", ["pwd_id", "pwd"],
|
||||
lambda pwd_id, pwd: iso15_frame(0xB4, bytes([_int(pwd_id)]) + _hex(pwd), mfg=NXP_MFG),
|
||||
"write a new password (0xB4)", opcode=0xB4),
|
||||
_c("LOCK_PASSWORD", ["pwd_id"],
|
||||
lambda pwd_id: iso15_frame(0xB5, bytes([_int(pwd_id)]), mfg=NXP_MFG),
|
||||
"lock a password (0xB5)", opcode=0xB5),
|
||||
_c("PROTECT_PAGE", ["page", "prot"],
|
||||
lambda page, prot: iso15_frame(0xB6, bytes([_int(page), _int(prot)]), mfg=NXP_MFG),
|
||||
"set a page's read/write protection (0xB6)", opcode=0xB6),
|
||||
_c("LOCK_PAGE_PROTECTION", ["page"],
|
||||
lambda page: iso15_frame(0xB7, bytes([_int(page)]), mfg=NXP_MFG),
|
||||
"lock a page's protection condition (0xB7)", opcode=0xB7),
|
||||
_c("SET_EAS", [], lambda: iso15_frame(0xA2, mfg=NXP_MFG), "set the EAS bit (0xA2)", opcode=0xA2),
|
||||
_c("RESET_EAS", [], lambda: iso15_frame(0xA3, mfg=NXP_MFG), "clear the EAS bit (0xA3)", opcode=0xA3),
|
||||
_c("LOCK_EAS", [], lambda: iso15_frame(0xA4, mfg=NXP_MFG), "lock the EAS bit (0xA4)", opcode=0xA4),
|
||||
_c("EAS_ALARM", [], lambda: iso15_frame(0xA5, mfg=NXP_MFG), "read the EAS alarm sequence (0xA5)", opcode=0xA5),
|
||||
_c("PROTECT_EAS_AFI", [], lambda: iso15_frame(0xA6, mfg=NXP_MFG),
|
||||
"password-protect EAS/AFI (0xA6)", opcode=0xA6),
|
||||
_c("WRITE_EAS_ID", ["eas_id"], lambda eas_id: iso15_frame(0xA7, _hex(eas_id), mfg=NXP_MFG),
|
||||
"write the 16-bit EAS ID (0xA7)", opcode=0xA7),
|
||||
_c("ENABLE_PRIVACY", ["xor_pwd"], lambda xor_pwd: iso15_frame(0xBA, _hex(xor_pwd), mfg=NXP_MFG),
|
||||
"enter privacy mode with the privacy password (0xBA)", opcode=0xBA),
|
||||
_c("DESTROY", ["xor_pwd"], lambda xor_pwd: iso15_frame(0xB9, _hex(xor_pwd), mfg=NXP_MFG),
|
||||
"permanently destroy the tag — IRREVERSIBLE (0xB9)", opcode=0xB9),
|
||||
)
|
||||
|
||||
|
||||
@@ -301,7 +420,7 @@ def catalog_for(session) -> Catalog | None:
|
||||
"""Resolve the command catalog for the session's identified transponder, or None."""
|
||||
name = (session.transponder or "").upper()
|
||||
if session.protocol == "hf15":
|
||||
return ISO15
|
||||
return _SLIX if "SLIX" in name or "ICODE" in name else ISO15
|
||||
if session.protocol == "hf14a":
|
||||
if not session.transponder:
|
||||
return None
|
||||
|
||||
@@ -19,6 +19,7 @@ from prompt_toolkit.completion import Completer, Completion
|
||||
from .parser import CONTROL_VERBS, parse_token
|
||||
from .catalog import catalog_for
|
||||
from .memory import landmark_pages
|
||||
from .entry import is_byte_line
|
||||
|
||||
_HEXDIGITS = set("0123456789abcdefABCDEF")
|
||||
_BINDIGITS = set("01")
|
||||
@@ -50,6 +51,12 @@ class RawCompleter(Completer):
|
||||
tokens = stripped.split()
|
||||
at_word = bool(tokens) and not stripped.endswith(" ")
|
||||
|
||||
# ISO 15693 raw frame is position-aware: byte 0 = flags, byte 1 = command, byte 2 = mfg
|
||||
# (a bitfield-first frame, unlike 14a where byte 0 is the opcode).
|
||||
if getattr(self._session, "protocol", None) == "hf15" and is_byte_line(stripped):
|
||||
yield from self._iso15_raw(tokens, at_word)
|
||||
return
|
||||
|
||||
# raw byte entry, on the byte after a page-command opcode (30 …): offer the memory map as
|
||||
# raw bytes, so `30 04` gets the same suggestions as READ( — inserting the raw value
|
||||
on_second = (len(tokens) == 2 and at_word) or (len(tokens) == 1 and not at_word)
|
||||
@@ -108,6 +115,59 @@ class RawCompleter(Completer):
|
||||
yield Completion(val, start_position=-len(partial),
|
||||
display=f"{val} {role}", display_meta=role)
|
||||
|
||||
def _raw_base(self, partial: str) -> tuple[str, str]:
|
||||
"""(base, body) for a raw-frame byte token — a 0x/0b prefix wins, else the session mode."""
|
||||
low = partial.lower()
|
||||
if low.startswith("0x"):
|
||||
return "hex", low[2:]
|
||||
if low.startswith("0b"):
|
||||
return "bin", low[2:]
|
||||
return ("bin" if getattr(self._session, "entry_mode", "hex") == "bin" else "hex"), low
|
||||
|
||||
def _iso15_byte(self, value: int, partial: str, label: str, meta: str):
|
||||
"""A single raw-byte completion rendered in the entry base, filtered by ``partial``."""
|
||||
base, body = self._raw_base(partial)
|
||||
val = f"{value:08b}" if base == "bin" else f"{value:02X}"
|
||||
if body and not val.lower().startswith(body):
|
||||
return None
|
||||
return Completion(val, start_position=-len(partial), display=f"{val} {label}", display_meta=meta)
|
||||
|
||||
def _iso15_raw(self, tokens: list[str], at_word: bool):
|
||||
"""Position-aware completion for an ISO 15693 raw frame: byte 0 = the request-flags menu
|
||||
(each value decoded), byte 1 = the command, byte 2 = the NXP mfg code on custom commands."""
|
||||
from .catalog import ISO15_FLAG_LANDMARKS, iso15_flags_decode, NXP_MFG
|
||||
pos = (len(tokens) - 1) if at_word else len(tokens)
|
||||
partial = tokens[pos] if (at_word and pos < len(tokens)) else ""
|
||||
if pos == 0: # flags byte — the header menu + decode
|
||||
for f in ISO15_FLAG_LANDMARKS:
|
||||
c = self._iso15_byte(f, partial, iso15_flags_decode(f), iso15_flags_decode(f))
|
||||
if c:
|
||||
yield c
|
||||
elif pos == 1: # command byte
|
||||
catalog = catalog_for(self._session)
|
||||
if catalog is None:
|
||||
return
|
||||
for name in catalog.names():
|
||||
op = catalog.get(name).opcode()
|
||||
if op is None:
|
||||
continue
|
||||
c = self._iso15_byte(op, partial, name, catalog.get(name).help)
|
||||
if c:
|
||||
yield c
|
||||
elif pos == 2: # mfg byte, only after a custom command
|
||||
cmd = self._token_byte(tokens[1]) if len(tokens) > 1 else None
|
||||
if cmd is not None and cmd >= 0xA0:
|
||||
c = self._iso15_byte(NXP_MFG, partial, "NXP manufacturer code", "NXP custom (0x04)")
|
||||
if c:
|
||||
yield c
|
||||
|
||||
def _token_byte(self, tok: str) -> int | None:
|
||||
try:
|
||||
b = parse_token(tok, getattr(self._session, "entry_mode", "hex"))
|
||||
except ValueError:
|
||||
return None
|
||||
return b[0] if len(b) == 1 else None
|
||||
|
||||
def _page_arg(self, cmd_name: str, partial: str):
|
||||
"""Complete a page/block argument with the identified tag's memory landmarks, rendered in
|
||||
the active entry mode — ``0x04`` in hex, ``0b00000100`` in binary (every bit visible,
|
||||
|
||||
@@ -78,6 +78,41 @@ def name_14a(scan: dict) -> str:
|
||||
return "ISO14443-A"
|
||||
|
||||
|
||||
def _iso15_uid(dev) -> str | None:
|
||||
"""Get a 15693 tag's UID robustly. The anticollision inventory misses often on vicinity tags,
|
||||
and this link also returns the odd garbage frame — so retry both the inventory and a direct
|
||||
GET_SYSTEM_INFO (0x2B, which carries the UID), validating each response. Returns display-order
|
||||
UID hex, or None."""
|
||||
for _ in range(3):
|
||||
scan = _try(lambda: dev.hf.iso15.scan(), {"found": False})
|
||||
uid = scan.get("uid")
|
||||
if scan.get("found") and isinstance(uid, str) and uid.lower().startswith("e0"):
|
||||
return uid
|
||||
for _ in range(4): # GET_SYSTEM_INFO: [flags][info][UID(8)]..
|
||||
resp = _try(lambda: dev.hf.iso15.raw(b"\x02\x2b"), None)
|
||||
raw = resp.get("raw") if isinstance(resp, dict) else None
|
||||
if raw and len(raw) >= 10 and raw[0] == 0x00 and raw[9] == 0xE0: # UID LSB-first ends in E0
|
||||
return bytes(raw[2:10])[::-1].hex()
|
||||
return None
|
||||
|
||||
|
||||
def name_iso15(dev, uid15: str) -> str:
|
||||
"""Name a 15693 tag from its UID + a probe. ``E0 04`` = NXP ICODE; distinguish ICODE SLIX
|
||||
(no READ_SIGNATURE) from SLIX2 (supports it, 0xBD). Non-NXP falls back to generic ISO15693.
|
||||
(ICODE DNA / NTAG5 also share E0 04 and would need a finer probe — a later refinement.)"""
|
||||
if not uid15.lower().startswith("e004"):
|
||||
return "ISO15693"
|
||||
# READ_SIGNATURE (0xBD): SLIX2 returns a 32-byte ECC signature (response >= 33 bytes with the
|
||||
# flags byte); plain SLIX ignores the command. Require the full length so a flaky/stale frame
|
||||
# can't false-positive; probe twice in case the real signature frame is dropped.
|
||||
for _ in range(2):
|
||||
sig = _try(lambda: dev.hf.iso15.raw(b"\x02\xbd\x04"), None)
|
||||
raw = sig.get("raw") if isinstance(sig, dict) else None
|
||||
if raw is not None and len(raw) >= 33 and raw[0] == 0x00:
|
||||
return "ICODE SLIX2"
|
||||
return "ICODE SLIX"
|
||||
|
||||
|
||||
def _ident_decode(direction: int, payload: bytes) -> str | None:
|
||||
"""Annotator for the identify probe frames (activation + GET_VERSION)."""
|
||||
if not payload:
|
||||
@@ -169,14 +204,14 @@ def identify(session, emit=None) -> dict:
|
||||
"transponder": session.transponder, "uid": scan.get("uid"),
|
||||
"version": version.hex() if version else None}
|
||||
|
||||
# ISO 15693 — require found AND a real E0-prefixed UID, else a 14a miss reads as a bogus tag.
|
||||
scan15 = _try(lambda: dev.hf.iso15.scan(), {"found": False})
|
||||
uid15 = scan15.get("uid")
|
||||
if scan15.get("found") and isinstance(uid15, str) and uid15.lower().startswith("e0"):
|
||||
# ISO 15693 — require a real E0-prefixed UID (inventory, or a direct GET_SYSTEM_INFO fallback
|
||||
# for flaky vicinity anticollision), else a 14a miss reads as a bogus tag.
|
||||
uid15 = _iso15_uid(dev)
|
||||
if isinstance(uid15, str) and uid15.lower().startswith("e0"):
|
||||
session.field, session.protocol = "hf", "hf15"
|
||||
session.transponder = "ISO15693"
|
||||
session.transponder = name_iso15(dev, uid15)
|
||||
return {"found": True, "field": "hf", "protocol": "15693",
|
||||
"transponder": "ISO15693", "uid": uid15}
|
||||
"transponder": session.transponder, "uid": uid15}
|
||||
|
||||
# LF: no HF tag, so demodulate the field. pm3py.lf captures the envelope and decodes it —
|
||||
# what the tag emits (EM4100 ID, …) and, via a block-0 read, whether it's actually a T5577
|
||||
|
||||
@@ -133,14 +133,29 @@ def _t5577_block_role(block: int | None) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _is_iso15(transponder: str | None) -> bool:
|
||||
up = (transponder or "").upper()
|
||||
return "SLIX" in up or "ICODE" in up
|
||||
|
||||
|
||||
def _iso15_block_role(block: int | None) -> str | None:
|
||||
"""ISO 15693 / ICODE blocks are uniform 4-byte user-memory blocks; AFI / DSFID / EAS / config
|
||||
live behind commands, not block addresses."""
|
||||
if block is None or not (0 <= block <= 255):
|
||||
return None
|
||||
return "user memory (4-byte block)"
|
||||
|
||||
|
||||
def page_role(transponder: str | None, page: int | None) -> str | None:
|
||||
"""The role of ``page``/``block`` on ``transponder`` — the full Type 2 header (UID / static
|
||||
lock / CC-or-OTP), user memory, dynamic lock, CFG0/CFG1 (family-accurate fields), PWD, PACK —
|
||||
or a T5577 block role, or None when unknown."""
|
||||
or a T5577 block role, or an ISO15693 block, or None when unknown."""
|
||||
if transponder and "T5577" in transponder.upper():
|
||||
return _t5577_block_role(page)
|
||||
if _mfc_size(transponder):
|
||||
return _mfc_block_role(transponder, page)
|
||||
if _is_iso15(transponder):
|
||||
return _iso15_block_role(page)
|
||||
layout = _resolve_layout(transponder)
|
||||
if layout is None or page is None:
|
||||
return None
|
||||
@@ -174,6 +189,8 @@ def landmark_pages(transponder: str | None) -> list[tuple[int, str]]:
|
||||
return [(b, _t5577_block_role(b)) for b in range(8)]
|
||||
if _mfc_size(transponder):
|
||||
return _mfc_landmarks(transponder)
|
||||
if _is_iso15(transponder):
|
||||
return [(0, "user memory (4-byte block)")] # uniform blocks; AFI/DSFID/EAS via commands
|
||||
layout = _resolve_layout(transponder)
|
||||
if layout is None:
|
||||
return []
|
||||
|
||||
@@ -27,6 +27,12 @@ _READ_FLAGS = ISO15_CONNECT | ISO15_HIGH_SPEED | ISO15_READ_RESPONSE # 0x31
|
||||
# (mirrors the C client's hf_15_write_blk).
|
||||
_WRITE_FLAGS = ISO15_CONNECT | ISO15_READ_RESPONSE | ISO15_LONG_WAIT # 0x61
|
||||
|
||||
# Command bytes that program the tag and so need the long tag-ack window (raw() auto-detects these
|
||||
# from iso_cmd[1]): std write/lock, EAS writes, password/protect/destroy/privacy.
|
||||
_WRITE_CMDS = frozenset({0x21, 0x22, 0x24, 0x27, 0x28, 0x29, 0x2A,
|
||||
0xA2, 0xA3, 0xA4, 0xA6, 0xA7,
|
||||
0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB9, 0xBA})
|
||||
|
||||
|
||||
def _iso15_payload(iso_cmd: bytes, flags: int = _READ_FLAGS) -> bytes:
|
||||
"""Build a HF_ISO15693_COMMAND payload from a raw ISO15693 command.
|
||||
@@ -204,16 +210,19 @@ class HF15Commands:
|
||||
success = resp.status == 0 and len(resp.data) > 0 and resp.data[0] == 0
|
||||
return {"success": success, "block": block}
|
||||
|
||||
async def raw(self, iso_cmd: bytes | str, long_wait: bool = False,
|
||||
async def raw(self, iso_cmd: bytes | str, long_wait: bool | None = None,
|
||||
timeout: float = 5.0) -> dict:
|
||||
"""Send a raw ISO15693 request frame and return the tag's response.
|
||||
|
||||
``iso_cmd`` is the request **without** the CRC (flags | command | [mfg] | [UID] | params);
|
||||
the ISO15693 CRC is appended host-side. ``long_wait`` uses the write-length tag-ack timeout
|
||||
(needed for WRITE/LOCK-class commands). The returned ``raw`` is the firmware-relayed tag
|
||||
response (response-flags + data + its CRC)."""
|
||||
(needed for WRITE/LOCK-class commands); ``None`` (default) auto-selects it from the command
|
||||
byte. The returned ``raw`` is the firmware-relayed tag response (response-flags + data +
|
||||
its CRC)."""
|
||||
if isinstance(iso_cmd, str):
|
||||
iso_cmd = bytes.fromhex(iso_cmd.replace(" ", ""))
|
||||
if long_wait is None:
|
||||
long_wait = (len(iso_cmd) > 1 and iso_cmd[1] in _WRITE_CMDS)
|
||||
payload = _iso15_payload(iso_cmd, flags=_WRITE_FLAGS if long_wait else _READ_FLAGS)
|
||||
resp = await self._t.send_ng(Cmd.HF_ISO15693_COMMAND, payload, timeout=timeout)
|
||||
data = bytes(resp.data)
|
||||
|
||||
@@ -11,7 +11,8 @@ 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.entry import type_digit, is_byte_line, reconcile_bases
|
||||
from pm3py.cli.rawcli.identify import identify, clear, name_14a, format_summary
|
||||
from pm3py.cli.rawcli.catalog import TYPE2, ISO15, T5577, LF_READONLY, MFC, catalog_for, catalog_for as _cf
|
||||
from pm3py.cli.rawcli.catalog import (TYPE2, ISO15, T5577, LF_READONLY, MFC, _SLIX,
|
||||
iso15_frame, iso15_flags_decode, catalog_for, catalog_for as _cf)
|
||||
from pm3py.cli.rawcli.tlv import parse_tlv, format_tlv
|
||||
from pm3py.cli.rawcli.completer import RawCompleter
|
||||
from pm3py.cli.rawcli.app import dispatch
|
||||
@@ -249,9 +250,16 @@ class TestIdentify:
|
||||
assert page_role(s.transponder, 4) == "user memory"
|
||||
|
||||
def test_15693_when_e0_uid(self):
|
||||
# E0 04 = NXP ICODE; no READ_SIGNATURE (mock) -> named ICODE SLIX, not generic ISO15693
|
||||
s = RawSession(device=_mock_device(scan15={"found": True, "uid": "e004010203040506"}))
|
||||
r = identify(s)
|
||||
assert r["found"] and s.field == "hf" and s.protocol == "hf15"
|
||||
assert s.transponder == "ICODE SLIX"
|
||||
|
||||
def test_15693_non_nxp_generic(self):
|
||||
# a non-NXP E0 UID stays generic ISO15693
|
||||
s = RawSession(device=_mock_device(scan15={"found": True, "uid": "e007010203040506"}))
|
||||
identify(s)
|
||||
assert s.transponder == "ISO15693"
|
||||
|
||||
def test_15693_rejects_bogus_uid(self):
|
||||
@@ -418,8 +426,37 @@ class TestCatalog:
|
||||
assert "not yet wired" in MFC.get("PERSONALIZE_UID").run(dev, "2") # EV1 opcode-only
|
||||
|
||||
def test_iso15_builds(self):
|
||||
# 15693 frames are flags | command | [mfg] | params, assembled by iso15_frame
|
||||
assert ISO15.get("READ_BLOCK").build("4") == b"\x02\x20\x04"
|
||||
assert ISO15.get("INVENTORY").build() == b"\x26\x01\x00"
|
||||
assert ISO15.get("GET_SYSTEM_INFO").build() == b"\x02\x2B"
|
||||
assert ISO15.get("READ_BLOCK").opcode() == 0x20 # opcode = the command byte, not flags
|
||||
|
||||
def test_iso15_frame_builder(self):
|
||||
assert iso15_frame(0x20, bytes([4])) == b"\x02\x20\x04" # non-addressed, high rate
|
||||
assert iso15_frame(0xB2, mfg=0x04) == b"\x02\xB2\x04" # NXP custom -> mfg byte
|
||||
# addressed: flags 0x22, UID appended LSByte-first
|
||||
f = iso15_frame(0x20, bytes([4]), uid="e004010203040506", addressed=True)
|
||||
assert f == bytes.fromhex("2220") + bytes.fromhex("e004010203040506")[::-1] + bytes([4])
|
||||
assert iso15_frame(0x20, bytes([4]), option=True) == b"\x42\x20\x04"
|
||||
|
||||
def test_iso15_flags_decode(self):
|
||||
assert iso15_flags_decode(0x02) == "high rate · non-addressed"
|
||||
assert iso15_flags_decode(0x22) == "high rate · addressed"
|
||||
assert "option" in iso15_flags_decode(0x42)
|
||||
assert "inventory" in iso15_flags_decode(0x26)
|
||||
|
||||
def test_slix_catalog(self):
|
||||
# ICODE SLIX datasheet command set: standard + NXP custom, frames via the header builder
|
||||
assert _SLIX.get("READ_BLOCK").build("4") == b"\x02\x20\x04"
|
||||
assert _SLIX.get("GET_RANDOM").build() == b"\x02\xB2\x04" # NXP mfg inserted
|
||||
assert _SLIX.get("SET_PASSWORD").build("4", "DEADBEEF") == bytes.fromhex("02b30404deadbeef")
|
||||
assert _SLIX.get("READ_MULTIPLE").build("0", "4") == b"\x02\x23\x00\x03" # count-1 on wire
|
||||
# opcode = the command byte (frame byte 1), so by_opcode maps correctly
|
||||
assert _SLIX.get("GET_RANDOM").opcode() == 0xB2
|
||||
assert _SLIX.by_opcode(0xB2).name == "GET_RANDOM" and _SLIX.by_opcode(0x20).name == "READ_BLOCK"
|
||||
# dispatch: an identified SLIX (or generic ICODE) resolves to this catalog
|
||||
s = RawSession(); s.protocol, s.transponder = "hf15", "ICODE SLIX"
|
||||
assert catalog_for(s) is _SLIX
|
||||
|
||||
def test_resolver(self):
|
||||
s = RawSession()
|
||||
@@ -615,6 +652,24 @@ class TestCompleter:
|
||||
gv = next(c for c in cs if "GET_VERSION" in self._disp(c))
|
||||
assert gv.text == "01100000" # raw binary byte, not the command name
|
||||
|
||||
def test_iso15_position_aware_completion(self):
|
||||
# 15693 raw frame: byte 0 = flags menu (decoded), byte 1 = command, byte 2 = NXP mfg
|
||||
s = RawSession()
|
||||
s.protocol, s.transponder = "hf15", "ICODE SLIX"
|
||||
flags = {c.text: c.display_meta_text for c in self._complete(s, "")}
|
||||
assert flags["02"] == "high rate · non-addressed"
|
||||
assert flags["22"] == "high rate · addressed"
|
||||
cmds = {c.text: self._disp(c) for c in self._complete(s, "02 ")} # after flags -> command
|
||||
assert "READ_BLOCK" in cmds["20"] and "GET_RANDOM" in cmds["B2"]
|
||||
assert "04" in [c.text for c in self._complete(s, "02 B2 ")] # custom -> mfg byte
|
||||
assert self._complete(s, "02 20 ") == [] # standard cmd -> no mfg
|
||||
# function-call form still gets the block map, command names still complete by letters
|
||||
assert any("user memory" in (c.display_meta_text or "") for c in self._complete(s, "READ_BLOCK("))
|
||||
assert any("GET_RANDOM" in self._disp(c) for c in self._complete(s, "get_r"))
|
||||
# binary flags render as 8 bits
|
||||
s.entry_mode = "bin"
|
||||
assert any(c.text == "00000010" for c in self._complete(s, ""))
|
||||
|
||||
def test_mfc_opcode_completion(self):
|
||||
# the new MIFARE Classic datasheet opcodes surface by hex AND binary value, inserting the byte
|
||||
s = RawSession()
|
||||
|
||||
Reference in New Issue
Block a user