| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279 |
- """Tests for daemon.pn5180 — ISO 14443-3 cascade levels in activate_type_a() (#3258).
- The PN5180 is replaced by a fake of its register and transceive commands with
- one tag in the field, so the real anticollision and SELECT sequence runs.
- """
- import importlib
- import importlib.util
- import sys
- import types
- from pathlib import Path
- from unittest.mock import MagicMock
- import pytest
- SEL = (0x93, 0x95, 0x97)
- CT = 0x88
- UID_4 = bytes.fromhex("1A2B3C4D") # Bambu tag: MIFARE Classic 1K, single-size UID
- UID_7 = bytes.fromhex("04ECE673D32A81") # NTAG215 from #3258, double-size UID
- UID_10 = bytes.fromhex("04112233445566778899") # triple-size UID
- def _bcc(part: bytes) -> int:
- return part[0] ^ part[1] ^ part[2] ^ part[3]
- def _cascade_parts(uid: bytes) -> list[bytes]:
- """The four UID bytes a tag answers at each cascade level (ISO 14443-3)."""
- if len(uid) == 4:
- return [uid]
- if len(uid) == 7:
- return [bytes([CT]) + uid[:3], uid[3:]]
- return [bytes([CT]) + uid[:3], bytes([CT]) + uid[3:6], uid[6:]]
- class FakeChip:
- """Register and transceive commands of a PN5180 with one Type A tag in the field.
- The tag answers WUPA/REQA, then anticollision and SELECT of the cascade level
- it is at, and nothing else. Like a real tag, it ignores an anticollision
- frame sent with CRC and a SELECT sent without one.
- """
- def __init__(self, uid: bytes, sak: int, parts: list[bytes] | None = None, bad_bcc_level=None, gone_at_level=None):
- self.parts = parts if parts is not None else _cascade_parts(uid)
- self.sak = sak
- self.bad_bcc_level = bad_bcc_level
- self.gone_at_level = gone_at_level
- self.regs = {0x00: 0, 0x12: 0, 0x19: 0}
- self.frames: list[bytes] = []
- self.level = None # cascade level the tag is at; None until woken
- self.response = b""
- # -- PN5180 commands --
- def write_reg(self, reg, val):
- self.regs[reg] = val
- def write_reg_or(self, reg, mask):
- self.regs[reg] = self.regs.get(reg, 0) | mask
- def write_reg_and(self, reg, mask):
- self.regs[reg] = self.regs.get(reg, 0) & mask
- def read_reg(self, reg):
- if reg == 0x13: # RX_STATUS: received length in bits 0-8
- return len(self.response)
- return self.regs.get(reg, 0)
- def read_data(self, length):
- return self.response[:length]
- def send_data(self, data, valid_bits=0x00):
- frame = bytes(data)
- self.frames.append(frame)
- self.response = self._answer(frame)
- # -- tag --
- def _answer(self, frame: bytes) -> bytes:
- crc = bool(self.regs[0x19] & 0x01) and bool(self.regs[0x12] & 0x01)
- if frame in (b"\x52", b"\x26"):
- self.level = 0
- return b"\x44\x00" # ATQA
- if self.level is None or self.level >= len(self.parts) or self.level == self.gone_at_level:
- return b""
- if frame[0] != SEL[self.level]:
- return b""
- part = self.parts[self.level]
- bcc = _bcc(part)
- if frame[1:] == b"\x20":
- if crc:
- return b""
- return part + bytes([bcc ^ 0xFF if self.level == self.bad_bcc_level else bcc])
- if frame[1:] == b"\x70" + part + bytes([bcc]):
- if not crc:
- return b""
- self.level += 1
- return bytes([0x04 if self.level < len(self.parts) else self.sak])
- return b""
- @pytest.fixture
- def pn5180_mod(monkeypatch):
- """daemon.pn5180 imported without gpiod/spidev, its sleeps skipped."""
- monkeypatch.setitem(sys.modules, "gpiod", MagicMock())
- monkeypatch.setitem(sys.modules, "spidev", MagicMock())
- monkeypatch.delitem(sys.modules, "daemon.pn5180", raising=False)
- mod = importlib.import_module("daemon.pn5180")
- monkeypatch.setattr(mod, "time", types.SimpleNamespace(sleep=lambda _s: None, monotonic=lambda: 0.0))
- return mod
- @pytest.fixture
- def read_tag_mod(monkeypatch):
- """scripts/read_tag.py, the Read Tag diagnostic, imported without gpiod/spidev, its sleeps skipped."""
- monkeypatch.setitem(sys.modules, "gpiod", MagicMock())
- monkeypatch.setitem(sys.modules, "spidev", MagicMock())
- spec = importlib.util.spec_from_file_location(
- "spoolbuddy_read_tag", Path(__file__).resolve().parents[1] / "scripts" / "read_tag.py"
- )
- mod = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(mod)
- monkeypatch.setattr(mod, "time", types.SimpleNamespace(sleep=lambda _s: None, monotonic=lambda: 0.0))
- return mod
- def _reader(mod, chip: FakeChip):
- """A PN5180 whose register and transceive commands go to ``chip``."""
- nfc = mod.PN5180.__new__(mod.PN5180)
- for name in ("write_reg", "write_reg_or", "write_reg_and", "read_reg", "read_data", "send_data"):
- setattr(nfc, name, getattr(chip, name))
- for name in ("reset", "load_rf_config", "rf_on", "rf_off"):
- setattr(nfc, name, lambda *_args: None)
- return nfc
- def _sels(chip: FakeChip) -> list[int]:
- return [frame[0] for frame in chip.frames if len(frame) > 1]
- class TestActivateTypeA:
- def test_single_size_uid_stops_at_level_1(self, pn5180_mod):
- chip = FakeChip(UID_4, sak=0x08)
- assert _reader(pn5180_mod, chip).activate_type_a() == (UID_4, 0x08)
- assert chip.frames == [
- b"\x52",
- b"\x93\x20",
- b"\x93\x70" + UID_4 + bytes([_bcc(UID_4)]),
- ]
- def test_double_size_uid_is_read_to_level_2(self, pn5180_mod):
- """An NTAG reports all seven bytes and its final SAK, not 88 + three bytes."""
- chip = FakeChip(UID_7, sak=0x00)
- uid, sak = _reader(pn5180_mod, chip).activate_type_a()
- assert uid.hex().upper() == "04ECE673D32A81"
- assert sak == 0x00
- level2 = UID_7[3:]
- assert chip.frames[-2:] == [b"\x95\x20", b"\x95\x70" + level2 + bytes([_bcc(level2)])]
- def test_triple_size_uid_is_read_to_level_3(self, pn5180_mod):
- chip = FakeChip(UID_10, sak=0x00)
- assert _reader(pn5180_mod, chip).activate_type_a() == (UID_10, 0x00)
- assert _sels(chip) == [0x93, 0x93, 0x95, 0x95, 0x97, 0x97]
- def test_double_size_mifare_classic_keeps_its_final_sak(self, pn5180_mod):
- chip = FakeChip(UID_7, sak=0x08)
- assert _reader(pn5180_mod, chip).activate_type_a() == (UID_7, 0x08)
- def test_crc_is_off_for_each_anticollision_and_on_for_each_select(self, pn5180_mod):
- """The fake tag ignores frames with the wrong CRC setting, so a full read proves it."""
- chip = FakeChip(UID_10, sak=0x00)
- assert _reader(pn5180_mod, chip).activate_type_a() is not None
- assert chip.level == 3
- def test_bad_bcc_at_level_2_is_no_tag(self, pn5180_mod):
- chip = FakeChip(UID_7, sak=0x00, bad_bcc_level=1)
- assert _reader(pn5180_mod, chip).activate_type_a() is None
- assert b"\x95\x20" in chip.frames
- assert not any(frame.startswith(b"\x95\x70") for frame in chip.frames)
- def test_no_answer_at_level_2_is_no_tag(self, pn5180_mod):
- """A tag that leaves the field after level 1 is not reported as 88 + three bytes."""
- chip = FakeChip(UID_7, sak=0x00, gone_at_level=1)
- assert _reader(pn5180_mod, chip).activate_type_a() is None
- def test_incomplete_sak_without_cascade_tag_is_no_tag(self, pn5180_mod):
- chip = FakeChip(UID_7, sak=0x00, parts=[UID_7[:4], UID_7[3:]])
- assert _reader(pn5180_mod, chip).activate_type_a() is None
- assert 0x95 not in _sels(chip)
- def test_uid_still_incomplete_after_level_3_is_no_tag(self, pn5180_mod):
- part = bytes([CT, 0x01, 0x02, 0x03])
- chip = FakeChip(UID_10, sak=0x00, parts=[part, part, part, part])
- assert _reader(pn5180_mod, chip).activate_type_a() is None
- assert _sels(chip) == [0x93, 0x93, 0x95, 0x95, 0x97, 0x97]
- class TestNfcReaderPoll:
- def test_ntag_is_reported_with_its_full_uid(self, pn5180_mod):
- from daemon.nfc_reader import NFCReader
- reader = NFCReader()
- reader._nfc = _reader(pn5180_mod, FakeChip(UID_7, sak=0x00))
- event, data = reader.poll()
- assert event == "tag_detected"
- assert data["tag_uid"] == "04ECE673D32A81"
- assert data["sak"] == 0x00
- assert data["tag_type"] == "ntag"
- # NTAGs are not Bambu tags: no block read, nothing for the backend to decode (#2685)
- assert data["raw_blocks"] is None
- def test_bambu_tag_is_reported_with_its_data_blocks(self, pn5180_mod):
- """A MIFARE Classic poll sends the blocks read_bambu_tag() read, as hex, for the backend to decode (#2685)."""
- from daemon.nfc_reader import NFCReader
- reader = NFCReader()
- reader._nfc = _reader(pn5180_mod, FakeChip(UID_4, sak=0x08))
- # Whatever blocks the driver is told to read; block 9 holds the tray UUID
- tray_uuid = bytes.fromhex("36B6C12DB3DA4A39A50C275F510BD399")
- reader._nfc.read_bambu_tag = MagicMock(
- side_effect=lambda uid: {
- block: tray_uuid if block == 9 else bytes([block]) * 16 for block in pn5180_mod.BAMBU_BLOCKS
- }
- )
- event, data = reader.poll()
- assert event == "tag_detected"
- assert data["tag_type"] == "mifare_classic"
- reader._nfc.read_bambu_tag.assert_called_once_with(UID_4)
- # Block 6 (temperatures) included, not just the blocks the tray UUID needs
- assert sorted(data["raw_blocks"], key=int) == ["1", "2", "4", "5", "6", "9"]
- assert data["raw_blocks"]["6"] == "06" * 16
- assert data["raw_blocks"]["9"] == "36B6C12DB3DA4A39A50C275F510BD399"
- assert data["tray_uuid"] == "36B6C12DB3DA4A39A50C275F510BD399"
- def test_failed_bambu_read_sends_no_blocks(self, pn5180_mod):
- from daemon.nfc_reader import NFCReader
- reader = NFCReader()
- reader._nfc = _reader(pn5180_mod, FakeChip(UID_4, sak=0x08))
- reader._nfc.read_bambu_tag = MagicMock(return_value=None)
- event, data = reader.poll()
- assert event == "tag_detected"
- assert data["raw_blocks"] is None
- assert data["tray_uuid"] is None
- class TestReadTagDiagnostic:
- """The Read Tag diagnostic has its own activate_type_a() and shows the UID the daemon reports."""
- @pytest.mark.parametrize(
- ("uid", "sak"), [(UID_4, 0x08), (UID_7, 0x00), (UID_10, 0x00)], ids=["4-byte", "7-byte", "10-byte"]
- )
- def test_the_full_uid_is_read(self, read_tag_mod, uid, sak):
- chip = FakeChip(uid, sak=sak)
- assert _reader(read_tag_mod, chip).activate_type_a() == (uid, sak)
- def test_a_tag_gone_at_level_2_is_no_tag(self, read_tag_mod):
- chip = FakeChip(UID_7, sak=0x00, gone_at_level=1)
- assert _reader(read_tag_mod, chip).activate_type_a() is None
|