- 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>
678 lines
23 KiB
Markdown
678 lines
23 KiB
Markdown
# 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
|