From 6ab8582308d73fbd5d5e61d7ef745a1d912d0425 Mon Sep 17 00:00:00 2001 From: michael Date: Thu, 16 Jul 2026 09:32:09 -0700 Subject: [PATCH] =?UTF-8?q?feat(rawcli):=20ICODE=20SLIX=20datasheet=20cata?= =?UTF-8?q?log=20=E2=80=94=2015693=20header=20builder=20+=20position-aware?= =?UTF-8?q?=20completion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pm3py/cli/rawcli/catalog.py | 143 +++++++++++++++++++++++++++++++--- pm3py/cli/rawcli/completer.py | 60 ++++++++++++++ pm3py/cli/rawcli/identify.py | 47 +++++++++-- pm3py/cli/rawcli/memory.py | 19 ++++- pm3py/core/hf_iso15.py | 15 +++- tests/test_rawcli.py | 59 +++++++++++++- 6 files changed, 319 insertions(+), 24 deletions(-) diff --git a/pm3py/cli/rawcli/catalog.py b/pm3py/cli/rawcli/catalog.py index 0f287f0..2daf153 100644 --- a/pm3py/cli/rawcli/catalog.py +++ b/pm3py/cli/rawcli/catalog.py @@ -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 blocks from (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 diff --git a/pm3py/cli/rawcli/completer.py b/pm3py/cli/rawcli/completer.py index 45cfdf1..e831af2 100644 --- a/pm3py/cli/rawcli/completer.py +++ b/pm3py/cli/rawcli/completer.py @@ -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, diff --git a/pm3py/cli/rawcli/identify.py b/pm3py/cli/rawcli/identify.py index c41ed0c..0535ce5 100644 --- a/pm3py/cli/rawcli/identify.py +++ b/pm3py/cli/rawcli/identify.py @@ -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 diff --git a/pm3py/cli/rawcli/memory.py b/pm3py/cli/rawcli/memory.py index bd978c6..74633c3 100644 --- a/pm3py/cli/rawcli/memory.py +++ b/pm3py/cli/rawcli/memory.py @@ -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 [] diff --git a/pm3py/core/hf_iso15.py b/pm3py/core/hf_iso15.py index 3beea31..03bcd1b 100644 --- a/pm3py/core/hf_iso15.py +++ b/pm3py/core/hf_iso15.py @@ -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) diff --git a/tests/test_rawcli.py b/tests/test_rawcli.py index f4d805c..c1b8017 100644 --- a/tests/test_rawcli.py +++ b/tests/test_rawcli.py @@ -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()