feat(rawcli): TLV/NDEF formatting + multi-line editing (Phase 5)

- tlv.py: parse_tlv() walks a Type-2 TLV block (NULL/LOCK/MEM/NDEF/PROP/
  TERMINATOR, incl. 3-byte 0xFF length); format_tlv() renders an indented
  multi-line breakdown and annotates NDEF messages via the existing
  decode_ndef_annotation. prompt_tlv() opens a multi-line editor to
  compose a TLV as hex across lines.
- app/parser: `tlv <hex>` decodes + prints the breakdown; bare `tlv`
  opens the multi-line editor.

5 new tests (structure, extended length, multi-line format, command).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-14 12:15:54 -07:00
parent 9c84f9e7b0
commit 4e7181d289
4 changed files with 149 additions and 1 deletions

View File

@@ -79,6 +79,8 @@ def dispatch(state: RawSession, line: str) -> bool:
elif cmd.name == "close":
clear(state)
print("connection closed")
elif cmd.name == "tlv":
_handle_tlv(state, cmd.args)
return True
if cmd.kind == "call":
@@ -113,6 +115,22 @@ def _handle_call(state: RawSession, cmd) -> None:
is_tty=sys.stdout.isatty()))
def _handle_tlv(state: RawSession, args) -> None:
from .tlv import format_tlv, prompt_tlv
from .parser import parse_bytes
if args:
try:
data = parse_bytes(" ".join(args), state.entry_mode)
except ValueError as exc:
print(f"rawcli: {exc}")
return
else:
data = prompt_tlv(state) # interactive multi-line editor
if not data:
return
print(format_tlv(data))
def _handle_help(state: RawSession, args) -> None:
catalog = catalog_for(state)
if args and catalog is not None:

View File

@@ -14,7 +14,7 @@ HEX = "hex"
BIN = "bin"
#: single-word control verbs the REPL handles directly
CONTROL_VERBS = {"help", "identify", "transponder", "close", "quit", "exit", "q"}
CONTROL_VERBS = {"help", "identify", "transponder", "close", "tlv", "quit", "exit", "q"}
_CALL_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\((.*)\)\s*$", re.DOTALL)

99
pm3py/cli/rawcli/tlv.py Normal file
View File

