From 38aedaca4c7780ea147f6a8d208683ba3125b7b4 Mon Sep 17 00:00:00 2001 From: ccatlett1984 Date: Thu, 24 Sep 2026 19:57:47 -0400 Subject: [PATCH] read NTAG213 and NTAG216 instead of assuming NTAG215 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # fm175xx: read NTAG213 and NTAG216 instead of assuming NTAG215 ## Summary `__reader_a_ultralight_read_all_data` is hard-coded to NTAG215's geometry. It loops to `FM175XX_NTAG215_TOTAL_PAGES` and allocates `FM175XX_NTAG215_TOTAL_SIZE`, so: - **NTAG216 is silently truncated** to 540 of its 924 bytes. An NDEF record living in the upper 384 bytes is invisible, with no error to say so. - **NTAG213 fails outright.** Reads past page 44 are NAKed, and the recovery branch cannot fire (see below), so the whole read returns `FM175XX_CARD_READ_ERR`. It now reads until the tag stops answering and truncates to the largest recognised page count that was fully covered — 180 bytes for an NTAG213, 540 for an NTAG215, 924 for an NTAG216. ## The recovery branch was dead code ```python if (page_no - 4) in Constants.FM175XX_ULTRALIGHT_VALID_END_PAGES: ``` `FM175XX_ULTRALIGHT_VALID_END_PAGES` is `[135, 44]`, and `page_no` comes from `range(0, 135, 4)`, so it takes values `0, 4, … 132` and `page_no - 4` ranges over `-4 … 128`. It can never equal 135 (135 + 4 is not a multiple of 4) and never reaches 44 in a failing iteration, because the loop stops at 132 before a 44-page tag's first NAK at page 48 would be attempted. So any tag that NAKs mid-read falls through to `FM175XX_CARD_READ_ERR`, and any tag larger than an NTAG215 is quietly cut short. This replaces the constant with `FM175XX_ULTRALIGHT_KNOWN_PAGE_COUNTS` and a check that can actually fire. ## Why truncate rather than trust the read length A READ returns four pages and rolls over within addressable memory, so the last successful read on any tag overruns the final page. Truncating to a known page count discards those roll-over bytes, which would otherwise be handed back as if they were tag memory. Plain Ultralight is deliberately **not** in the recognised list, so it stays rejected as it was before. An Ultralight EV1 MF0UL21 has 41 pages and, because of roll-over, answers every read a 44-page tag would; accepting 44 would return 176 bytes whose tail is roll-over rather than memory. `FM175XX_ULTRALIGHT_TOTAL_PAGES` is left in place but is no longer referenced — happy to remove it if you would rather not keep an unused constant. ## Testing ``` $ pytest 66 passed, 1 skipped ``` New `test/test_fm175xx_ultralight_read.py` drives the real function with the page-read helper replaced by a simulated tag that models both behaviours that matter — a READ returns four pages and rolls over within addressable memory, and an address past the last page is NAKed. No hardware is constructed or touched. It asserts that an NTAG213, NTAG215 and NTAG216 each come back at their exact size **and byte-identical to the simulated tag's memory** — the roll-over check, since a naive implementation returns the right length with the wrong tail. It also asserts that 0-, 4-, 16-, 20-, 41- and 44-page tags are still rejected, 41 included specifically because it is a real size that roll-over makes look like 44. ## Scope Only the Ultralight/NTAG path changes. `__reader_a_m1_read_all_data` and the Mifare Classic path are untouched, as is every caller — `read_mifare_ultralight` still receives a byte list and simply gets a correctly sized one. --- src/reader/fm175xx/constants.py | 23 +++++++- src/reader/fm175xx/rfid.py | 39 ++++++++++---- test/test_fm175xx_ultralight_read.py | 81 ++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 12 deletions(-) create mode 100644 test/test_fm175xx_ultralight_read.py 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