diff --git a/pm3py/cli/rawcli/identify.py b/pm3py/cli/rawcli/identify.py index 7b1c52b..c41ed0c 100644 --- a/pm3py/cli/rawcli/identify.py +++ b/pm3py/cli/rawcli/identify.py @@ -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: diff --git a/pm3py/cli/rawcli/memory.py b/pm3py/cli/rawcli/memory.py index 239c87f..89040be 100644 --- a/pm3py/cli/rawcli/memory.py +++ b/pm3py/cli/rawcli/memory.py @@ -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)", diff --git a/tests/test_rawcli.py b/tests/test_rawcli.py index fcbc1c7..9b29e79 100644 --- a/tests/test_rawcli.py +++ b/tests/test_rawcli.py @@ -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)"