diff --git a/docs/plans/2026-03-17-table-compiler-update-plan.md b/docs/plans/2026-03-17-table-compiler-update-plan.md new file mode 100644 index 0000000..9d19789 --- /dev/null +++ b/docs/plans/2026-03-17-table-compiler-update-plan.md @@ -0,0 +1,716 @@ +# TableCompiler Update Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Update TableEntry to the new 120-byte firmware format with EML actions and groups, add NXP ICODE and SLIX2 compiler methods, and wire automatic table compilation into SimSession. + +**Architecture:** Replace old 132-byte TableEntry (with match_mask) with new 120-byte format (with eml_action, groups, set_auth). Add `compile_nxp_icode()` and `compile_slix2()` to TableCompiler. SimSession auto-compiles and uploads table after sim starts. + +**Tech Stack:** Python, pytest, existing sim-framework worktree + +**Working directory:** `/home/work/pm3py/.worktrees/sim-framework/` + +**Test command:** `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/ -v` + +--- + +### Task 1: Update TableEntry to new 120-byte format + +**Files:** +- Modify: `pm3py/sim/table_compiler.py` +- Modify: `tests/test_sim_table_compiler.py` + +**Step 1: Write failing tests for new format** + +Replace the `TestTableEntry` class in `tests/test_sim_table_compiler.py`: + +```python +class TestTableEntry: + def test_serialize_size(self): + entry = TableEntry(match=b"\x26", response=b"\x04\x00") + raw = entry.serialize() + assert len(raw) == 120 + + def test_serialize_roundtrip(self): + entry = TableEntry( + match=b"\x26", + match_mode=0, + response=b"\x04\x00", + response_flags=0x01, + ) + raw = entry.serialize() + parsed = TableEntry.deserialize(raw) + assert parsed.match == b"\x26" + assert parsed.response == b"\x04\x00" + assert parsed.match_mode == 0 + assert parsed.response_flags == 0x01 + + def test_serialize_eml_fields(self): + entry = TableEntry( + match=b"\x02\xA1\x04", + match_mode=1, + response=bytes([0x00]), + eml_action=1, + eml_offset=2040, + eml_len=1, + eml_resp_insert=1, + ) + raw = entry.serialize() + parsed = TableEntry.deserialize(raw) + assert parsed.eml_action == 1 + assert parsed.eml_offset == 2040 + assert parsed.eml_len == 1 + assert parsed.eml_resp_insert == 1 + + def test_serialize_group_fields(self): + entry = TableEntry( + match=b"\x02\xB3\x04\x04", + response=bytes([0x00]), + group=1, + activate_groups=0x06, + set_auth=0x04, + ) + raw = entry.serialize() + parsed = TableEntry.deserialize(raw) + assert parsed.group == 1 + assert parsed.activate_groups == 0x06 + assert parsed.set_auth == 0x04 + + def test_serialize_consume_flag(self): + entry = TableEntry(match=b"\x26", response=b"\x04\x00", flags=0x02) + raw = entry.serialize() + parsed = TableEntry.deserialize(raw) + assert parsed.flags & 0x02 + + def test_exact_match(self): + entry = TableEntry(match=b"\x26", response=b"\x04\x00") + assert entry.matches(b"\x26") + assert not entry.matches(b"\x52") + + def test_prefix_match(self): + entry = TableEntry(match=b"\x93\x20", match_mode=1, response=b"\x01\x02\x03\x04\x04") + assert entry.matches(b"\x93\x20") + assert entry.matches(b"\x93\x20\xFF\xFF") + assert not entry.matches(b"\x95\x20") +``` + +**Step 2: Run tests to see them fail** + +Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_table_compiler.py::TestTableEntry -v` +Expected: FAIL — old constructor expects positional args + +**Step 3: Update TableEntry in table_compiler.py** + +Replace the `TableEntry` dataclass, `ENTRY_SIZE`, remove `MATCH_MASKED`: + +```python +# Match modes +MATCH_EXACT = 0 +MATCH_PREFIX = 1 + +# Entry size in firmware format (must match sim_table_entry_t) +ENTRY_SIZE = 120 +SIM_TABLE_MAX_MATCH = 32 +SIM_TABLE_MAX_RESPONSE = 64 + +# Response flags +RESP_FLAG_CRC = 0x01 + +# Entry flags +ENTRY_FLAG_CONSUME = 0x02 + +# EML actions +EML_NONE = 0 +EML_READ = 1 +EML_WRITE = 2 +EML_AES_CMAC = 3 + +# Config register offset in iso15_tag_t.data[] +CONFIG_REG_OFFSET = 2040 + + +@dataclass +class TableEntry: + """One entry in the firmware response table (120 bytes packed).""" + match: bytes + match_mode: int = MATCH_EXACT + response: bytes = b"" + response_flags: int = 0 + eml_action: int = 0 + eml_offset: int = 0 + eml_len: int = 0 + eml_resp_insert: int = 0 + cmd_data_offset: int = 0 + cmd_data_len: int = 0 + group: int = 0 + activate_groups: int = 0 + deactivate_groups: int = 0 + set_auth: int = 0 + clear_auth: int = 0 + flags: int = 0 + + def serialize(self) -> bytes: + """Pack into 120-byte firmware format.""" + buf = bytearray(ENTRY_SIZE) + off = 0 + + # match[32] + match_len[1] + match_mode[1] + m = self.match[:SIM_TABLE_MAX_MATCH] + buf[off:off + len(m)] = m + off += SIM_TABLE_MAX_MATCH + buf[off] = len(m) + buf[off + 1] = self.match_mode + off += 2 + + # response[64] + response_len[1] + response_flags[1] + r = self.response[:SIM_TABLE_MAX_RESPONSE] + buf[off:off + len(r)] = r + off += SIM_TABLE_MAX_RESPONSE + buf[off] = len(r) + buf[off + 1] = self.response_flags + off += 2 + + # eml_action[1] + eml_offset[2 LE] + eml_len[1] + eml_resp_insert[1] + # + cmd_data_offset[1] + cmd_data_len[1] + buf[off] = self.eml_action + struct.pack_into(" TableEntry: + """Unpack from 120-byte firmware format.""" + off = 0 + match_len = data[SIM_TABLE_MAX_MATCH] + match = bytes(data[0:match_len]) + match_mode = data[SIM_TABLE_MAX_MATCH + 1] + off = SIM_TABLE_MAX_MATCH + 2 + + resp_len = data[off + SIM_TABLE_MAX_RESPONSE] + response = bytes(data[off:off + resp_len]) + response_flags = data[off + SIM_TABLE_MAX_RESPONSE + 1] + off += SIM_TABLE_MAX_RESPONSE + 2 + + eml_action = data[off] + eml_offset = struct.unpack_from(" bool: + """Check if a command matches this entry.""" + if self.match_mode == MATCH_EXACT: + return cmd == self.match + elif self.match_mode == MATCH_PREFIX: + return len(cmd) >= len(self.match) and cmd[:len(self.match)] == self.match + return False +``` + +**Step 4: Update all existing callers and tests** + +Replace old positional constructor calls throughout `table_compiler.py` and `tests/test_sim_table_compiler.py`: + +Old: `TableEntry(b"\x26", 0, b"", b"\x04\x00", 0)` +New: `TableEntry(match=b"\x26", response=b"\x04\x00")` + +Old: `flags=0x01` (CRC) +New: `response_flags=0x01` + +Old: `flags=0x02` (consume) +New: `flags=0x02` (unchanged — same field name, same bit) + +Old: `match_mask=b""` +New: removed + +Update `ResponseTable.serialize` test: `assert len(raw) == 240` (2 * 120, not 264) + +Update `SimSession.upload_table` test: `assert len(call_args[0][1]) == 120` (not 132) + +Update `from_trace`: remove `match_mask=b""` parameter + +**Step 5: Run all tests** + +Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/ -v` +Expected: All pass + +**Step 6: Commit** + +```bash +cd /home/work/pm3py/.worktrees/sim-framework +git add pm3py/sim/table_compiler.py tests/test_sim_table_compiler.py +git commit --no-gpg-sign -m "feat: update TableEntry to 120-byte format with EML actions and groups" +``` + +--- + +### Task 2: Add compile_nxp_icode method + +**Files:** +- Modify: `pm3py/sim/table_compiler.py` +- Modify: `tests/test_sim_table_compiler.py` + +**Step 1: Write failing tests** + +Append to `tests/test_sim_table_compiler.py`: + +```python +from pm3py.sim.nxp_icode import NxpIcodeTag +from pm3py.sim.table_compiler import CONFIG_REG_OFFSET, EML_READ, EML_WRITE + + +class TestTableCompilerNxpIcode: + def test_compile_has_read_config_entries(self): + tag = NxpIcodeTag(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06") + table = TableCompiler.compile_nxp_icode(tag) + + # READ_CONFIG reg 0 (unaddressed normalized) + entry = table.lookup(bytes([0x02, 0xA1, 0x04, 0x00])) + assert entry is not None + assert entry.eml_action == EML_READ + assert entry.eml_offset == CONFIG_REG_OFFSET + assert entry.eml_len == 1 + assert entry.eml_resp_insert == 1 # after flags byte + + def test_compile_has_write_config_entries(self): + tag = NxpIcodeTag(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06") + table = TableCompiler.compile_nxp_icode(tag) + + entry = table.lookup(bytes([0x02, 0xA2, 0x04, 0x00])) + assert entry is not None + assert entry.eml_action == EML_WRITE + assert entry.eml_offset == CONFIG_REG_OFFSET + + def test_compile_has_get_random(self): + tag = NxpIcodeTag(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06") + table = TableCompiler.compile_nxp_icode(tag) + + entry = table.lookup(bytes([0x02, 0xB2, 0x04])) + assert entry is not None + assert len(entry.response) == 3 # flags + 2 random bytes + + def test_compile_all_8_config_regs(self): + tag = NxpIcodeTag(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06") + table = TableCompiler.compile_nxp_icode(tag) + + for reg in range(8): + entry = table.lookup(bytes([0x02, 0xA1, 0x04, reg])) + assert entry is not None, f"Missing READ_CONFIG reg {reg}" + assert entry.eml_offset == CONFIG_REG_OFFSET + reg + + def test_config_regs_in_eml_range(self): + """Config reg EML offsets must be within tag->data[2048].""" + tag = NxpIcodeTag(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06") + table = TableCompiler.compile_nxp_icode(tag) + + for entry in table.entries: + if entry.eml_action in (EML_READ, EML_WRITE): + assert entry.eml_offset + entry.eml_len <= 2048 +``` + +**Step 2: Run to verify failure** + +Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_table_compiler.py::TestTableCompilerNxpIcode -v` +Expected: FAIL — `AttributeError: type object 'TableCompiler' has no attribute 'compile_nxp_icode'` + +**Step 3: Implement compile_nxp_icode** + +Add to `TableCompiler` class in `table_compiler.py`: + +```python + @staticmethod + def compile_nxp_icode(tag) -> ResponseTable: + """Compile NXP ICODE/NTAG5 custom command entries. + + Generates table entries for READ/WRITE_CONFIG and GET_RANDOM. + Config registers stored in EML at data[2040:2048]. + All entries use unaddressed format (firmware normalizes addressed cmds). + """ + entries = [] + num_regs = tag._config_regs if hasattr(tag, '_config_regs') else bytearray(8) + num_config = len(num_regs) + + for reg in range(num_config): + # READ_CONFIG reg N: read 1 byte from EML, prefix with 0x00 + entries.append(TableEntry( + match=bytes([0x02, 0xA1, 0x04, reg]), + match_mode=MATCH_EXACT, + response=bytes([0x00]), + eml_action=EML_READ, + eml_offset=CONFIG_REG_OFFSET + reg, + eml_len=1, + eml_resp_insert=1, + )) + + # WRITE_CONFIG reg N: write 1 byte from cmd to EML + entries.append(TableEntry( + match=bytes([0x02, 0xA2, 0x04, reg]), + match_mode=MATCH_EXACT, + response=bytes([0x00]), + eml_action=EML_WRITE, + eml_offset=CONFIG_REG_OFFSET + reg, + eml_len=1, + cmd_data_offset=4, # value byte at position 4 in normalized cmd + )) + + # GET_RANDOM: return pre-picked random + import os + random_bytes = os.urandom(2) + entries.append(TableEntry( + match=bytes([0x02, 0xB2, 0x04]), + match_mode=MATCH_PREFIX, + response=bytes([0x00]) + random_bytes, + )) + + return ResponseTable(entries=entries) +``` + +**Step 4: Run tests** + +Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_table_compiler.py -v` +Expected: All pass + +**Step 5: Commit** + +```bash +cd /home/work/pm3py/.worktrees/sim-framework +git add pm3py/sim/table_compiler.py tests/test_sim_table_compiler.py +git commit --no-gpg-sign -m "feat: add TableCompiler.compile_nxp_icode for config registers + GET_RANDOM" +``` + +--- + +### Task 3: Add compile_slix2 method + +**Files:** +- Modify: `pm3py/sim/table_compiler.py` +- Modify: `tests/test_sim_table_compiler.py` + +**Step 1: Write failing tests** + +Append to `tests/test_sim_table_compiler.py`: + +```python +from pm3py.sim.icode_slix2 import IcodeSlix2Tag + + +class TestTableCompilerSlix2: + def test_compile_has_set_password(self): + tag = IcodeSlix2Tag( + uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06", + read_password=0x12345678, + ) + table = TableCompiler.compile_slix2(tag) + + # SET_PASSWORD prefix match (error for wrong password) + fallback = [e for e in table.entries if + len(e.match) >= 3 and e.match[1] == 0xB3 and + e.match_mode == MATCH_PREFIX] + assert len(fallback) > 0 + assert fallback[-1].response == bytes([0x01, 0x0F]) # error + + def test_compile_set_password_sets_auth(self): + tag = IcodeSlix2Tag( + uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06", + read_password=0x12345678, + ) + table = TableCompiler.compile_slix2(tag) + + # Correct password entry should set auth bit + auth_entries = [e for e in table.entries if e.set_auth != 0] + assert len(auth_entries) > 0 + + def test_compile_includes_nxp_entries(self): + """SLIX2 table should include NXP base entries.""" + tag = IcodeSlix2Tag(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06") + table = TableCompiler.compile_slix2(tag) + + # Should have READ_CONFIG from nxp_icode compiler + entry = table.lookup(bytes([0x02, 0xA1, 0x04, 0x00])) + assert entry is not None + + def test_compile_privacy_mode_groups(self): + """Privacy mode should use groups to disable inventory.""" + tag = IcodeSlix2Tag( + uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06", + privacy_password=0xAABBCCDD, + ) + table = TableCompiler.compile_slix2(tag) + + # Should have a SET_PASSWORD entry that activates a group + privacy_entries = [e for e in table.entries + if e.activate_groups != 0] + assert len(privacy_entries) > 0 +``` + +**Step 2: Run to verify failure** + +Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_sim_table_compiler.py::TestTableCompilerSlix2 -v` +Expected: FAIL — `AttributeError: 'TableCompiler' has no attribute 'compile_slix2'` + +**Step 3: Implement compile_slix2** + +Add to `TableCompiler` class: + +```python + @staticmethod + def compile_slix2(tag) -> ResponseTable: + """Compile ICODE SLIX2 entries: passwords + privacy + NXP base. + + Groups: + 0 = always active + 1 = normal operation (active unless privacy mode) + """ + # Start with NXP base entries (config regs, GET_RANDOM) + table = TableCompiler.compile_nxp_icode(tag) + + # Password auth bits: read=0x01, write=0x02, privacy=0x04, destroy=0x08 + PWD_AUTH_BITS = {0x01: 0x01, 0x02: 0x02, 0x04: 0x04, 0x08: 0x08} + + # GET_RANDOM is already in the table — get the random bytes for XOR + get_rnd = [e for e in table.entries if len(e.match) >= 2 and e.match[1] == 0xB2] + random_bytes = get_rnd[0].response[1:3] if get_rnd else b"\x00\x00" + + # SET_PASSWORD entries for each configured password + for pwd_id, password in tag._passwords.items(): + if password is None: + continue + + auth_bit = PWD_AUTH_BITS.get(pwd_id, 0) + + # Compute XORed password bytes (as reader would send) + import struct as st + pwd_bytes = st.pack("= 2 # at least sim start + table upload + + def test_start_15693_no_compile_by_default(self): + """start_15693 without compile should not upload table.""" + from pm3py.sim.sim_session import SimSession + from unittest.mock import MagicMock + + tag = NxpIcodeTag(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06") + + mock_ser = MagicMock() + mock_ser.in_waiting = 0 + mock_ser.read.return_value = b"" + + session = SimSession(port=mock_ser) + session.start_15693(tag, trace=False) + + # Should have written only the SIM command (+ maybe EML sync) + # No table upload + calls = mock_ser.write.call_args_list + # Check no frame contains CMD_SIM_TABLE_UPLOAD + from pm3py.sim.sim_session import CMD_SIM_TABLE_UPLOAD + import struct + for call in calls: + frame = call[0][0] + if len(frame) >= 10: + cmd_in_frame = struct.unpack_from(" None: +``` + +After `self._active = True` and before the trace setup, add: + +```python + if compile: + self._compile_and_upload_table(tag, ser) +``` + +Add the helper method: + +```python +def _compile_and_upload_table(self, tag, ser) -> None: + """Compile response table from tag model and upload to firmware.""" + from .table_compiler import TableCompiler, ResponseTable + + table = ResponseTable(entries=[]) + + # NXP ICODE base (config regs, GET_RANDOM) + if hasattr(tag, '_config_regs'): + nxp_table = TableCompiler.compile_nxp_icode(tag) + table.entries.extend(nxp_table.entries) + + # SLIX2 (passwords, privacy) + if hasattr(tag, '_passwords'): + slix2_table = TableCompiler.compile_slix2(tag) + # compile_slix2 already includes nxp_icode, so use it instead + table = slix2_table + + if not table.entries: + return + + # Upload: first chunk has 4-byte initial_groups header + import struct + initial_groups = 0xFFFFFFFF + data = table.serialize() + max_payload = 512 + + # First frame: header + entries + header = struct.pack("