diff --git a/src/reader/fm175xx/constants.py b/src/reader/fm175xx/constants.py index bc2d3c1..16ff882 100644 --- a/src/reader/fm175xx/constants.py +++ b/src/reader/fm175xx/constants.py @@ -114,15 +114,36 @@ FM175XX_CARD_INFO_READ = 0 FM175XX_CARD_INFO_CLEAR = 1 +# About NTAG213 Card +FM175XX_NTAG213_TOTAL_PAGES = 45 + # About NTAG215 Card FM175XX_NTAG215_TOTAL_PAGES = 135 FM175XX_NTAG215_USER_START_PAGE = 4 FM175XX_NTAG215_USER_END_PAGE = 129 FM175XX_NTAG215_BYTES_PER_PAGE = 4 FM175XX_NTAG215_TOTAL_SIZE = 540 + +# About NTAG216 Card +FM175XX_NTAG216_TOTAL_PAGES = 231 +FM175XX_NTAG216_TOTAL_SIZE = 924 + FM175XX_ULTRALIGHT_TOTAL_PAGES = 44 -FM175XX_ULTRALIGHT_VALID_END_PAGES = [FM175XX_NTAG215_TOTAL_PAGES, FM175XX_ULTRALIGHT_TOTAL_PAGES] +# Page counts this reader recognises, ascending. A tag's size is not known before +# it is read, so it is read until it stops answering and the result truncated to +# the largest of these it fully covered -- which also discards the roll-over bytes +# the final READ returns. A tag answering fewer pages than the smallest entry is +# rejected as unreadable. +# +# FM175XX_ULTRALIGHT_TOTAL_PAGES is deliberately absent. A READ rolls over within +# addressable memory, so a 41-page Ultralight EV1 (MF0UL21) answers every read up +# to page 40 and is indistinguishable from a 44-page tag at this level; accepting +# 44 would hand back 176 bytes whose tail is roll-over rather than tag memory. +# Plain Ultralight stays rejected, as it was before. +FM175XX_ULTRALIGHT_KNOWN_PAGE_COUNTS = [FM175XX_NTAG213_TOTAL_PAGES, + FM175XX_NTAG215_TOTAL_PAGES, + FM175XX_NTAG216_TOTAL_PAGES] # About M1 Card # EEPROM diff --git a/src/reader/fm175xx/rfid.py b/src/reader/fm175xx/rfid.py index 1a8b968..49133f7 100644 --- a/src/reader/fm175xx/rfid.py +++ b/src/reader/fm175xx/rfid.py @@ -688,30 +688,47 @@ def __reader_a_ultralight_page_read(self, page:int) -> Fm175xxReturnVal: return ret # TODO: Maybe don't call it ultralight but the actual ISO specification - # Reader-A: NTAG215, read all data + # Reader-A: NTAG/Ultralight, read all data + # + # The tag's size is not known before it is read and there is no GET_VERSION + # path here, so pages are read until the tag stops answering and the result is + # truncated to the largest recognised page count that was fully covered: 180 + # bytes for an NTAG213, 540 for an NTAG215 and 924 for an NTAG216, which is the + # only one that runs the loop to completion. def __reader_a_ultralight_read_all_data(self, retry_times = 3) -> Fm175xxReturnVal: ret = Fm175xxReturnVal() - card_data_tmp = [0] * Constants.FM175XX_NTAG215_TOTAL_SIZE + card_data_tmp = [0] * Constants.FM175XX_NTAG216_TOTAL_SIZE + pages_read = 0 - for page_no in range(0, Constants.FM175XX_NTAG215_TOTAL_PAGES, 4): + for page_no in range(0, Constants.FM175XX_NTAG216_TOTAL_PAGES, 4): result = Fm175xxReturnVal() for _ in range(retry_times): result = self.__reader_a_ultralight_page_read(page_no) if (result.err_code == Constants.FM175XX_OK): break if (result.err_code != Constants.FM175XX_OK): - if (page_no - 4) in Constants.FM175XX_ULTRALIGHT_VALID_END_PAGES: - card_data_tmp = card_data_tmp[0 : (page_no * Constants.FM175XX_NTAG215_BYTES_PER_PAGE)] - break - - ret.err_code = Constants.FM175XX_CARD_READ_ERR - return ret + # End of tag memory, or a tag smaller than the loop bound. A READ + # rolls over within addressable memory, so the last successful read + # may overrun the final page; the truncation below discards that. + break area = page_no * Constants.FM175XX_NTAG215_BYTES_PER_PAGE - bytes_to_copy = min(16, Constants.FM175XX_NTAG215_TOTAL_SIZE - area) + bytes_to_copy = min(16, Constants.FM175XX_NTAG216_TOTAL_SIZE - area) if bytes_to_copy > 0: card_data_tmp[area : area + bytes_to_copy] = result.out_data[0 : bytes_to_copy] + pages_read = page_no + 4 + total_pages = 0 + for known_pages in Constants.FM175XX_ULTRALIGHT_KNOWN_PAGE_COUNTS: + if (pages_read >= known_pages): + total_pages = known_pages + + if (total_pages == 0): + self.logger.warning("Ultralight read stopped after %d pages, too small for any known tag", pages_read) + ret.err_code = Constants.FM175XX_CARD_READ_ERR + return ret + + self.logger.debug("Ultralight read reached page %d, keeping %d pages", pages_read, total_pages) ret.err_code = Constants.FM175XX_OK - ret.out_data = card_data_tmp + ret.out_data = card_data_tmp[0 : total_pages * Constants.FM175XX_NTAG215_BYTES_PER_PAGE] return ret \ No newline at end of file diff --git a/test/test_fm175xx_ultralight_read.py b/test/test_fm175xx_ultralight_read.py new file mode 100644 index 0000000..8a68607 --- /dev/null +++ b/test/test_fm175xx_ultralight_read.py @@ -0,0 +1,81 @@ +"""Variable-length NTAG/Ultralight reads, simulated without hardware.""" +import logging +from pathlib import Path +import sys +from types import ModuleType + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +# The reader module imports Linux-only GPIO/SPI bindings at import time. No test +# here constructs or touches physical hardware. +sys.modules.setdefault("gpiod", ModuleType("gpiod")) +sys.modules.setdefault("spidev", ModuleType("spidev")) + +from reader.fm175xx import constants as Constants +from reader.fm175xx.rfid import Fm175xx + +BYTES_PER_PAGE = Constants.FM175XX_NTAG215_BYTES_PER_PAGE + + +def reader(total_pages): + """A reader whose page READ behaves like a tag of total_pages pages. + + A READ returns four pages and rolls over within addressable memory, so a read + starting on the last page still succeeds and overruns. An address past the + last page is NAKed. + """ + memory = bytes(range(256)) * (total_pages * BYTES_PER_PAGE // 256 + 1) + memory = memory[: total_pages * BYTES_PER_PAGE] + + def page_read(page, _pages=total_pages): + ret = type("R", (), {"err_code": Constants.FM175XX_OK, "out_data": None})() + if page >= _pages: + ret.err_code = Constants.FM175XX_CARD_READ_ERR + ret.out_data = None + return ret + data = bytearray() + for offset in range(4): + start = ((page + offset) % _pages) * BYTES_PER_PAGE + data += memory[start : start + BYTES_PER_PAGE] + ret.out_data = list(data) + return ret + + instance = Fm175xx.__new__(Fm175xx) + instance.logger = logging.getLogger("test_fm175xx") + instance._Fm175xx__reader_a_ultralight_page_read = page_read + return instance, memory + + +def read_all(total_pages): + instance, memory = reader(total_pages) + return instance._Fm175xx__reader_a_ultralight_read_all_data(), memory + + +@pytest.mark.parametrize("total_pages,expected_bytes", [ + (Constants.FM175XX_NTAG213_TOTAL_PAGES, 180), + (Constants.FM175XX_NTAG215_TOTAL_PAGES, 540), + (Constants.FM175XX_NTAG216_TOTAL_PAGES, 924), +]) +def test_recognised_tags_read_in_full(total_pages, expected_bytes): + ret, memory = read_all(total_pages) + assert ret.err_code == Constants.FM175XX_OK + # The whole tag, and no roll-over bytes from the final overrunning read. + assert len(ret.out_data) == expected_bytes + assert bytes(ret.out_data) == memory[:expected_bytes] + + +@pytest.mark.parametrize("total_pages", [0, 4, 16, 20, 41, 44]) +def test_tags_below_the_smallest_known_size_are_rejected(total_pages): + # 41 is a real size (Ultralight EV1 MF0UL21). Roll-over makes it answer every + # read a 44-page tag would, so it must not be mistaken for one. + ret, _ = read_all(total_pages) + assert ret.err_code == Constants.FM175XX_CARD_READ_ERR + + +def test_ntag216_is_not_truncated_to_ntag215(): + # The previous loop bound stopped at 135 pages and returned 540 bytes for an + # NTAG216, silently discarding 384 bytes an NDEF record could occupy. + ret, _ = read_all(Constants.FM175XX_NTAG216_TOTAL_PAGES) + assert len(ret.out_data) > Constants.FM175XX_NTAG215_TOTAL_SIZE