From 4e7181d2898d62842d714e30575d6a833792bf08 Mon Sep 17 00:00:00 2001 From: michael Date: Tue, 14 Jul 2026 12:15:54 -0700 Subject: [PATCH] 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 ` 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 --- pm3py/cli/rawcli/app.py | 18 +++++++ pm3py/cli/rawcli/parser.py | 2 +- pm3py/cli/rawcli/tlv.py | 99 ++++++++++++++++++++++++++++++++++++++ tests/test_rawcli.py | 31 ++++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 pm3py/cli/rawcli/tlv.py diff --git a/pm3py/cli/rawcli/app.py b/pm3py/cli/rawcli/app.py index 6dfdab4..aa049e6 100644 --- a/pm3py/cli/rawcli/app.py +++ b/pm3py/cli/rawcli/app.py @@ -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: diff --git a/pm3py/cli/rawcli/parser.py b/pm3py/cli/rawcli/parser.py index dfe7c61..f72c1de 100644 --- a/pm3py/cli/rawcli/parser.py +++ b/pm3py/cli/rawcli/parser.py @@ -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) diff --git a/pm3py/cli/rawcli/tlv.py b/pm3py/cli/rawcli/tlv.py new file mode 100644 index 0000000..065c544 --- /dev/null +++ b/pm3py/cli/rawcli/tlv.py @@ -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) diff --git a/tests/test_rawcli.py b/tests/test_rawcli.py index be2110a..8d7f2db 100644 --- a/tests/test_rawcli.py +++ b/tests/test_rawcli.py @@ -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