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

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