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":
|
elif cmd.name == "close":
|
||||||
clear(state)
|
clear(state)
|
||||||
print("connection closed")
|
print("connection closed")
|
||||||
|
elif cmd.name == "tlv":
|
||||||
|
_handle_tlv(state, cmd.args)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if cmd.kind == "call":
|
if cmd.kind == "call":
|
||||||
@@ -113,6 +115,22 @@ def _handle_call(state: RawSession, cmd) -> None:
|
|||||||
is_tty=sys.stdout.isatty()))
|
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:
|
def _handle_help(state: RawSession, args) -> None:
|
||||||
catalog = catalog_for(state)
|
catalog = catalog_for(state)
|
||||||
if args and catalog is not None:
|
if args and catalog is not None:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ HEX = "hex"
|
|||||||
BIN = "bin"
|
BIN = "bin"
|
||||||
|
|
||||||
#: single-word control verbs the REPL handles directly
|
#: 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)
|
_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.entry import byte_space
|
||||||
from pm3py.cli.rawcli.identify import identify, clear, name_14a
|
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.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
|
from pm3py.cli.rawcli.app import dispatch
|
||||||
|
|
||||||
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||||
@@ -265,3 +266,33 @@ class TestFunctionCalls:
|
|||||||
s = self._id(); dispatch(s, "identify"); capsys.readouterr()
|
s = self._id(); dispatch(s, "identify"); capsys.readouterr()
|
||||||
dispatch(s, "help READ")
|
dispatch(s, "help READ")
|
||||||
assert "0x30" in capsys.readouterr().out
|
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