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:
@@ -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:
|
||||
|
||||
@@ -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
99
pm3py/cli/rawcli/tlv.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user