fix(rawcli): memory locations live only in suggestions; base-10 call args

Two corrections:

1. Memory-location names are a SUGGESTION, never command output. Removed the region annotation
   printed after each exchange (both the function-call and raw-hex paths) along with its dead
   machinery (region_annotation, pages_for_payload, TagCommand.pages). The location now shows in
   exactly two places: the completion menu (page landmarks) and the live bottom-bar hint —
   which now also covers raw byte entry (30 04 -> "READ(page) … · page 0x04 → user memory")
   via Catalog.by_opcode.

2. Function-call arguments are base 10 by default. READ(04) crashed on int("04", 0) (Python
   rejects leading-zero decimals in base 0). parser.parse_arg_int parses a call argument as
   decimal unless it carries a 0x/0b/0o prefix, so READ(04)/READ(40) work and READ(0x28) still
   overrides to hex. Raw byte entry keeps its opposite default (hex, 0b to intermix) — unchanged.

Hardware-verified on NTAG213: READ(04) sends 30 04 with no region line in the output; the hint
shows the page role for READ(4), READ(04), READ(0x28), raw 30 04 and 30 28; completion still
lists the memory map. 1331 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-15 18:38:53 -07:00
parent 899142f23a
commit a3cef180d2
5 changed files with 87 additions and 131 deletions

View File

