Skip to content

Commit a13bc9e

Browse files
Support ntag216
1 parent 07fc06b commit a13bc9e

3 files changed

Lines changed: 199 additions & 27 deletions

File tree

‎src/reader/fm175xx/constants.py‎

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -114,15 +114,16 @@
114114
FM175XX_CARD_INFO_READ = 0
115115
FM175XX_CARD_INFO_CLEAR = 1
116116

117-
# About NTAG215 Card
118-
FM175XX_NTAG215_TOTAL_PAGES = 135
119-
FM175XX_NTAG215_USER_START_PAGE = 4
120-
FM175XX_NTAG215_USER_END_PAGE = 129
121-
FM175XX_NTAG215_BYTES_PER_PAGE = 4
122-
FM175XX_NTAG215_TOTAL_SIZE = 540
123-
FM175XX_ULTRALIGHT_TOTAL_PAGES = 44
124-
125-
FM175XX_ULTRALIGHT_VALID_END_PAGES = [FM175XX_NTAG215_TOTAL_PAGES, FM175XX_ULTRALIGHT_TOTAL_PAGES]
117+
# Ultralight / NTAG Type 2 tags
118+
FM175XX_ULTRALIGHT_BYTES_PER_PAGE = 4
119+
# Default CC size byte -> physical page count, including header and configuration.
120+
# CC sizes describe the NDEF area and are smaller than the physical NTAG memory.
121+
FM175XX_ULTRALIGHT_PAGE_COUNTS_BY_CC_SIZE = {
122+
0x06: 16, # MIFARE Ultralight
123+
0x12: 45, # NTAG213
124+
0x3E: 135, # NTAG215
125+
0x6D: 231, # NTAG216
126+
}
126127

127128
# About M1 Card
128129
# EEPROM
@@ -144,4 +145,4 @@
144145
FM175XX_SELF_TEST_STAGE_DOING = 1
145146
FM175XX_SELF_TEST_STAGE_STOP = 2
146147

147-
FM175XX_MIN_TIME = 0.200
148+
FM175XX_MIN_TIME = 0.200

‎src/reader/fm175xx/rfid.py‎

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def read_mifare_ultralight(self, scan_result: ScanResult) -> bytes | None:
7777
data = self.__reader_a_ultralight_read_all_data()
7878

7979
if data.err_code != Constants.FM175XX_OK:
80-
self.logger.error("Mifare Classic read error: %d", data.err_code)
80+
self.logger.error("Mifare Ultralight read error: %d", data.err_code)
8181
return None
8282

