feat(rawcli): identify NTAG5 and ICODE DNA (READ_CONFIG probe)

NTAG5, ICODE DNA and SLIX2 all share E0 04 and answer READ_SIGNATURE, so name_iso15
now splits them with an addressed, retried READ_CONFIG (0xC0) probe:
  - session register 0xA0 (STATUS_REG) succeeds only on NTAG5      -> "NTAG 5"
  - 0xA0 errors but config block 0x00 succeeds (DNA has config mem) -> "ICODE DNA"
  - neither (SLIX2 has no READ_CONFIG) -> the 0xBD signature step   -> "ICODE SLIX2"
  - plain SLIX answers neither                                      -> "ICODE SLIX"

Hardware-verified against a real NTAG5 (VivoKey VK Thermo, UID E0 04 01 58, 0xA0 ->
00 01400000) and a real ICODE DNA (UID E0 04 01 18, 0xA0 -> 01 0F error, 0x00 -> 00 ...).
DNA was previously mislabeled "ICODE SLIX2". catalog_for routes DNA to the SLIX2 catalog
for now (a dedicated DNA catalog with READ_CONFIG + AES is a follow-up). Adds a mocked
unit test covering all four branches from the real captures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-16 13:33:41 -07:00
parent 9e18a10274
commit 4684d0cc83
4 changed files with 66 additions and 10 deletions

View File

