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:
michael
2026-07-16 09:32:09 -07:00
parent 9c54015735
commit 6ab8582308
6 changed files with 319 additions and 24 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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 []

View File

@@ -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)

View File

@@ -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()