8383
return bytes(data.out_data)
@@ -657,7 +657,7 @@ def __reader_a_m1_read_all_data(self, uid:list, auth_mode:int, auth_key : TagAut
657657
ret.out_data = card_data_tmp
658658
return ret
659659

660-
# Reader-A: NTAG/Ultralight, read a page (4 bytes)
660+
# Reader-A: NTAG/Ultralight, read four consecutive pages (16 bytes)
661661
def __reader_a_ultralight_page_read(self, page:int) -> Fm175xxReturnVal:
662662
outbuf = [0] * 2
663663
inbuf = [0] * 16
@@ -688,30 +688,40 @@ def __reader_a_ultralight_page_read(self, page:int) -> Fm175xxReturnVal:
688688
return ret
689689

690690
# TODO: Maybe don't call it ultralight but the actual ISO specification
691-
# Reader-A: NTAG215, read all data
691+
# Reader-A: Ultralight/NTAG, read all physical pages for the known CC size.
692692
def __reader_a_ultralight_read_all_data(self, retry_times = 3) -> Fm175xxReturnVal:
693693
ret = Fm175xxReturnVal()
694-
card_data_tmp = [0] * Constants.FM175XX_NTAG215_TOTAL_SIZE
695-
696-
for page_no in range(0, Constants.FM175XX_NTAG215_TOTAL_PAGES, 4):
697-
result = Fm175xxReturnVal()
694+
ret.err_code = Constants.FM175XX_CARD_READ_ERR
695+
if retry_times < 1:
696+
return ret
697+
698+
card_data_tmp = []
699+
total_size = 16 # Pages 0-3 include the capability container (CC).
700+
701+
while len(card_data_tmp) < total_size:
702+
# READ always returns four pages. Overlap the last read if needed
703+
# so it includes the final physical page without rollover bytes.
704+
area = min(len(card_data_tmp), total_size - 16)
705+
page_no = area // Constants.FM175XX_ULTRALIGHT_BYTES_PER_PAGE
698706
for _ in range(retry_times):
699707
result = self.__reader_a_ultralight_page_read(page_no)
700708
if (result.err_code == Constants.FM175XX_OK):
701709
break
702710
if (result.err_code != Constants.FM175XX_OK):
703-
if (page_no - 4) in Constants.FM175XX_ULTRALIGHT_VALID_END_PAGES:
704-
card_data_tmp = card_data_tmp[0 : (page_no * Constants.FM175XX_NTAG215_BYTES_PER_PAGE)]
705-
break
706-
707-
ret.err_code = Constants.FM175XX_CARD_READ_ERR
708711
return ret
709712

710-
area = page_no * Constants.FM175XX_NTAG215_BYTES_PER_PAGE
711-
bytes_to_copy = min(16, Constants.FM175XX_NTAG215_TOTAL_SIZE - area)
712-
if bytes_to_copy > 0:
713-
card_data_tmp[area : area + bytes_to_copy] = result.out_data[0 : bytes_to_copy]
713+
if page_no == 0:
714+
# Use the default CC size to look up the physical capacity;
715+
# the NDEF size alone would omit user/configuration pages.
716+
cc = result.out_data[12:16]
717+
page_count = Constants.FM175XX_ULTRALIGHT_PAGE_COUNTS_BY_CC_SIZE.get(cc[2])
718+
if cc[0] != 0xE1 or page_count is None:
719+
self.logger.error("Invalid or unsupported Ultralight/NTAG capability container: %s", bytes(cc).hex())
720+
return ret
721+
total_size = page_count * Constants.FM175XX_ULTRALIGHT_BYTES_PER_PAGE
722+
723+
card_data_tmp.extend(result.out_data[len(card_data_tmp) - area:])
714724

715725
ret.err_code = Constants.FM175XX_OK
716726
ret.out_data = card_data_tmp
717-
return ret
727+
return ret
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Exercise Type 2 memory boundaries through the real reader's READ command."""
2+
import logging
3+
from pathlib import Path
4+
import sys
5+
from types import ModuleType
6+
from unittest.mock import Mock
7+
8+
import pytest
9+
import yaml
10+
11+
from test_tags import (
12+
PROCESSOR_FIXTURES,
13+
_assert_expected_matches_actual,
14+
_collect_fixture_cases,
15+
)
16+
17+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
18+
19+
from reader.scan_result import ScanResult
20+
from tag.tag_types import TagType
21+
22+
23+
@pytest.fixture
24+
def reader(monkeypatch):
25+
# The RF command is simulated; no Linux GPIO/SPI hardware is accessed.
26+
monkeypatch.setitem(sys.modules, "gpiod", ModuleType("gpiod"))
27+
monkeypatch.setitem(sys.modules, "spidev", ModuleType("spidev"))
28+
from reader.fm175xx.rfid import Fm175xx
29+
30+
instance = Fm175xx.__new__(Fm175xx)
31+
instance.logger = logging.getLogger("test.ultralight")
32+
return instance
33+
34+
35+
@pytest.fixture
36+
def scan():
37+
return ScanResult(TagType.MifareUltralight, b"\x04\x01\x02\x03\x04\x05\x06", b"\x00\x44", b"\x00", b"\x04\x00")
38+
39+
40+
def memory(size_byte):
41+
physical_sizes = {0x06: 64, 0x12: 180, 0x3E: 540, 0x6D: 924}
42+
data = bytearray(i % 251 for i in range(physical_sizes[size_byte]))
43+
data[12:16] = bytes([0xE1, 0x10, size_byte, 0x00])
44+
return bytes(data)
45+
46+
47+
def mock_tag(reader, monkeypatch, data, failures=None, short_page=None):
48+
from reader.fm175xx import constants
49+
from reader.fm175xx.rfid import Fm175xxReturnVal
50+
51+
failures = dict(failures or {})
52+
53+
def execute(cmd):
54+
assert cmd.send_buff[0] == 0x30
55+
assert cmd.bytes_to_recv == 16
56+
page = cmd.send_buff[1]
57+
assert 0 <= page <= 255
58+
offset = page * 4
59+
assert offset + 16 <= len(data), "READ crossed the physical memory boundary"
60+
result = Fm175xxReturnVal()
61+
if failures.get(page, 0):
62+
failures[page] -= 1
63+
result.err_code = constants.FM175XX_CARD_TIMER_ERR
64+
else:
65+
result.err_code = constants.FM175XX_OK
66+
result.out_data = list(data[offset:offset + 16])
67+
if page == short_page:
68+
result.out_data.pop()
69+
return result
70+
71+
command = Mock(side_effect=execute)
72+
monkeypatch.setattr(reader, "_Fm175xx__command_exe", command)
73+
return command
74+
75+
76+
@pytest.mark.parametrize("size_byte,total_size,last_page", [
77+
pytest.param(0x06, 64, 12, id="ultralight"),
78+
pytest.param(0x12, 180, 41, id="ntag213"),
79+
pytest.param(0x3E, 540, 131, id="ntag215"),
80+
pytest.param(0x6D, 924, 227, id="ntag216"),
81+
])
82+
def test_reads_all_physical_pages(reader, scan, monkeypatch, size_byte, total_size, last_page):
83+
data = memory(size_byte)
84+
command = mock_tag(reader, monkeypatch, data)
85+
86+
result = reader.read_mifare_ultralight(scan)
87+
88+
assert result == data
89+
assert len(result) == total_size
90+
assert command.call_args_list[0].args[0].send_buff == [0x30, 0]
91+
assert command.call_args_list[-1].args[0].send_buff == [0x30, last_page]
92+
assert command.call_count == (total_size + 15) // 16
93+
assert sum(call.args[0].send_buff[1] == 0 for call in command.call_args_list) == 1
94+
95+
96+
@pytest.mark.parametrize("cc", [
97+
bytes.fromhex("00 00 00 00"),
98+
bytes.fromhex("00 10 12 00"),
99+
bytes.fromhex("e1 10 00 00"),
100+
bytes.fromhex("e1 10 01 00"),
101+
bytes.fromhex("e1 10 7f 00"),
102+
bytes.fromhex("e1 10 ff 00"),
103+
])
104+
def test_invalid_cc_fails_before_reading_data(reader, scan, monkeypatch, cc):
105+
data = bytearray(memory(0x12))
106+
data[12:16] = cc
107+
command = mock_tag(reader, monkeypatch, data)
108+
109+
assert reader.read_mifare_ultralight(scan) is None
110+
assert command.call_count == 1
111+
112+
113+
@pytest.mark.parametrize("size_byte,page", [
114+
(0x6D, 0), (0x6D, 4), (0x6D, 48), (0x6D, 132),
115+
(0x06, 12), (0x12, 41), (0x3E, 131), (0x6D, 227),
116+
])
117+
def test_persistent_read_failure_never_returns_partial_success(reader, scan, monkeypatch, size_byte, page):
118+
# Page 48 used to be accepted as a successful end after three failed reads.
119+
command = mock_tag(reader, monkeypatch, memory(size_byte), failures={page: 3})
120+
121+
assert reader.read_mifare_ultralight(scan) is None
122+
assert [call.args[0].send_buff[1] for call in command.call_args_list[-3:]] == [page] * 3
123+
124+
125+
@pytest.mark.parametrize("page", [0, 4, 227])
126+
def test_transient_failure_retries_and_returns_complete_data(reader, scan, monkeypatch, page):
127+
data = memory(0x6D)
128+
command = mock_tag(reader, monkeypatch, data, failures={page: 2})
129+
130+
assert reader.read_mifare_ultralight(scan) == data
131+
assert sum(call.args[0].send_buff[1] == page for call in command.call_args_list) == 3
132+
133+
134+
@pytest.mark.parametrize("page", [0, 4])
135+
def test_short_response_is_a_read_failure(reader, scan, monkeypatch, page):
136+
mock_tag(reader, monkeypatch, memory(0x12), short_page=page)
137+
138+
assert reader.read_mifare_ultralight(scan) is None
139+
140+
141+
@pytest.mark.parametrize("fixture_path", [
142+
path for path in _collect_fixture_cases()
143+
if PROCESSOR_FIXTURES[path.parent.name]["tag_type"] == TagType.MifareUltralight
144+
], ids=lambda path: path.stem)
145+
def test_physical_dump_preserves_processor_output(reader, scan, monkeypatch, fixture_path):
146+
data = fixture_path.read_bytes()
147+
# Some older NTAG213 dumps include rollover bytes. Simulate only physical
148+
# memory and pass the real reader's exact-length output to each processor.
149+
physical_sizes = {0x06: 64, 0x12: 180, 0x3E: 540, 0x6D: 924}
150+
physical_size = physical_sizes[data[14]]
151+
assert len(data) >= physical_size
152+
mock_tag(reader, monkeypatch, data[:physical_size])
153+
154+
dump = reader.read_mifare_ultralight(scan)
155+
assert dump == data[:physical_size]
156+
processor = PROCESSOR_FIXTURES[fixture_path.parent.name]["build_processor"]()
157+
filament = processor.process_tag(scan, dump)
158+
159+
assert filament is not None
160+
expected = yaml.safe_load(fixture_path.with_suffix(".yml").read_text(encoding="utf-8"))
161+
_assert_expected_matches_actual(expected, filament.to_dict())

0 commit comments

Comments
 (0)