Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion src/reader/fm175xx/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 28 additions & 11 deletions src/reader/fm175xx/rfid.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
81 changes: 81 additions & 0 deletions test/test_fm175xx_ultralight_read.py
Original file line number Diff line number Diff line change
@@ -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