docs: roadmap index + custom-14a-command plans + backfill plan docs
- Add docs/plans/README.md as the roadmap index cataloguing all design/plan docs. - Add the custom ISO14443-A command handling design + plan (L3 NTAG I2C SECTOR_SELECT / cross-sector reads + native auth; L4 static APDUs + WTX relay), produced from a multi-agent design workflow. - Backfill previously-uncommitted plan docs (trace-formatter, ndef-trace-decode, live-sniff, firmware-upstream-rebase, 14a-live-trace) and NTAG5_SECURITY.md. - Sync CLAUDE.md package structure with the committed transponder models. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
677
docs/plans/2026-03-18-ndef-trace-decode.md
Normal file
677
docs/plans/2026-03-18-ndef-trace-decode.md
Normal file
@@ -0,0 +1,677 @@
|
||||
# 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 <message> 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 <ndef> 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
|
||||
Reference in New Issue
Block a user