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

@@ -205,19 +205,25 @@ def _identify_lf(dev, session, emit):
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"
emit(fmt.format(0, cmd).strip("\n"))
resp = _try(lambda: dev.hf.iso14a.raw(cmd, flags=ISO14A_APPEND_CRC), None)
raw = resp.get("raw") if isinstance(resp, dict) else None
if not raw:
return None, None
version = bytes(raw[:8])
emit(fmt.format(1, bytes(raw)).strip("\n")) # raw() already trims to the received length
model = decode_version(version)
if model:
emit(f"{model}")
return model, version
for attempt in range(3):
resp = _try(lambda: dev.hf.iso14a.raw(cmd, flags=ISO14A_APPEND_CRC), None)
raw = resp.get("raw") if isinstance(resp, dict) else None
if raw and len(raw) >= 8:
version = bytes(raw[:8])
model = decode_version(version)
if model:
emit(fmt.format(1, bytes(raw)).strip("\n")) # raw() already trims to the RX length
emit(f"{model}")
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:

View File

@@ -34,6 +34,26 @@ _ROLES = {
_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:
"""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."""
if transponder and "T5577" in transponder.upper():
return _t5577_block_role(page)
layout = _LAYOUTS.get(transponder or "")
layout = _resolve_layout(transponder)
if layout is None or page is None:
return None
if 0 <= page <= 2:
@@ -76,7 +96,7 @@ def landmark_pages(transponder: str | None) -> list[tuple[int, str]]:
has no known layout."""
if transponder and "T5577" in transponder.upper():
return [(b, _t5577_block_role(b)) for b in range(8)]
layout = _LAYOUTS.get(transponder or "")
layout = _resolve_layout(transponder)
if layout is None:
return []
out = {0: "UID / serial number", 3: "Capability Container (CC)",