fix(rawcli): memory annotations survive a flaky/absent GET_VERSION

The page-role annotations and completion were gated on identify resolving an *exact* model
string (NTAG216). On a flaky NG link a single lost GET_VERSION dropped identify to the generic
SAK name "MIFARE Ultralight / NTAG", which isn't in the layout table — so every region
annotation and memory-map completion silently went blank. (The happy-path checks always got a
clean identify, so this never showed in earlier verification.)

Two fixes:
- _probe_version retries GET_VERSION up to 3x, re-selecting between attempts, so a lost shot on
  a flaky link no longer costs the exact model (and its full memory map).
- memory._resolve_layout falls back to a generic Type 2 map (UID / CC / the always-user pages
  0x04-0x0F) when only the NTAG/Ultralight family is known — so READ(0)/READ(4) still annotate
  even when the exact model can't be determined (or an original Ultralight has no GET_VERSION).
  Config pages differ by model and are left unnamed rather than guessed.

Tests: retry recovers the model after two lost shots; a never-answering GET_VERSION yields the
generic name yet still annotates the universal pages; generic names resolve UID/CC/user but not
model-specific pages. 1328 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-15 18:23:38 -07:00
parent 33e713549d
commit 899142f23a
3 changed files with 74 additions and 13 deletions

View File

@@ -212,6 +212,30 @@ class TestIdentify:
assert r["version"] == NTAG216_VERSION.hex()
s.device.hf.iso14a.raw.assert_called_with(b"\x60", flags=0x20) # real GET_VERSION+CRC
def test_14a_getversion_retries_flaky_link(self):
# a lost GET_VERSION shot must not drop us to the generic name — retry recovers the model
s = RawSession(device=_mock_device(
scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"}))
s.device.hf.iso14a.raw.side_effect = [
{"raw": None}, # 1st shot lost
{"raw": None}, # 2nd shot lost
{"raw": NTAG216_VERSION + b"\x99\x88"}, # 3rd shot lands
]
identify(s)
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):
# 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
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"
def test_15693_when_e0_uid(self):
s = RawSession(device=_mock_device(scan15={"found": True, "uid": "e004010203040506"}))
r = identify(s)
@@ -638,6 +662,17 @@ class TestMemoryHint:
assert region_annotation("MIFARE Classic 1K", [4]) is None # no layout -> no annotation
assert region_annotation("NTAG216", []) is None
def test_generic_type2_fallback(self):
from pm3py.cli.rawcli.memory import region_annotation, 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, 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
def test_t5577_block_roles(self):
from pm3py.cli.rawcli.memory import page_role, input_hint
tp = "T5577 (EM4100 20260716FF)"