@@ -225,16 +225,16 @@ class TestIdentify:
assert s.transponder == "NTAG216" # recovered, not the generic name
assert s.device.hf.iso14a.raw.call_count == 3
def test_14a_getversion_fails_generic_still_annotates(self):
def test_14a_getversion_fails_generic_still_resolves(self):
# GET_VERSION never answers -> generic family name, but the universal Type 2 pages still
# annotate (this is the path that silently broke before)
from pm3py.cli.rawcli.memory import region_annotation
# resolve a role (this is the path that silently broke before)
from pm3py.cli.rawcli.memory import page_role
s = RawSession(device=_mock_device(
scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"}))
s.device.hf.iso14a.raw.return_value = {"raw": None}
identify(s)
assert s.transponder == "MIFARE Ultralight / NTAG"
assert region_annotation(s.transponder, [4, 5, 6, 7]) == "04-07 → user memory"
assert page_role(s.transponder, 4) == "user memory"
def test_15693_when_e0_uid(self):
s = RawSession(device=_mock_device(scan15={"found": True, "uid": "e004010203040506"}))
@@ -396,15 +396,18 @@ class TestCatalog:
assert TYPE2.get("GET_VERSION").opcode() == 0x60
assert LF_READONLY.get("INFO").opcode() is None # run-only, no build
def test_pages_for_payload(self):
# raw bytes reverse-map to the pages a page/block command touches
from pm3py.cli.rawcli.catalog import pages_for_payload
assert pages_for_payload(TYPE2, b"\x30\x04") == [4, 5, 6, 7] # READ(4)
assert pages_for_payload(TYPE2, b"\x3A\x00\x06") == [0, 1, 2, 3, 4, 5, 6] # FAST_READ(0,6)
assert pages_for_payload(TYPE2, b"\xA2\x04\x01\x02\x03\x04") == [4] # WRITE(4, ..)
assert pages_for_payload(TYPE2, b"\x60") is None # GET_VERSION — not a page command
assert pages_for_payload(TYPE2, b"\x30") is None # opcode only, no page byte
assert pages_for_payload(None, b"\x30\x04") is None # no catalog
def test_call_arg_is_base_ten(self):
# a function-call page argument is base 10 unless prefixed — READ(04)/READ(40) must not crash
assert TYPE2.get("READ").build("04") == b"\x30\x04" # leading-zero decimal
assert TYPE2.get("READ").build("40") == b"\x30\x28" # 40 decimal = page 0x28
assert TYPE2.get("READ").build("0x28") == b"\x30\x28" # hex override
assert TYPE2.get("READ").build("0b101000") == b"\x30\x28" # binary override
assert TYPE2.get("READ").build("4") == b"\x30\x04"
def test_by_opcode(self):
assert TYPE2.by_opcode(0x30).name == "READ"
assert TYPE2.by_opcode(0xA2).name == "WRITE"
assert TYPE2.by_opcode(0x99) is None
class TestFunctionCalls:
@@ -433,8 +436,8 @@ class TestFunctionCalls:
assert calls[0].args[0] == b"\xA0\x04"
assert calls[1].args[0] == b"\x01\x02\x03\x04" + b"\x00" * 12
def test_read_annotates_memory_region(self, capsys):
# READ output names the datasheet regions the pages cover (the user's ask)
def test_memory_region_stays_out_of_output(self, capsys):
# the memory-location info belongs ONLY in the hint/completion — never printed as output
s = RawSession(device=_mock_device(
scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"},
version=NTAG216_VERSION))
@@ -442,27 +445,9 @@ class TestFunctionCalls:
assert s.transponder == "NTAG216"
s.device.hf.iso14a.raw.return_value = {"raw": b"\x03\x00\xFE\x00" * 4}
capsys.readouterr()
dispatch(s, "READ(4)")
assert "04-07 → user memory" in _plain(capsys.readouterr().out)
dispatch(s, "READ(0)")
o = _plain(capsys.readouterr().out)
assert "UID / serial number" in o and "Capability Container" in o
def test_raw_hex_annotates_memory_region(self, capsys):
# raw '30 04' is a READ(4) — it earns the same region annotation as the function form
s = RawSession(device=_mock_device(
scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"},
version=NTAG216_VERSION))
dispatch(s, "identify")
assert s.transponder == "NTAG216"
s.device.hf.iso14a.raw.return_value = {"raw": b"\x03\x00\xFE\x00" * 4}
capsys.readouterr()
dispatch(s, "30 04") # raw READ(4)
assert "04-07 → user memory" in _plain(capsys.readouterr().out)
dispatch(s, "3A 00 06") # raw FAST_READ(0,6)
o = _plain(capsys.readouterr().out)
assert "UID / serial number" in o and "04-06 → user memory" in o
dispatch(s, "60") # GET_VERSION — no page op, no region
dispatch(s, "READ(4)") # function call
assert "user memory" not in _plain(capsys.readouterr().out)
dispatch(s, "30 04") # raw hex
assert "user memory" not in _plain(capsys.readouterr().out)
def test_call_without_identify(self, capsys):
@@ -653,25 +638,25 @@ class TestMemoryHint:
from pm3py.cli.rawcli.memory import input_hint
assert input_hint(RawSession(), "READ(4)") is None
def test_region_annotation(self):
from pm3py.cli.rawcli.memory import region_annotation
assert region_annotation("NTAG216", [0, 1, 2, 3]) == \
"00-02 → UID / serial number · 03 → Capability Container (CC)"
assert region_annotation("NTAG216", [4, 5, 6, 7]) == "04-07 → user memory"
assert "PWD" in region_annotation("NTAG213", [0x2B])
assert region_annotation("MIFARE Classic 1K", [4]) is None # no layout -> no annotation
assert region_annotation("NTAG216", []) is None
def test_hint_raw_bytes(self):
# raw hex (30 04) gets the location hint too — the opcode maps to READ, byte 1 is the page
from pm3py.cli.rawcli.memory import input_hint
s = self._sess() # NTAG213
h = input_hint(s, "30 04")
assert h.startswith("READ(page)") and "page 0x04 → user memory" in h
assert "PWD" in input_hint(s, "30 2B") # page 0x2B = PWD on a 213
assert input_hint(s, "60").startswith("GET_VERSION") # opcode-only command, no page part
def test_generic_type2_fallback(self):
from pm3py.cli.rawcli.memory import region_annotation, landmark_pages, page_role
from pm3py.cli.rawcli.memory import landmark_pages, page_role
# only the family is known (flaky/absent GET_VERSION) -> universal pages still resolve
for name in ("MIFARE Ultralight / NTAG", "NTAG (144 B)"):
assert region_annotation(name, [4, 5, 6, 7]) == "04-07 → user memory"
assert page_role(name, 4) == "user memory"
assert page_role(name, 0) == "UID / serial number"
assert page_role(name, 3) == "Capability Container (CC)"
assert page_role(name, 0x20) is None # model-specific -> not guessed
assert [p for p, _ in landmark_pages(name)] == [0, 3, 4] # no config landmarks
assert region_annotation("MIFARE Classic 1K", [4]) is None # not Type 2 -> nothing
assert page_role("MIFARE Classic 1K", 4) is None # not Type 2 -> nothing
def test_t5577_block_roles(self):
from pm3py.cli.rawcli.memory import page_role, input_hint