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:
@@ -205,19 +205,25 @@ def _identify_lf(dev, session, emit):
|
|||||||
|
|
||||||
|
|
||||||
def _probe_version(dev, fmt, emit) -> tuple[str | None, bytes | None]:
|
def _probe_version(dev, fmt, emit) -> tuple[str | None, bytes | None]:
|
||||||
"""Send a real GET_VERSION (0x60 +CRC), trace the exchange, decode the exact model."""
|
"""Send a real GET_VERSION (0x60 +CRC), trace the exchange, decode the exact model. Retried:
|
||||||
|
a single shot can be lost on a flaky NG link, and a miss drops us to the generic SAK name —
|
||||||
|
which loses the exact-model memory map (page roles, completion). Re-selects between attempts to
|
||||||
|
recover a desynced link."""
|
||||||
cmd = b"\x60"
|
cmd = b"\x60"
|
||||||
emit(fmt.format(0, cmd).strip("\n"))
|
emit(fmt.format(0, cmd).strip("\n"))
|
||||||
resp = _try(lambda: dev.hf.iso14a.raw(cmd, flags=ISO14A_APPEND_CRC), None)
|
for attempt in range(3):
|
||||||
raw = resp.get("raw") if isinstance(resp, dict) else None
|
resp = _try(lambda: dev.hf.iso14a.raw(cmd, flags=ISO14A_APPEND_CRC), None)
|
||||||
if not raw:
|
raw = resp.get("raw") if isinstance(resp, dict) else None
|
||||||
return None, None
|
if raw and len(raw) >= 8:
|
||||||
version = bytes(raw[:8])
|
version = bytes(raw[:8])
|
||||||
emit(fmt.format(1, bytes(raw)).strip("\n")) # raw() already trims to the received length
|
model = decode_version(version)
|
||||||
model = decode_version(version)
|
if model:
|
||||||
if model:
|
emit(fmt.format(1, bytes(raw)).strip("\n")) # raw() already trims to the RX length
|
||||||
emit(f" → {model}")
|
emit(f" → {model}")
|
||||||
return model, version
|
return model, version
|
||||||
|
if attempt < 2:
|
||||||
|
_try(lambda: dev.hf.iso14a.scan(), None) # re-select, then retry the version
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def clear(session) -> None:
|
def clear(session) -> None:
|
||||||
|
|||||||
@@ -34,6 +34,26 @@ _ROLES = {
|
|||||||
|
|
||||||
_CALL_HEAD = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*([^,)]*)")
|
_CALL_HEAD = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*([^,)]*)")
|
||||||
|
|
||||||
|
# The pages guaranteed on every Type 2 tag (down to the smallest, NTAG210): UID/CC and user
|
||||||
|
# memory 0x04-0x0F. Used when only the NTAG/Ultralight *family* is known — GET_VERSION couldn't
|
||||||
|
# complete on a flaky link, or an original Ultralight has no GET_VERSION. Config pages differ by
|
||||||
|
# model, so they're left unnamed rather than guessed.
|
||||||
|
_GENERIC_TYPE2 = {"user": (0x04, 0x0F)}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_layout(transponder: str | None) -> dict | None:
|
||||||
|
"""The page layout for ``transponder`` — the exact per-IC map when identify named the model,
|
||||||
|
else the generic Type 2 map when only the NTAG / Ultralight family is known, else None."""
|
||||||
|
if not transponder:
|
||||||
|
return None
|
||||||
|
layout = _LAYOUTS.get(transponder)
|
||||||
|
if layout is not None:
|
||||||
|
return layout
|
||||||
|
up = transponder.upper()
|
||||||
|
if "NTAG" in up or "ULTRALIGHT" in up:
|
||||||
|
return _GENERIC_TYPE2
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _t5577_block_role(block: int | None) -> str | None:
|
def _t5577_block_role(block: int | None) -> str | None:
|
||||||
"""Role of a T5577 block: 0 = config, 7 = password, 1-6 = data (the emulated tag content)."""
|
"""Role of a T5577 block: 0 = config, 7 = password, 1-6 = data (the emulated tag content)."""
|
||||||
@@ -53,7 +73,7 @@ def page_role(transponder: str | None, page: int | None) -> str | None:
|
|||||||
T5577 config / password / …), or None."""
|
T5577 config / password / …), or None."""
|
||||||
if transponder and "T5577" in transponder.upper():
|
if transponder and "T5577" in transponder.upper():
|
||||||
return _t5577_block_role(page)
|
return _t5577_block_role(page)
|
||||||
layout = _LAYOUTS.get(transponder or "")
|
layout = _resolve_layout(transponder)
|
||||||
if layout is None or page is None:
|
if layout is None or page is None:
|
||||||
return None
|
return None
|
||||||
if 0 <= page <= 2:
|
if 0 <= page <= 2:
|
||||||
@@ -76,7 +96,7 @@ def landmark_pages(transponder: str | None) -> list[tuple[int, str]]:
|
|||||||
has no known layout."""
|
has no known layout."""
|
||||||
if transponder and "T5577" in transponder.upper():
|
if transponder and "T5577" in transponder.upper():
|
||||||
return [(b, _t5577_block_role(b)) for b in range(8)]
|
return [(b, _t5577_block_role(b)) for b in range(8)]
|
||||||
layout = _LAYOUTS.get(transponder or "")
|
layout = _resolve_layout(transponder)
|
||||||
if layout is None:
|
if layout is None:
|
||||||
return []
|
return []
|
||||||
out = {0: "UID / serial number", 3: "Capability Container (CC)",
|
out = {0: "UID / serial number", 3: "Capability Container (CC)",
|
||||||
|
|||||||
@@ -212,6 +212,30 @@ class TestIdentify:
|
|||||||
assert r["version"] == NTAG216_VERSION.hex()
|
assert r["version"] == NTAG216_VERSION.hex()
|
||||||
s.device.hf.iso14a.raw.assert_called_with(b"\x60", flags=0x20) # real GET_VERSION+CRC
|
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):
|
def test_15693_when_e0_uid(self):
|
||||||
s = RawSession(device=_mock_device(scan15={"found": True, "uid": "e004010203040506"}))
|
s = RawSession(device=_mock_device(scan15={"found": True, "uid": "e004010203040506"}))
|
||||||
r = identify(s)
|
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("MIFARE Classic 1K", [4]) is None # no layout -> no annotation
|
||||||
assert region_annotation("NTAG216", []) is None
|
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):
|
def test_t5577_block_roles(self):
|
||||||
from pm3py.cli.rawcli.memory import page_role, input_hint
|
from pm3py.cli.rawcli.memory import page_role, input_hint
|
||||||
tp = "T5577 (EM4100 20260716FF)"
|
tp = "T5577 (EM4100 20260716FF)"
|
||||||
|
|||||||
Reference in New Issue
Block a user