# NDEF Trace Decode Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Detect and decode NDEF content in ISO 15693 trace output — both sim trace and sniff — showing human-readable NDEF records in the annotation column, with overflow on the next line. **Architecture:** Add an `decode_ndef()` function that parses NDEF TLV + records from block data. Wire it into `decode_15693_response()` so READ SINGLE BLOCK and READ MULTIPLE BLOCKS responses that contain NDEF data show decoded content. CC (block 0) gets its own annotation. Overflow annotations wrap to a continuation line at the annotation column. **Tech Stack:** Python, pytest, existing hf_15.py decoder + sim trace_fmt.py **Working directory:** `/home/work/pm3py/.worktrees/sim-framework/` **Test command:** `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py tests/test_sim_trace_fmt.py -v` --- ### Context for implementer The trace formatter produces lines like: ``` [Sim] Tag → Reader: 00 E1 40 0D 01 C1 C0 OK [4B] [Sim] Tag → Reader: 00 03 0B D1 01 07 54 02 65 6E 74 65 73 74 FE 00 ... FD 02 OK [60B] ``` The response decoder (`decode_15693_response`) currently sees all success responses as either inventory (9B data), empty, or generic `OK [NB]`. We want to detect and decode: 1. **CC block** (block 0): `E1 40 XX YY` → `CC v1.0 MLEN=XX MBREAD=Y` 2. **NDEF TLV**: `03 LL FE` → decode the NDEF records inside 3. **NDEF records**: Text → `NDEF Text "en" "hello"`, URI → `NDEF URI https://example.com`, Media → `NDEF MIME text/plain [5B]` When the annotation is longer than the available space, it wraps to a continuation line right-justified at the annotation column (same pattern the hex wrapping already uses). **Tricky parts:** - The decoder only sees the response payload (after flags byte), with no context about WHICH command produced it. It must detect CC/NDEF purely from the data pattern. - READ MULTIPLE BLOCKS responses can contain block 0 (CC) followed by NDEF data. The CC is in the first 4 bytes, NDEF TLV starts at byte 4. - READ SINGLE BLOCK #0 response is just 4 bytes of CC data. - We need both `hf_15.py` (sniff decoder) and `sim/trace_fmt.py` (sim decoder) to share the NDEF decode logic. Put the shared code in `hf_15.py` since that's where the 15693 decoders live; the sim trace_fmt calls into it via `decode_15693`. **NFC Forum Type 5 CC format (4 bytes):** - Byte 0: 0xE1 = NDEF magic - Byte 1: version (upper nibble = major, lower = minor) + access bits - Byte 2: MLEN (data area size / 8) - Byte 3: feature flags (bit 0 = MBREAD) **NDEF TLV format:** - Type 0x03 = NDEF message, 0xFE = terminator, 0x00 = NULL (skip) - Length: 1 byte if < 0xFF, else 0xFF + 2 bytes big-endian - Value: raw NDEF message bytes **NDEF record format:** - Byte 0: flags (MB=0x80, ME=0x40, CF=0x20, SR=0x10, IL=0x08, TNF=0x07) - Byte 1: TYPE_LENGTH - If SR: byte 2 = PAYLOAD_LENGTH (1 byte), else bytes 2-5 = PAYLOAD_LENGTH (4 bytes BE) - If IL: next byte = ID_LENGTH - TYPE (TYPE_LENGTH bytes) - ID (ID_LENGTH bytes, if IL set) - PAYLOAD (PAYLOAD_LENGTH bytes) **Well-known RTD types:** - "T" (0x54): Text record — payload[0] = lang_len, payload[1:1+lang_len] = lang, rest = text - "U" (0x55): URI record — payload[0] = prefix code, rest = URI suffix --- ### Task 1: NDEF TLV + record decoder **Files:** - Modify: `pm3py/hf_15.py` - Modify: `tests/test_hf_15.py` **Step 1: Write failing tests** Append to `tests/test_hf_15.py`: ```python from pm3py.hf_15 import decode_ndef_annotation # ---- NDEF decode ---- class TestDecodeNdefAnnotation: def test_cc_block(self): """4-byte CC recognized and decoded.""" data = bytes([0xE1, 0x40, 0x0D, 0x01]) ann = decode_ndef_annotation(data) assert ann is not None assert "CC" in ann assert "v1.0" in ann assert "MBREAD" in ann def test_cc_no_mbread(self): data = bytes([0xE1, 0x40, 0x10, 0x00]) ann = decode_ndef_annotation(data) assert "CC" in ann assert "MBREAD" not in ann def test_ndef_text_record(self): """NDEF TLV with Text record.""" # TLV: 03 0B FE # Record: D1 01 07 54 02 65 6E 74 65 73 74 # MB+ME+SR, TNF=well-known, type="T", lang="en", text="test" msg = bytes([0xD1, 0x01, 0x07, 0x54, 0x02, 0x65, 0x6E, 0x74, 0x65, 0x73, 0x74]) tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE]) ann = decode_ndef_annotation(tlv) assert ann is not None assert '"test"' in ann def test_ndef_uri_record(self): """NDEF TLV with URI record.""" # URI: https://example.com → prefix 0x04 + "example.com" uri_payload = bytes([0x04]) + b"example.com" rec = bytes([0xD1, 0x01, len(uri_payload), 0x55]) + uri_payload tlv = bytes([0x03, len(rec)]) + rec + bytes([0xFE]) ann = decode_ndef_annotation(tlv) assert "https://example.com" in ann def test_ndef_mime_record(self): """NDEF TLV with MIME record.""" mime_type = b"text/plain" payload = b"hello" rec = bytes([0xD2, len(mime_type), len(payload)]) + mime_type + payload tlv = bytes([0x03, len(rec)]) + rec + bytes([0xFE]) ann = decode_ndef_annotation(tlv) assert "text/plain" in ann def test_no_ndef_magic(self): """Random data without NDEF TLV returns None.""" ann = decode_ndef_annotation(bytes([0x00, 0x00, 0x00, 0x00])) assert ann is None def test_cc_plus_ndef(self): """Data starting with CC followed by NDEF TLV (READ MULTIPLE from block 0).""" cc = bytes([0xE1, 0x40, 0x0D, 0x01]) msg = bytes([0xD1, 0x01, 0x07, 0x54, 0x02, 0x65, 0x6E, 0x74, 0x65, 0x73, 0x74]) tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE]) data = cc + tlv + bytes(50) # pad like real response ann = decode_ndef_annotation(data) assert "CC" in ann assert '"test"' in ann def test_empty_ndef(self): """NDEF TLV with zero length.""" tlv = bytes([0x03, 0x00, 0xFE]) ann = decode_ndef_annotation(tlv) assert ann is not None assert "empty" in ann.lower() or "0B" in ann or "NDEF" in ann def test_long_text_truncated(self): """Long text is truncated in annotation.""" long_text = "A" * 200 msg = ndef_text(long_text) tlv = bytes([0x03, 0xFF, (len(msg) >> 8) & 0xFF, len(msg) & 0xFF]) + msg + bytes([0xFE]) ann = decode_ndef_annotation(tlv) assert ann is not None assert "..." in ann # truncated ``` **Step 2: Run tests to see them fail** Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py::TestDecodeNdefAnnotation -v` Expected: FAIL — `ImportError: cannot import name 'decode_ndef_annotation'` **Step 3: Implement decode_ndef_annotation** Add to `pm3py/hf_15.py`, after the existing `URI_PREFIXES` dict (copy it from type5.py or import it), before the `_15693_CMDS` dict: ```python # ---- NDEF decode (for trace annotation) ---- # NFC Type 5 CC magic _NDEF_MAGIC = 0xE1 # URI prefix codes (NFC Forum RTD URI) _URI_PREFIXES = { 0x00: "", 0x01: "http://www.", 0x02: "https://www.", 0x03: "http://", 0x04: "https://", 0x05: "tel:", 0x06: "mailto:", } # TNF values _TNF_EMPTY = 0x00 _TNF_WELL_KNOWN = 0x01 _TNF_MEDIA = 0x02 _TNF_URI = 0x03 _TNF_EXTERNAL = 0x04 _MAX_ANN_TEXT = 40 # truncate decoded text in annotations def decode_ndef_annotation(data: bytes) -> str | None: """Decode NDEF content from block data for trace annotation. Handles: - CC block (4 bytes starting with 0xE1) - NDEF TLV (starting with 0x03) - CC + NDEF TLV (READ MULTIPLE from block 0) Returns annotation string or None if not NDEF. """ if len(data) < 4: return None parts = [] offset = 0 # Check for CC at start if data[0] == _NDEF_MAGIC: ver_major = (data[1] >> 6) & 0x03 ver_minor = (data[1] >> 4) & 0x03 mlen = data[2] features = data[3] cc_str = f"CC v{ver_major}.{ver_minor} MLEN={mlen}" if features & 0x01: cc_str += " MBREAD" parts.append(cc_str) offset = 4 if offset >= len(data): return cc_str # Scan for NDEF TLV while offset < len(data): tlv_type = data[offset] offset += 1 if tlv_type == 0x00: # NULL TLV — skip continue if tlv_type == 0xFE: # Terminator break if tlv_type != 0x03: # Not NDEF — stop break # Parse TLV length if offset >= len(data): break tlv_len = data[offset] offset += 1 if tlv_len == 0xFF: if offset + 2 > len(data): break tlv_len = (data[offset] << 8) | data[offset + 1] offset += 2 if tlv_len == 0: parts.append("NDEF empty") continue # Parse NDEF message (one or more records) msg_end = min(offset + tlv_len, len(data)) records = _parse_ndef_records(data[offset:msg_end]) for rec in records: parts.append(rec) offset = msg_end return " | ".join(parts) if parts else None def _parse_ndef_records(msg: bytes) -> list[str]: """Parse NDEF records from raw message bytes. Returns list of annotation strings.""" records = [] offset = 0 while offset < len(msg): if offset + 3 > len(msg): break flags = msg[offset] tnf = flags & 0x07 sr = bool(flags & 0x10) il = bool(flags & 0x08) type_len = msg[offset + 1] offset += 2 # Payload length if sr: if offset >= len(msg): break payload_len = msg[offset] offset += 1 else: if offset + 4 > len(msg): break payload_len = int.from_bytes(msg[offset:offset + 4], "big") offset += 4 # ID length id_len = 0 if il: if offset >= len(msg): break id_len = msg[offset] offset += 1 # Type if offset + type_len > len(msg): break rec_type = msg[offset:offset + type_len] offset += type_len # ID (skip) offset += id_len # Payload if offset + payload_len > len(msg): payload = msg[offset:] else: payload = msg[offset:offset + payload_len] offset += payload_len # Decode based on TNF + type ann = _decode_ndef_record(tnf, rec_type, payload) records.append(ann) return records def _decode_ndef_record(tnf: int, rec_type: bytes, payload: bytes) -> str: """Decode a single NDEF record into an annotation string.""" if tnf == _TNF_WELL_KNOWN: if rec_type == b"T" and len(payload) >= 1: lang_len = payload[0] & 0x3F lang = payload[1:1 + lang_len].decode("ascii", errors="replace") text = payload[1 + lang_len:].decode("utf-8", errors="replace") if len(text) > _MAX_ANN_TEXT: text = text[:_MAX_ANN_TEXT] + "..." return f'NDEF Text "{lang}" "{text}"' if rec_type == b"U" and len(payload) >= 1: prefix = _URI_PREFIXES.get(payload[0], "") suffix = payload[1:].decode("utf-8", errors="replace") uri = prefix + suffix if len(uri) > _MAX_ANN_TEXT: uri = uri[:_MAX_ANN_TEXT] + "..." return f"NDEF URI {uri}" return f"NDEF WK type={rec_type!r} [{len(payload)}B]" if tnf == _TNF_MEDIA: mime = rec_type.decode("ascii", errors="replace") if len(mime) > 30: mime = mime[:30] + "..." return f"NDEF MIME {mime} [{len(payload)}B]" if tnf == _TNF_EXTERNAL: ext_type = rec_type.decode("ascii", errors="replace") return f"NDEF EXT {ext_type} [{len(payload)}B]" if tnf == _TNF_EMPTY: return "NDEF empty" return f"NDEF TNF={tnf} [{len(payload)}B]" ``` **Step 4: Run tests** Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py::TestDecodeNdefAnnotation -v` Expected: All pass **Step 5: Commit** ```bash cd /home/work/pm3py/.worktrees/sim-framework git add pm3py/hf_15.py tests/test_hf_15.py git commit --no-gpg-sign -m "feat: NDEF TLV + record decoder for trace annotations" ``` --- ### Task 2: Wire NDEF decode into response annotations **Files:** - Modify: `pm3py/hf_15.py` - Modify: `tests/test_hf_15.py` **Step 1: Write failing tests** Append to `tests/test_hf_15.py`: ```python class TestResponseNdefDecode: def test_read_single_block_0_cc(self): """READ SINGLE BLOCK #0 response shows CC annotation.""" # Response: flags(1) + block_data(4) data = bytes([0x00, 0xE1, 0x40, 0x0D, 0x01]) ann = decode_15693_response(data) assert "CC" in ann assert "MBREAD" in ann def test_read_multiple_blocks_ndef(self): """READ MULTIPLE BLOCKS response with NDEF shows decoded text.""" # Response: flags(1) + blocks data msg = bytes([0xD1, 0x01, 0x07, 0x54, 0x02, 0x65, 0x6E, 0x74, 0x65, 0x73, 0x74]) tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE]) data = bytes([0x00]) + tlv + bytes(50) ann = decode_15693_response(data) assert '"test"' in ann def test_read_multiple_with_cc_and_ndef(self): """READ MULTIPLE from block 0: CC + NDEF both decoded.""" cc = bytes([0xE1, 0x40, 0x0D, 0x01]) msg = bytes([0xD1, 0x01, 0x07, 0x54, 0x02, 0x65, 0x6E, 0x74, 0x65, 0x73, 0x74]) tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE]) data = bytes([0x00]) + cc + tlv + bytes(40) ann = decode_15693_response(data) assert "CC" in ann assert '"test"' in ann def test_generic_data_no_ndef(self): """Non-NDEF data still shows OK [NB].""" data = bytes([0x00, 0xDE, 0xAD, 0xBE, 0xEF]) ann = decode_15693_response(data) assert "OK [4B]" in ann def test_inventory_not_affected(self): """Inventory response still decoded as inventory, not NDEF.""" data = bytes([0x00, 0x00]) + bytes(8) ann = decode_15693_response(data) assert "INVENTORY" in ann ``` **Step 2: Run tests to see them fail** Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py::TestResponseNdefDecode -v` Expected: FAIL — `"CC"` not in `"OK [4B]"` **Step 3: Update decode_15693_response** Replace the generic response handler section in `decode_15693_response` (the part after error check, starting at `data = payload[1:]`): ```python def decode_15693_response(payload: bytes) -> str | None: """Decode an ISO 15693 response frame into annotation.""" if len(payload) < 1: return None flags = payload[0] if flags & 0x01: # Error if len(payload) >= 2: code = payload[1] desc = _15693_ERRORS.get(code, "") return f"ERROR 0x{code:02X} {desc}" if desc else f"ERROR 0x{code:02X}" return "ERROR" data = payload[1:] if len(data) == 0: return "OK" # Inventory response: dsfid(1) + uid(8) = 9 bytes if len(data) == 9: uid_msb = bytes(reversed(data[1:9])) return f"OK INVENTORY UID={uid_msb.hex().upper()}" # Try NDEF decode (CC block, NDEF TLV, or both) ndef_ann = decode_ndef_annotation(data) if ndef_ann: return f"OK {ndef_ann}" return f"OK [{len(data)}B]" ``` **Step 4: Run tests** Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py -v` Expected: All pass (including existing tests — verify `test_decode_response_ok_data` still passes since it uses non-NDEF data) **Step 5: Commit** ```bash cd /home/work/pm3py/.worktrees/sim-framework git add pm3py/hf_15.py tests/test_hf_15.py git commit --no-gpg-sign -m "feat: wire NDEF decode into ISO 15693 response annotations" ``` --- ### Task 3: Annotation overflow wrapping **Files:** - Modify: `pm3py/hf_15.py` (sniff formatter) - Modify: `pm3py/sim/trace_fmt.py` (sim formatter) - Modify: `tests/test_hf_15.py` - Modify: `tests/test_sim_trace_fmt.py` The current formatters already wrap hex to multiple lines when it's too wide. But annotations are either right-justified on the last hex line (short) or on their own line (wrapped hex). NDEF annotations can be long (e.g. `OK CC v1.0 MLEN=13 MBREAD | NDEF Text "en" "test"`). When the annotation overflows the available width, it should wrap to the next line at the annotation column. **Step 1: Write failing tests** In `tests/test_hf_15.py`, append: ```python class TestSniffLineAnnotationOverflow: def test_long_annotation_wraps(self): """Long NDEF annotation wraps to continuation line.""" # Build a response with CC + NDEF text that produces a long annotation cc = bytes([0xE1, 0x40, 0x0D, 0x01]) long_text = "A" * 30 from pm3py.sim.type5 import ndef_text msg = ndef_text(long_text) tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE]) payload = bytes([0x00]) + cc + tlv # Add CRC payload += bytes([0xAA, 0xBB]) entry = {"is_response": True, "data": payload, "timestamp": 0, "duration": 0, "parity": b""} line = format_sniff_line(entry, width=80, is_tty=False) lines = line.split("\n") # Annotation should be on a separate continuation line ann_lines = [l for l in lines if "NDEF" in l] assert len(ann_lines) >= 1 def test_short_annotation_no_wrap(self): """Short annotation stays on same line as hex.""" data = bytes([0x00, 0xE1, 0x40, 0x0D, 0x01, 0xAA, 0xBB]) entry = {"is_response": True, "data": data, "timestamp": 0, "duration": 0, "parity": b""} line = format_sniff_line(entry, width=120, is_tty=False) assert "CC" in line # Should be single-line (plus possible hex wrap) non_empty = [l for l in line.split("\n") if l.strip()] assert len(non_empty) <= 2 # hex + annotation on same or next line ``` In `tests/test_sim_trace_fmt.py`, append to `TestTraceFormatterWrapping`: ```python def test_long_annotation_wraps_to_next_line(self): """Long NDEF annotation wraps to continuation line.""" # Build response with CC + NDEF that gives a long annotation cc = bytes([0xE1, 0x40, 0x0D, 0x01]) from pm3py.sim.type5 import ndef_text msg = ndef_text("A" * 30) tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE]) payload = bytes([0x00]) + cc + tlv fmt = TraceFormatter(mode="sim", decoder=decode_15693, width=80) result = self._strip_ansi(fmt.format(1, payload)) lines = result.strip().split("\n") ann_lines = [l for l in lines if "NDEF" in l] assert len(ann_lines) >= 1 ``` **Step 2: Run tests to see them fail** Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py::TestSniffLineAnnotationOverflow tests/test_sim_trace_fmt.py::TestTraceFormatterWrapping::test_long_annotation_wraps_to_next_line -v` Expected: Might pass or fail depending on current wrapping behavior — the annotation might already go on its own line if hex is wrapped. Run to check. **Step 3: Update annotation wrapping in both formatters** In `format_sniff_line()` in `hf_15.py`, replace the annotation handling in the multi-line section (the `if annotation:` block near the end): ```python if annotation: # Wrap annotation if it exceeds available width ann_lines = _wrap_annotation(annotation, avail) for al in ann_lines: ann_pad = max(avail - len(al), 0) parts.append(f"{pad}{' ' * ann_pad}{_color(ann_color, al, is_tty)}") ``` And add the helper (shared between both formatters): ```python def _wrap_annotation(annotation: str, avail: int) -> list[str]: """Wrap annotation text to fit within avail columns. Splits on ' | ' boundaries first, then by word if still too long. """ if len(annotation) <= avail: return [annotation] # Try splitting on pipe separator (NDEF uses " | " between parts) if " | " in annotation: parts = annotation.split(" | ") lines = [] current = parts[0] for part in parts[1:]: candidate = f"{current} | {part}" if len(candidate) <= avail: current = candidate else: lines.append(current) current = part lines.append(current) return lines # Fall back to truncation return [annotation[:avail - 3] + "..."] ``` In `trace_fmt.py`, apply the same pattern to the `TraceFormatter.format()` method's multi-line annotation section. Import `_wrap_annotation` from `hf_15` or duplicate the logic inline: In the multi-line section of `TraceFormatter.format()`, replace: ```python if annotation: ann_pad = avail - len(annotation) ann_pad = max(ann_pad, 0) parts.append(f"{pad}{' ' * ann_pad}{self._color(ann_color, annotation)}") ``` With: ```python if annotation: ann_lines = self._wrap_annotation(annotation, avail) for al in ann_lines: ann_pad = max(avail - len(al), 0) parts.append(f"{pad}{' ' * ann_pad}{self._color(ann_color, al)}") ``` And add to `TraceFormatter`: ```python def _wrap_annotation(self, annotation: str, avail: int) -> list[str]: """Wrap annotation to fit within avail columns.""" if len(annotation) <= avail: return [annotation] if " | " in annotation: parts = annotation.split(" | ") lines = [] current = parts[0] for part in parts[1:]: candidate = f"{current} | {part}" if len(candidate) <= avail: current = candidate else: lines.append(current) current = part lines.append(current) return lines return [annotation[:avail - 3] + "..."] ``` **Step 4: Run all tests** Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py tests/test_sim_trace_fmt.py -v` Expected: All pass **Step 5: Commit** ```bash cd /home/work/pm3py/.worktrees/sim-framework git add pm3py/hf_15.py pm3py/sim/trace_fmt.py tests/test_hf_15.py tests/test_sim_trace_fmt.py git commit --no-gpg-sign -m "feat: annotation overflow wrapping for NDEF decode in trace output" ``` --- ### Task 4: Copy hf_15.py to main branch + final test **Files:** - Copy: `pm3py/hf_15.py` → `/home/work/pm3py/pm3py/hf_15.py` - Copy: `tests/test_hf_15.py` → `/home/work/pm3py/tests/test_hf_15.py` **Step 1: Copy files** ```bash cp /home/work/pm3py/.worktrees/sim-framework/pm3py/hf_15.py /home/work/pm3py/pm3py/hf_15.py cp /home/work/pm3py/.worktrees/sim-framework/tests/test_hf_15.py /home/work/pm3py/tests/test_hf_15.py ``` **Step 2: Run main branch tests** Run: `cd /home/work/pm3py && python -m pytest tests/ -v` Expected: All pass **Step 3: Run worktree full test suite** Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/ -v` Expected: All pass