From 9e1312fc74405698d2ec26526a823bf197171386 Mon Sep 17 00:00:00 2001 From: ccatlett1984 Date: Thu, 24 Sep 2026 19:49:29 -0400 Subject: [PATCH 1/4] OpenTag3D: fall back to the bed temperature range when the target is unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # OpenTag3D: fall back to the bed temperature range when the target is unset ## Summary `bed_temp_c` is read from the tag's bed target only. A tag that populates the min/max pair but leaves the target at 0 reports a bed temperature of 0 rather than a usable value. This mirrors the handling already applied to the hotend a few lines above: ```python hotend_min_temp_c = data["min_print_temp"] or data["print_temp"] hotend_max_temp_c = data["max_print_temp"] or data["print_temp"] ``` so the bed becomes: ```python bed_temp_c=data["bed_temp"] or data["min_bed_temp"] or data["max_bed_temp"], ``` One line, plus a regression test. ## Why The spec carries `bed_temp` alongside `min_bed_temp` and `max_bed_temp`, and nothing requires a writer to fill in all three. `GenericFilament` has a single bed value, so the adapter already has to choose — and it chooses the range over the target for the hotend. Doing the same for the bed keeps one policy rather than two. Downstream this is the difference between a printer receiving a bed temperature and receiving nothing: the Snapmaker U1 integration drops non-positive temperatures from its `filament_detect` payload, so a 0 is indistinguishable from absent. ## Testing ``` $ pytest 57 passed, 1 skipped ``` New test, using the bundled Polar Filament fixture, checks target → min → max precedence and that all three unset still yields 0: ```python def test_bed_temp_falls_back_to_range(processor, scan, payload): payload[148] = 0 payload[149:151] = bytes([11, 13]) assert processor.process_tag(scan, tag(record(payload))).bed_temp_c == 55 payload[149] = 0 assert processor.process_tag(scan, tag(record(payload))).bed_temp_c == 65 payload[150] = 0 assert processor.process_tag(scan, tag(record(payload))).bed_temp_c == 0 ``` --- src/tag/opentag3d/processor.py | 2 +- test/test_opentag3d.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/tag/opentag3d/processor.py b/src/tag/opentag3d/processor.py index 57f2610..53a9ce7 100644 --- a/src/tag/opentag3d/processor.py +++ b/src/tag/opentag3d/processor.py @@ -99,7 +99,7 @@ def __to_filament(self, scan_result: ScanResult, data: dict) -> GenericFilament: weight_grams=data["weight"], hotend_min_temp_c=hotend_min_temp_c, hotend_max_temp_c=hotend_max_temp_c, - bed_temp_c=data["bed_temp"], + bed_temp_c=data["bed_temp"] or data["min_bed_temp"] or data["max_bed_temp"], drying_temp_c=data["max_dry_temp"], drying_time_hours=data["dry_time"], # Reuse the other processors' unknown-date sentinel for absent dates. diff --git a/test/test_opentag3d.py b/test/test_opentag3d.py index 95565f6..d9dabe7 100644 --- a/test/test_opentag3d.py +++ b/test/test_opentag3d.py @@ -109,6 +109,19 @@ def test_short_payload_and_target_fallback(processor, scan, payload): assert decode_payload(payload[:145], SCHEMAS[2])["mfg_time"] is None +def test_bed_temp_falls_back_to_range(processor, scan, payload): + # The spec stores a bed target alongside a min/max pair. A tag that fills in + # only the range should still yield a bed temperature, mirroring the existing + # print-temperature fallback rather than reporting 0. + payload[148] = 0 + payload[149:151] = bytes([11, 13]) + assert processor.process_tag(scan, tag(record(payload))).bed_temp_c == 55 + payload[149] = 0 + assert processor.process_tag(scan, tag(record(payload))).bed_temp_c == 65 + payload[150] = 0 + assert processor.process_tag(scan, tag(record(payload))).bed_temp_c == 0 + + def test_primary_transparent_black_and_cf_modifier(processor, scan, payload): payload[60:76] = bytes(16) payload[7:12] = b"CF\x00\x00\x00" From 38aedaca4c7780ea147f6a8d208683ba3125b7b4 Mon Sep 17 00:00:00 2001 From: ccatlett1984 Date: Thu, 24 Sep 2026 19:57:47 -0400 Subject: [PATCH 2/4] 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 From 716f9eadcfcbd43900372ba846f51e66865170cb Mon Sep 17 00:00:00 2001 From: ccatlett1984 Date: Fri, 25 Sep 2026 18:14:45 -0400 Subject: [PATCH 3/4] merge suchmememanyskill ntag216 support PR manually --- src/reader/fm175xx/constants.py | 42 ++----- src/reader/fm175xx/rfid.py | 61 +++++----- test/test_mifare_ultralight_reader.py | 161 ++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 65 deletions(-) create mode 100644 test/test_mifare_ultralight_reader.py diff --git a/src/reader/fm175xx/constants.py b/src/reader/fm175xx/constants.py index 16ff882..2b20ebc 100644 --- a/src/reader/fm175xx/constants.py +++ b/src/reader/fm175xx/constants.py @@ -114,36 +114,16 @@ 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 - -# 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] +# Ultralight / NTAG Type 2 tags +FM175XX_ULTRALIGHT_BYTES_PER_PAGE = 4 +# Default CC size byte -> physical page count, including header and configuration. +# CC sizes describe the NDEF area and are smaller than the physical NTAG memory. +FM175XX_ULTRALIGHT_PAGE_COUNTS_BY_CC_SIZE = { + 0x06: 16, # MIFARE Ultralight + 0x12: 45, # NTAG213 + 0x3E: 135, # NTAG215 + 0x6D: 231, # NTAG216 +} # About M1 Card # EEPROM @@ -165,4 +145,4 @@ FM175XX_SELF_TEST_STAGE_DOING = 1 FM175XX_SELF_TEST_STAGE_STOP = 2 -FM175XX_MIN_TIME = 0.200 \ No newline at end of file +FM175XX_MIN_TIME = 0.200 diff --git a/src/reader/fm175xx/rfid.py b/src/reader/fm175xx/rfid.py index 49133f7..1d2d362 100644 --- a/src/reader/fm175xx/rfid.py +++ b/src/reader/fm175xx/rfid.py @@ -77,7 +77,7 @@ def read_mifare_ultralight(self, scan_result: ScanResult) -> bytes | None: data = self.__reader_a_ultralight_read_all_data() if data.err_code != Constants.FM175XX_OK: - self.logger.error("Mifare Classic read error: %d", data.err_code) + self.logger.error("Mifare Ultralight read error: %d", data.err_code) return None 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 ret.out_data = card_data_tmp return ret - # Reader-A: NTAG/Ultralight, read a page (4 bytes) + # Reader-A: NTAG/Ultralight, read four consecutive pages (16 bytes) def __reader_a_ultralight_page_read(self, page:int) -> Fm175xxReturnVal: outbuf = [0] * 2 inbuf = [0] * 16 @@ -688,47 +688,40 @@ 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: 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. + # Reader-A: Ultralight/NTAG, read all physical pages for the known CC size. def __reader_a_ultralight_read_all_data(self, retry_times = 3) -> Fm175xxReturnVal: ret = Fm175xxReturnVal() - card_data_tmp = [0] * Constants.FM175XX_NTAG216_TOTAL_SIZE - pages_read = 0 + ret.err_code = Constants.FM175XX_CARD_READ_ERR + if retry_times < 1: + return ret + + card_data_tmp = [] + total_size = 16 # Pages 0-3 include the capability container (CC). - for page_no in range(0, Constants.FM175XX_NTAG216_TOTAL_PAGES, 4): - result = Fm175xxReturnVal() + while len(card_data_tmp) < total_size: + # READ always returns four pages. Overlap the last read if needed + # so it includes the final physical page without rollover bytes. + area = min(len(card_data_tmp), total_size - 16) + page_no = area // Constants.FM175XX_ULTRALIGHT_BYTES_PER_PAGE 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): - # 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_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 + return ret - total_pages = 0 - for known_pages in Constants.FM175XX_ULTRALIGHT_KNOWN_PAGE_COUNTS: - if (pages_read >= known_pages): - total_pages = known_pages + if page_no == 0: + # Use the default CC size to look up the physical capacity; + # the NDEF size alone would omit user/configuration pages. + cc = result.out_data[12:16] + page_count = Constants.FM175XX_ULTRALIGHT_PAGE_COUNTS_BY_CC_SIZE.get(cc[2]) + if cc[0] != 0xE1 or page_count is None: + self.logger.error("Invalid or unsupported Ultralight/NTAG capability container: %s", bytes(cc).hex()) + return ret + total_size = page_count * Constants.FM175XX_ULTRALIGHT_BYTES_PER_PAGE - 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 + card_data_tmp.extend(result.out_data[len(card_data_tmp) - area:]) - 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[0 : total_pages * Constants.FM175XX_NTAG215_BYTES_PER_PAGE] - return ret \ No newline at end of file + ret.out_data = card_data_tmp + return ret diff --git a/test/test_mifare_ultralight_reader.py b/test/test_mifare_ultralight_reader.py new file mode 100644 index 0000000..7adb63b --- /dev/null +++ b/test/test_mifare_ultralight_reader.py @@ -0,0 +1,161 @@ +"""Exercise Type 2 memory boundaries through the real reader's READ command.""" +import logging +from pathlib import Path +import sys +from types import ModuleType +from unittest.mock import Mock + +import pytest +import yaml + +from test_tags import ( + PROCESSOR_FIXTURES, + _assert_expected_matches_actual, + _collect_fixture_cases, +) + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from reader.scan_result import ScanResult +from tag.tag_types import TagType + + +@pytest.fixture +def reader(monkeypatch): + # The RF command is simulated; no Linux GPIO/SPI hardware is accessed. + monkeypatch.setitem(sys.modules, "gpiod", ModuleType("gpiod")) + monkeypatch.setitem(sys.modules, "spidev", ModuleType("spidev")) + from reader.fm175xx.rfid import Fm175xx + + instance = Fm175xx.__new__(Fm175xx) + instance.logger = logging.getLogger("test.ultralight") + return instance + + +@pytest.fixture +def scan(): + return ScanResult(TagType.MifareUltralight, b"\x04\x01\x02\x03\x04\x05\x06", b"\x00\x44", b"\x00", b"\x04\x00") + + +def memory(size_byte): + physical_sizes = {0x06: 64, 0x12: 180, 0x3E: 540, 0x6D: 924} + data = bytearray(i % 251 for i in range(physical_sizes[size_byte])) + data[12:16] = bytes([0xE1, 0x10, size_byte, 0x00]) + return bytes(data) + + +def mock_tag(reader, monkeypatch, data, failures=None, short_page=None): + from reader.fm175xx import constants + from reader.fm175xx.rfid import Fm175xxReturnVal + + failures = dict(failures or {}) + + def execute(cmd): + assert cmd.send_buff[0] == 0x30 + assert cmd.bytes_to_recv == 16 + page = cmd.send_buff[1] + assert 0 <= page <= 255 + offset = page * 4 + assert offset + 16 <= len(data), "READ crossed the physical memory boundary" + result = Fm175xxReturnVal() + if failures.get(page, 0): + failures[page] -= 1 + result.err_code = constants.FM175XX_CARD_TIMER_ERR + else: + result.err_code = constants.FM175XX_OK + result.out_data = list(data[offset:offset + 16]) + if page == short_page: + result.out_data.pop() + return result + + command = Mock(side_effect=execute) + monkeypatch.setattr(reader, "_Fm175xx__command_exe", command) + return command + + +@pytest.mark.parametrize("size_byte,total_size,last_page", [ + pytest.param(0x06, 64, 12, id="ultralight"), + pytest.param(0x12, 180, 41, id="ntag213"), + pytest.param(0x3E, 540, 131, id="ntag215"), + pytest.param(0x6D, 924, 227, id="ntag216"), +]) +def test_reads_all_physical_pages(reader, scan, monkeypatch, size_byte, total_size, last_page): + data = memory(size_byte) + command = mock_tag(reader, monkeypatch, data) + + result = reader.read_mifare_ultralight(scan) + + assert result == data + assert len(result) == total_size + assert command.call_args_list[0].args[0].send_buff == [0x30, 0] + assert command.call_args_list[-1].args[0].send_buff == [0x30, last_page] + assert command.call_count == (total_size + 15) // 16 + assert sum(call.args[0].send_buff[1] == 0 for call in command.call_args_list) == 1 + + +@pytest.mark.parametrize("cc", [ + bytes.fromhex("00 00 00 00"), + bytes.fromhex("00 10 12 00"), + bytes.fromhex("e1 10 00 00"), + bytes.fromhex("e1 10 01 00"), + bytes.fromhex("e1 10 7f 00"), + bytes.fromhex("e1 10 ff 00"), +]) +def test_invalid_cc_fails_before_reading_data(reader, scan, monkeypatch, cc): + data = bytearray(memory(0x12)) + data[12:16] = cc + command = mock_tag(reader, monkeypatch, data) + + assert reader.read_mifare_ultralight(scan) is None + assert command.call_count == 1 + + +@pytest.mark.parametrize("size_byte,page", [ + (0x6D, 0), (0x6D, 4), (0x6D, 48), (0x6D, 132), + (0x06, 12), (0x12, 41), (0x3E, 131), (0x6D, 227), +]) +def test_persistent_read_failure_never_returns_partial_success(reader, scan, monkeypatch, size_byte, page): + # Page 48 used to be accepted as a successful end after three failed reads. + command = mock_tag(reader, monkeypatch, memory(size_byte), failures={page: 3}) + + assert reader.read_mifare_ultralight(scan) is None + assert [call.args[0].send_buff[1] for call in command.call_args_list[-3:]] == [page] * 3 + + +@pytest.mark.parametrize("page", [0, 4, 227]) +def test_transient_failure_retries_and_returns_complete_data(reader, scan, monkeypatch, page): + data = memory(0x6D) + command = mock_tag(reader, monkeypatch, data, failures={page: 2}) + + assert reader.read_mifare_ultralight(scan) == data + assert sum(call.args[0].send_buff[1] == page for call in command.call_args_list) == 3 + + +@pytest.mark.parametrize("page", [0, 4]) +def test_short_response_is_a_read_failure(reader, scan, monkeypatch, page): + mock_tag(reader, monkeypatch, memory(0x12), short_page=page) + + assert reader.read_mifare_ultralight(scan) is None + + +@pytest.mark.parametrize("fixture_path", [ + path for path in _collect_fixture_cases() + if PROCESSOR_FIXTURES[path.parent.name]["tag_type"] == TagType.MifareUltralight +], ids=lambda path: path.stem) +def test_physical_dump_preserves_processor_output(reader, scan, monkeypatch, fixture_path): + data = fixture_path.read_bytes() + # Some older NTAG213 dumps include rollover bytes. Simulate only physical + # memory and pass the real reader's exact-length output to each processor. + physical_sizes = {0x06: 64, 0x12: 180, 0x3E: 540, 0x6D: 924} + physical_size = physical_sizes[data[14]] + assert len(data) >= physical_size + mock_tag(reader, monkeypatch, data[:physical_size]) + + dump = reader.read_mifare_ultralight(scan) + assert dump == data[:physical_size] + processor = PROCESSOR_FIXTURES[fixture_path.parent.name]["build_processor"]() + filament = processor.process_tag(scan, dump) + + assert filament is not None + expected = yaml.safe_load(fixture_path.with_suffix(".yml").read_text(encoding="utf-8")) + _assert_expected_matches_actual(expected, filament.to_dict()) From 07fff25265da800b01447c16ad42f556817fd19b Mon Sep 17 00:00:00 2001 From: ccatlett1984 Date: Fri, 25 Sep 2026 18:52:20 -0400 Subject: [PATCH 4/4] remove test, no longer needed. --- test/test_fm175xx_ultralight_read.py | 81 ---------------------------- 1 file changed, 81 deletions(-) delete mode 100644 test/test_fm175xx_ultralight_read.py diff --git a/test/test_fm175xx_ultralight_read.py b/test/test_fm175xx_ultralight_read.py deleted file mode 100644 index 8a68607..0000000 --- a/test/test_fm175xx_ultralight_read.py +++ /dev/null @@ -1,81 +0,0 @@ -"""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