@@ -0,0 +1,99 @@
"""TLV / NDEF handling for rawcli: parse a Type-2 TLV block into a structured, indented
multi-line breakdown, and provide a multi-line editor for composing long TLVs.
NDEF message values (tag 0x03) are annotated by reusing
:func:`pm3py.trace.ndef.decode_ndef_annotation`.
"""
from __future__ import annotations
from dataclasses import dataclass
from pm3py.trace.ndef import decode_ndef_annotation
_TLV_NAMES = {
0x00: "NULL", 0x01: "LOCK CONTROL", 0x02: "MEMORY CONTROL",
0x03: "NDEF MESSAGE", 0xFD: "PROPRIETARY", 0xFE: "TERMINATOR",
}
@dataclass
class TLV:
tag: int
length: int
value: bytes
def parse_tlv(data: bytes) -> list[TLV]:
"""Walk a Type-2 TLV block (NULL/LOCK/MEMORY/NDEF/PROPRIETARY/TERMINATOR), handling the
3-byte 0xFF extended length. Stops at the terminator or when the data runs out."""
out: list[TLV] = []
i, n = 0, len(data)
while i < n:
tag = data[i]
i += 1
if tag == 0x00: # NULL — no length/value
out.append(TLV(tag, 0, b""))
continue
if tag == 0xFE: # terminator
out.append(TLV(tag, 0, b""))
break
if i >= n:
break
length = data[i]
i += 1
if length == 0xFF: # 3-byte length
if i + 2 > n:
break
length = (data[i] << 8) | data[i + 1]
i += 2
value = data[i:i + length]
i += length
out.append(TLV(tag, length, value))
return out
def _ndef_tlv(value: bytes) -> bytes:
if len(value) < 0xFF:
return bytes([0x03, len(value)]) + value
return bytes([0x03, 0xFF, (len(value) >> 8) & 0xFF, len(value) & 0xFF]) + value
def _hex_rows(value: bytes, per_row: int = 16) -> list[str]:
return [" ".join(f"{b:02X}" for b in value[i:i + per_row])
for i in range(0, len(value), per_row)]
def format_tlv(data: bytes) -> str:
"""Structured, indented multi-line breakdown of a TLV block."""
tlvs = parse_tlv(data)
if not tlvs:
return f"(no TLV structure in {len(data)} bytes)"
lines = [f"TLV breakdown ({len(data)} bytes):"]
for t in tlvs:
name = _TLV_NAMES.get(t.tag, f"0x{t.tag:02X}")
if t.tag in (0x00, 0xFE):
lines.append(f" [{t.tag:02X}] {name}")
continue
lines.append(f" [{t.tag:02X}] {name} len={t.length}")
for row in _hex_rows(t.value):
lines.append(f" {row}")
if t.tag == 0x03 and t.value:
annotation = decode_ndef_annotation(_ndef_tlv(t.value))
if annotation:
lines.append(f"{annotation}")
return "\n".join(lines)
def prompt_tlv(session) -> bytes | None:
"""Open a multi-line editor to compose a TLV as hex, gracefully spanning lines. Returns the
parsed bytes, or None if cancelled. Interactive — exercised in the live REPL."""
from prompt_toolkit import prompt
from .parser import parse_bytes
try:
text = prompt("tlv> ", multiline=True) # Esc+Enter (or Meta+Enter) to submit
except (EOFError, KeyboardInterrupt):
return None
text = text.strip()
if not text:
return None
return parse_bytes(text, session.entry_mode)

View File

@@ -12,6 +12,7 @@ from pm3py.cli.rawcli.parser import parse_token, parse_bytes, parse_line
from pm3py.cli.rawcli.entry import byte_space
from pm3py.cli.rawcli.identify import identify, clear, name_14a
from pm3py.cli.rawcli.catalog import TYPE2, ISO15, catalog_for, catalog_for as _cf
from pm3py.cli.rawcli.tlv import parse_tlv, format_tlv
from pm3py.cli.rawcli.app import dispatch
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
@@ -265,3 +266,33 @@ class TestFunctionCalls:
s = self._id(); dispatch(s, "identify"); capsys.readouterr()
dispatch(s, "help READ")
assert "0x30" in capsys.readouterr().out
class TestTLV:
# NULL + NDEF (a well-known text record "Hi"/en) + TERMINATOR
TEXT_REC = bytes.fromhex("D101055402656E4869") # D1 01 05 T 02 'en' 'Hi'
BLOCK = bytes([0x00, 0x03, len(TEXT_REC)]) + TEXT_REC + bytes([0xFE])
def test_parse_structure(self):
tlvs = parse_tlv(self.BLOCK)
tags = [t.tag for t in tlvs]
assert tags == [0x00, 0x03, 0xFE]
ndef = tlvs[1]
assert ndef.length == len(self.TEXT_REC) and ndef.value == self.TEXT_REC
def test_extended_length(self):
big = bytes([0x03, 0xFF, 0x01, 0x00]) + b"\x00" * 256 + bytes([0xFE])
tlvs = parse_tlv(big)
assert tlvs[0].tag == 0x03 and tlvs[0].length == 256
def test_format_multiline(self):
out = format_tlv(self.BLOCK)
assert "NULL" in out
assert "NDEF MESSAGE len=9" in out
assert "TERMINATOR" in out
assert "\n" in out # genuinely multi-line
assert "" in out # NDEF annotation line present
def test_tlv_command(self, capsys):
dispatch(RawSession(), "tlv " + self.BLOCK.hex())
assert "NDEF MESSAGE" in capsys.readouterr().out