test_pn5180.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. """Tests for daemon.pn5180 — ISO 14443-3 cascade levels in activate_type_a() (#3258).
  2. The PN5180 is replaced by a fake of its register and transceive commands with
  3. one tag in the field, so the real anticollision and SELECT sequence runs.
  4. """
  5. import importlib
  6. import importlib.util
  7. import sys
  8. import types
  9. from pathlib import Path
  10. from unittest.mock import MagicMock
  11. import pytest
  12. SEL = (0x93, 0x95, 0x97)
  13. CT = 0x88
  14. UID_4 = bytes.fromhex("1A2B3C4D") # Bambu tag: MIFARE Classic 1K, single-size UID
  15. UID_7 = bytes.fromhex("04ECE673D32A81") # NTAG215 from #3258, double-size UID
  16. UID_10 = bytes.fromhex("04112233445566778899") # triple-size UID
  17. def _bcc(part: bytes) -> int:
  18. return part[0] ^ part[1] ^ part[2] ^ part[3]
  19. def _cascade_parts(uid: bytes) -> list[bytes]:
  20. """The four UID bytes a tag answers at each cascade level (ISO 14443-3)."""
  21. if len(uid) == 4:
  22. return [uid]
  23. if len(uid) == 7:
  24. return [bytes([CT]) + uid[:3], uid[3:]]
  25. return [bytes([CT]) + uid[:3], bytes([CT]) + uid[3:6], uid[6:]]
  26. class FakeChip:
  27. """Register and transceive commands of a PN5180 with one Type A tag in the field.
  28. The tag answers WUPA/REQA, then anticollision and SELECT of the cascade level
  29. it is at, and nothing else. Like a real tag, it ignores an anticollision
  30. frame sent with CRC and a SELECT sent without one.
  31. """
  32. def __init__(self, uid: bytes, sak: int, parts: list[bytes] | None = None, bad_bcc_level=None, gone_at_level=None):
  33. self.parts = parts if parts is not None else _cascade_parts(uid)
  34. self.sak = sak
  35. self.bad_bcc_level = bad_bcc_level
  36. self.gone_at_level = gone_at_level
  37. self.regs = {0x00: 0, 0x12: 0, 0x19: 0}
  38. self.frames: list[bytes] = []
  39. self.level = None # cascade level the tag is at; None until woken
  40. self.response = b""
  41. # -- PN5180 commands --
  42. def write_reg(self, reg, val):
  43. self.regs[reg] = val
  44. def write_reg_or(self, reg, mask):
  45. self.regs[reg] = self.regs.get(reg, 0) | mask
  46. def write_reg_and(self, reg, mask):
  47. self.regs[reg] = self.regs.get(reg, 0) & mask
  48. def read_reg(self, reg):
  49. if reg == 0x13: # RX_STATUS: received length in bits 0-8
  50. return len(self.response)
  51. return self.regs.get(reg, 0)
  52. def read_data(self, length):
  53. return self.response[:length]
  54. def send_data(self, data, valid_bits=0x00):
  55. frame = bytes(data)
  56. self.frames.append(frame)
  57. self.response = self._answer(frame)
  58. # -- tag --
  59. def _answer(self, frame: bytes) -> bytes:
  60. crc = bool(self.regs[0x19] & 0x01) and bool(self.regs[0x12] & 0x01)
  61. if frame in (b"\x52", b"\x26"):
  62. self.level = 0
  63. return b"\x44\x00" # ATQA
  64. if self.level is None or self.level >= len(self.parts) or self.level == self.gone_at_level:
  65. return b""
  66. if frame[0] != SEL[self.level]:
  67. return b""
  68. part = self.parts[self.level]
  69. bcc = _bcc(part)
  70. if frame[1:] == b"\x20":
  71. if crc:
  72. return b""
  73. return part + bytes([bcc ^ 0xFF if self.level == self.bad_bcc_level else bcc])
  74. if frame[1:] == b"\x70" + part + bytes([bcc]):
  75. if not crc:
  76. return b""
  77. self.level += 1
  78. return bytes([0x04 if self.level < len(self.parts) else self.sak])
  79. return b""
  80. @pytest.fixture
  81. def pn5180_mod(monkeypatch):
  82. """daemon.pn5180 imported without gpiod/spidev, its sleeps skipped."""
  83. monkeypatch.setitem(sys.modules, "gpiod", MagicMock())
  84. monkeypatch.setitem(sys.modules, "spidev", MagicMock())
  85. monkeypatch.delitem(sys.modules, "daemon.pn5180", raising=False)
  86. mod = importlib.import_module("daemon.pn5180")
  87. monkeypatch.setattr(mod, "time", types.SimpleNamespace(sleep=lambda _s: None, monotonic=lambda: 0.0))
  88. return mod
  89. @pytest.fixture
  90. def read_tag_mod(monkeypatch):
  91. """scripts/read_tag.py, the Read Tag diagnostic, imported without gpiod/spidev, its sleeps skipped."""
  92. monkeypatch.setitem(sys.modules, "gpiod", MagicMock())
  93. monkeypatch.setitem(sys.modules, "spidev", MagicMock())
  94. spec = importlib.util.spec_from_file_location(
  95. "spoolbuddy_read_tag", Path(__file__).resolve().parents[1] / "scripts" / "read_tag.py"
  96. )
  97. mod = importlib.util.module_from_spec(spec)
  98. spec.loader.exec_module(mod)
  99. monkeypatch.setattr(mod, "time", types.SimpleNamespace(sleep=lambda _s: None, monotonic=lambda: 0.0))
  100. return mod
  101. def _reader(mod, chip: FakeChip):
  102. """A PN5180 whose register and transceive commands go to ``chip``."""
  103. nfc = mod.PN5180.__new__(mod.PN5180)
  104. for name in ("write_reg", "write_reg_or", "write_reg_and", "read_reg", "read_data", "send_data"):
  105. setattr(nfc, name, getattr(chip, name))
  106. for name in ("reset", "load_rf_config", "rf_on", "rf_off"):
  107. setattr(nfc, name, lambda *_args: None)
  108. return nfc
  109. def _sels(chip: FakeChip) -> list[int]:
  110. return [frame[0] for frame in chip.frames if len(frame) > 1]
  111. class TestActivateTypeA:
  112. def test_single_size_uid_stops_at_level_1(self, pn5180_mod):
  113. chip = FakeChip(UID_4, sak=0x08)
  114. assert _reader(pn5180_mod, chip).activate_type_a() == (UID_4, 0x08)
  115. assert chip.frames == [
  116. b"\x52",
  117. b"\x93\x20",
  118. b"\x93\x70" + UID_4 + bytes([_bcc(UID_4)]),
  119. ]
  120. def test_double_size_uid_is_read_to_level_2(self, pn5180_mod):
  121. """An NTAG reports all seven bytes and its final SAK, not 88 + three bytes."""
  122. chip = FakeChip(UID_7, sak=0x00)
  123. uid, sak = _reader(pn5180_mod, chip).activate_type_a()
  124. assert uid.hex().upper() == "04ECE673D32A81"
  125. assert sak == 0x00
  126. level2 = UID_7[3:]
  127. assert chip.frames[-2:] == [b"\x95\x20", b"\x95\x70" + level2 + bytes([_bcc(level2)])]
  128. def test_triple_size_uid_is_read_to_level_3(self, pn5180_mod):
  129. chip = FakeChip(UID_10, sak=0x00)
  130. assert _reader(pn5180_mod, chip).activate_type_a() == (UID_10, 0x00)
  131. assert _sels(chip) == [0x93, 0x93, 0x95, 0x95, 0x97, 0x97]
  132. def test_double_size_mifare_classic_keeps_its_final_sak(self, pn5180_mod):
  133. chip = FakeChip(UID_7, sak=0x08)
  134. assert _reader(pn5180_mod, chip).activate_type_a() == (UID_7, 0x08)
  135. def test_crc_is_off_for_each_anticollision_and_on_for_each_select(self, pn5180_mod):
  136. """The fake tag ignores frames with the wrong CRC setting, so a full read proves it."""
  137. chip = FakeChip(UID_10, sak=0x00)
  138. assert _reader(pn5180_mod, chip).activate_type_a() is not None
  139. assert chip.level == 3
  140. def test_bad_bcc_at_level_2_is_no_tag(self, pn5180_mod):
  141. chip = FakeChip(UID_7, sak=0x00, bad_bcc_level=1)
  142. assert _reader(pn5180_mod, chip).activate_type_a() is None
  143. assert b"\x95\x20" in chip.frames
  144. assert not any(frame.startswith(b"\x95\x70") for frame in chip.frames)
  145. def test_no_answer_at_level_2_is_no_tag(self, pn5180_mod):
  146. """A tag that leaves the field after level 1 is not reported as 88 + three bytes."""
  147. chip = FakeChip(UID_7, sak=0x00, gone_at_level=1)
  148. assert _reader(pn5180_mod, chip).activate_type_a() is None
  149. def test_incomplete_sak_without_cascade_tag_is_no_tag(self, pn5180_mod):
  150. chip = FakeChip(UID_7, sak=0x00, parts=[UID_7[:4], UID_7[3:]])
  151. assert _reader(pn5180_mod, chip).activate_type_a() is None
  152. assert 0x95 not in _sels(chip)
  153. def test_uid_still_incomplete_after_level_3_is_no_tag(self, pn5180_mod):
  154. part = bytes([CT, 0x01, 0x02, 0x03])
  155. chip = FakeChip(UID_10, sak=0x00, parts=[part, part, part, part])
  156. assert _reader(pn5180_mod, chip).activate_type_a() is None
  157. assert _sels(chip) == [0x93, 0x93, 0x95, 0x95, 0x97, 0x97]
  158. class TestNfcReaderPoll:
  159. def test_ntag_is_reported_with_its_full_uid(self, pn5180_mod):
  160. from daemon.nfc_reader import NFCReader
  161. reader = NFCReader()
  162. reader._nfc = _reader(pn5180_mod, FakeChip(UID_7, sak=0x00))
  163. event, data = reader.poll()
  164. assert event == "tag_detected"
  165. assert data["tag_uid"] == "04ECE673D32A81"
  166. assert data["sak"] == 0x00
  167. assert data["tag_type"] == "ntag"
  168. class TestReadTagDiagnostic:
  169. """The Read Tag diagnostic has its own activate_type_a() and shows the UID the daemon reports."""
  170. @pytest.mark.parametrize(
  171. ("uid", "sak"), [(UID_4, 0x08), (UID_7, 0x00), (UID_10, 0x00)], ids=["4-byte", "7-byte", "10-byte"]
  172. )
  173. def test_the_full_uid_is_read(self, read_tag_mod, uid, sak):
  174. chip = FakeChip(uid, sak=sak)
  175. assert _reader(read_tag_mod, chip).activate_type_a() == (uid, sak)
  176. def test_a_tag_gone_at_level_2_is_no_tag(self, read_tag_mod):
  177. chip = FakeChip(UID_7, sak=0x00, gone_at_level=1)
  178. assert _reader(read_tag_mod, chip).activate_type_a() is None