@@ -47,12 +47,13 @@ rendered dropdown on a real terminal is the only thing left unautomated.
15693 catalogs. The **full request-flags byte** is settable: sub-carrier (0x01), data-rate,
inventory, protocol-ext (0x08), and the mode-dependent bits 5-6 (Select/Address vs AFI byte +
Nb_slots 1/16), plus option. `iso15_flags_decode` decodes every bit for the byte-0 hint.
2. **Per-chip identify**`identify.name_iso15` distinguishes SLIX vs SLIX2 (READ_SIGNATURE 0xBD
probe). **NTAG5 and ICODE DNA both carry UID `E0 04 01 18`** (identical type indicator — model
comment confirms), so they can't be told apart by UID. Routing NTAG5 needs a **functional
probe** (e.g. READ_CONFIG 0xC0 of session register 0xA0/0xA1 — NTAG5 has them, DNA doesn't).
⚠ HARDWARE-DEPENDENT: needs a brief NTAG5 tap to capture the discriminating response (and ideally
a DNA capture to confirm it differs) before wiring — otherwise it risks misclassifying DNA.
2. **Per-chip identify**`identify.name_iso15` now splits the whole E0 04 family with a
READ_CONFIG (0xC0) probe (addressed + retried), **hardware-verified against a real NTAG5 and a
real DNA**: session register 0xA0 succeeds only on NTAG5 (`00 01400000`); DNA errors on 0xA0
(`01 0F`) but its config block 0x00 succeeds; SLIX2 has no READ_CONFIG (falls to the 0xBD
signature step); plain SLIX answers neither. So NTAG5 → "NTAG 5", DNA → "ICODE DNA" (was
mislabeled SLIX2), SLIX2/SLIX as before. Real UIDs: NTAG5 `E0 04 01 58`, DNA `E0 04 01 18`
(the model's NTAG5 prefix `…18` was copied from DNA; real NTAG5 is `…58`).
3. **Catalogs**`_SLIX`, `_SLIX2`, and **`_NTAG5`** ✅ all done, dispatched by `catalog_for`,
verified live. `_NTAG5` = SLIX2 minus STAY_QUIET_PERSISTENT (NTAG5 rejects it) + READ_CONFIG
(0xC0) / WRITE_CONFIG (0xC1) / PICK_RANDOM_UID (0xC2) / READ_SRAM (0xD2) / WRITE_SRAM (0xD3).

View File

@@ -527,7 +527,7 @@ def catalog_for(session) -> Catalog | None:
if session.protocol == "hf15":
if "NTAG5" in name or "NTAG 5" in name:
return _NTAG5
if "SLIX2" in name:
if "SLIX2" in name or "DNA" in name: # DNA: closest existing set; own catalog is a follow-up
return _SLIX2
return _SLIX if "SLIX" in name or "ICODE" in name else ISO15
if session.protocol == "hf14a":

View File

@@ -97,11 +97,38 @@ def _iso15_uid(dev) -> str | None:
def name_iso15(dev, uid15: str) -> str:
"""Name a 15693 tag from its UID + a probe. ``E0 04`` = NXP ICODE; distinguish ICODE SLIX
(no READ_SIGNATURE) from SLIX2 (supports it, 0xBD). Non-NXP falls back to generic ISO15693.
(ICODE DNA / NTAG5 also share E0 04 and would need a finer probe — a later refinement.)"""
"""Name a 15693 tag from its UID + probes. ``E0 04`` = NXP ICODE. NTAG5 shares ``E0 04 01`` with
ICODE DNA, so it is told apart by a functional probe (see below); SLIX2 is distinguished from
plain SLIX by READ_SIGNATURE (0xBD). Non-NXP falls back to generic ISO15693."""
if not uid15.lower().startswith("e004"):
return "ISO15693"
# NTAG5 / DNA / SLIX2 all share E0 04 and answer READ_SIGNATURE, so split them with READ_CONFIG
# (0xC0), addressed + retried (these tags are finicky). Session register 0xA0 (STATUS_REG)
# succeeds only on NTAG5 (its I2C-side registers). DNA has no session registers (0xA0 errors)
# but does have config memory (block 0x00 succeeds); SLIX2 has no READ_CONFIG command at all.
# Hardware-verified: NTAG5 (E0 04 01 58) 0xA0 -> 00 01400000; DNA (E0 04 01 18) 0xA0 -> 01 0F,
# 0x00 -> 00 ...
try:
uid_wire = bytes.fromhex(uid15)[::-1] # UID LSByte-first for addressing
def _read_config(block: int):
frame = bytes([0x22, 0xC0, 0x04]) + uid_wire + bytes([block, 0x00])
for _ in range(6):
r = _try(lambda: dev.hf.iso15.raw(frame), None)
raw = r.get("raw") if isinstance(r, dict) else None
if raw and raw[0] in (0x00, 0x01): # a real ok/error response (not a miss)
return raw
return None
a0 = _read_config(0xA0)
if a0 and a0[0] == 0x00 and len(a0) >= 5: # valid STATUS_REG -> NTAG5
return "NTAG 5"
if a0 and a0[0] == 0x01: # 0xA0 errored -> not NTAG5; DNA has
cfg0 = _read_config(0x00) # config memory, SLIX2 has no 0xC0
if cfg0 and cfg0[0] == 0x00 and len(cfg0) >= 5:
return "ICODE DNA"
except ValueError:
pass
# READ_SIGNATURE (0xBD): SLIX2 returns a 32-byte ECC signature (response >= 33 bytes with the
# flags byte); plain SLIX ignores the command. Require the full length so a flaky/stale frame
# can't false-positive; probe twice in case the real signature frame is dropped.

View File

@@ -268,6 +268,34 @@ class TestIdentify:
r = identify(s)
assert r["found"] is False and s.transponder is None
def test_name_iso15_ntag5_dna_slix2_slix(self):
# split the E0 04 family by READ_CONFIG (0xC0) probe, using the real hardware captures:
# NTAG5 -> session reg 0xA0 succeeds; DNA -> 0xA0 errors but config 0x00 succeeds;
# SLIX2 -> no READ_CONFIG (timeout) but READ_SIGNATURE answers; SLIX -> neither.
from pm3py.cli.rawcli.identify import name_iso15
from unittest.mock import MagicMock
def dev_with(responses):
dev = MagicMock()
def _raw(frame):
f = bytes(frame)
if f[:2] == b"\x22\xc0": # READ_CONFIG addressed: key by block
return responses.get(("cfg", f[-2]), {"raw": None})
if f[:3] == b"\x02\xbd\x04": # READ_SIGNATURE
return responses.get("sig", {"raw": None})
return {"raw": None}
dev.hf.iso15.raw.side_effect = _raw
return dev
ntag5 = dev_with({("cfg", 0xA0): {"raw": bytes.fromhex("0001400000bad5")}})
assert name_iso15(ntag5, "e0040158108ee802") == "NTAG 5"
dna = dev_with({("cfg", 0xA0): {"raw": bytes.fromhex("010f68ee")},
("cfg", 0x00): {"raw": bytes.fromhex("001e4d3235a1b8")}})
assert name_iso15(dna, "e0040118009b4ccb") == "ICODE DNA"
slix2 = dev_with({"sig": {"raw": bytes([0x00]) + bytes(34)}}) # 35-byte signature reply
assert name_iso15(slix2, "e0040203040506") == "ICODE SLIX2"
assert name_iso15(dev_with({}), "e004a2110b37c06b") == "ICODE SLIX"
def test_no_flat_lf(self):
# nothing on HF and a flat LF envelope must report not-found, not a bogus "LF tag"
assert identify(RawSession(device=_mock_device()))["found"] is False