From 227312c72564454b4106b257b5593e181dfa28c3 Mon Sep 17 00:00:00 2001 From: Gordian Zomer Date: Mon, 11 May 2026 13:36:01 +0200 Subject: [PATCH 1/8] Add NTAG write support and message handling - Implemented `write_ntag_pages` method in `GpioEnabledRfidReader` to handle NTAG21x page writes. - Enhanced `Runtime` class to manage pending NTAG write requests with a dedicated queue and threading support. - Introduced `_PendingWrite` class to encapsulate write request state. - Updated `TigerTagProcessor` to parse and log custom messages from the NTAG payload. - Created `encoder` module for TigerTag to encode specifications into a 96-byte payload, including message handling. - Added tests for pending write functionality and encoder round-trip validation. - Updated existing TigerTag YAML configurations to include bed temperature and message fields. --- .gitignore | 2 +- examples/u1.cfg | 15 + src/controllers/openrfid_api.py | 243 +++++++++++++++ src/exporters/openrfid_agent_event.py | 87 ++++++ src/filament/generic.py | 10 +- src/main.py | 8 +- src/reader/fm175xx/rfid.py | 64 ++++ src/reader/gpio_enabled_rfid_reader.py | 18 +- src/runtime.py | 107 ++++++- src/tag/tigertag/constants.py | 8 + src/tag/tigertag/encoder.py | 281 ++++++++++++++++++ src/tag/tigertag/processor.py | 18 +- test/tags/Tigertag/Elegoo PLA Basic Grey.yml | 8 +- ...D PETG HS Carbon Fiber Black TigerTag+.yml | 5 +- ... PLA Starter Lithophane Cyan TigerTag+.yml | 4 +- ...A Starter Lithophane Magenta TigerTag+.yml | 4 +- ...PLA Starter Lithophane White TigerTag+.yml | 4 +- ...LA Starter Lithophane Yellow TigerTag+.yml | 4 +- .../Sunlu PLA Roasted Chestnut TigerTag+.yml | 4 +- ... PLA Basic Refill Cold White TigerTag+.yml | 4 +- test/test_runtime_pending_write.py | 160 ++++++++++ test/test_tigertag_encoder.py | 124 ++++++++ 22 files changed, 1164 insertions(+), 18 deletions(-) create mode 100644 src/controllers/openrfid_api.py create mode 100644 src/exporters/openrfid_agent_event.py create mode 100644 src/tag/tigertag/encoder.py create mode 100644 test/test_runtime_pending_write.py create mode 100644 test/test_tigertag_encoder.py diff --git a/.gitignore b/.gitignore index ba0430d..ed8ebf5 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -__pycache__/ \ No newline at end of file +__pycache__ \ No newline at end of file diff --git a/examples/u1.cfg b/examples/u1.cfg index dcd819d..b9d5985 100644 --- a/examples/u1.cfg +++ b/examples/u1.cfg @@ -120,6 +120,21 @@ track_field = state get_index_from_field = array act_on_value = 1 +# Optional: expose OpenRFID over Moonraker as a JSON-RPC agent so a Mainsail/ +# Fluidd-style web UI can list channels, trigger scans, and (optionally) write +# tags. Pair with [openrfid_agent_event_exporter] below to also receive +# notify_agent_event broadcasts on every scan. +#[openrfid_api] +#moonraker_socket_path = /oem/printer_data/comms/moonraker.sock +## Set enable_write = true to allow openrfid/write_tag and openrfid/clear_tag. +## Leave false (the default) for read-only deployments. +#enable_write = false + +#[openrfid_agent_event_exporter] +## Defaults to all three events (tag_read, tag_parse_error, tag_not_present). +#events = tag_read,tag_parse_error,tag_not_present +#max_queued = 64 + [configuration] auto_read_mode = false retries = 20 diff --git a/src/controllers/openrfid_api.py b/src/controllers/openrfid_api.py new file mode 100644 index 0000000..47a2583 --- /dev/null +++ b/src/controllers/openrfid_api.py @@ -0,0 +1,243 @@ +"""Moonraker agent controller exposing OpenRFID over JSON-RPC. + +Registers a Moonraker *agent* named ``openrfid`` that: + +* exposes the remote methods ``openrfid/list_channels``, ``openrfid/scan_slot``, + ``openrfid/write_tag``, ``openrfid/clear_tag`` and ``openrfid/tigertag_encode``, +* broadcasts ``notify_agent_event`` notifications (event name + ``openrfid/scan``) for every scan event picked up from a configured + :class:`exporters.openrfid_agent_event.OpenrfidAgentEventExporter`. + +Write operations (``write_tag`` / ``clear_tag``) are gated behind the +``enable_write`` config flag (default ``false``) so installations that don't +want write access stay safe by default. +""" + +from __future__ import annotations + +import threading +import time +from typing import Any, Callable + +from config import get_entities_by_type, TYPE_EXPORTER +from controllers.moonraker_controller import MoonrakerController +from exporters.openrfid_agent_event import OpenrfidAgentEventExporter +from runtime import Runtime +from tag.tigertag import encoder as tigertag_encoder + + +AGENT_NAME = "openrfid" + +_MSG_ID_REGISTER_AGENT = 0x6F1A0001 +_MSG_ID_REGISTER_METHOD_BASE = 0x6F1A0010 + +REMOTE_METHOD_LIST_CHANNELS = "openrfid/list_channels" +REMOTE_METHOD_SCAN_SLOT = "openrfid/scan_slot" +REMOTE_METHOD_WRITE_TAG = "openrfid/write_tag" +REMOTE_METHOD_CLEAR_TAG = "openrfid/clear_tag" +REMOTE_METHOD_TIGERTAG_ENCODE = "openrfid/tigertag_encode" + + +class OpenrfidApiController(MoonrakerController): + """Moonraker agent that surfaces OpenRFID to Mainsail/Fluidd-style UIs.""" + + def __init__(self, config: dict): + super().__init__(config) + self.runtime: Runtime + self.enable_write = str(config.get("enable_write", "false")).lower() == "true" + self.agent_event_exporter_name = config.get("agent_event_exporter", None) + + self._exporter: OpenrfidAgentEventExporter | None = None + self._broadcast_thread: threading.Thread | None = None + self._stop_broadcast = threading.Event() + + self._handlers: dict[str, Callable[[dict], Any]] = { + REMOTE_METHOD_LIST_CHANNELS: self._handle_list_channels, + REMOTE_METHOD_SCAN_SLOT: self._handle_scan_slot, + REMOTE_METHOD_WRITE_TAG: self._handle_write_tag, + REMOTE_METHOD_CLEAR_TAG: self._handle_clear_tag, + REMOTE_METHOD_TIGERTAG_ENCODE: self._handle_tigertag_encode, + } + + # ------------------------------------------------------------------ + # Wiring helpers + # ------------------------------------------------------------------ + def _resolve_exporter(self) -> OpenrfidAgentEventExporter | None: + if self._exporter is not None: + return self._exporter + + exporters = [e for e in get_entities_by_type(TYPE_EXPORTER) if isinstance(e, OpenrfidAgentEventExporter)] + if not exporters: + return None + + if self.agent_event_exporter_name: + for e in exporters: + if e.name == self.agent_event_exporter_name: + self._exporter = e + return e + self.logger.warning("Configured agent_event_exporter '%s' not found", self.agent_event_exporter_name) + return None + + if len(exporters) > 1: + self.logger.warning( + "Multiple openrfid_agent_event_exporter entities found; using '%s'", + exporters[0].name, + ) + self._exporter = exporters[0] + return self._exporter + + # ------------------------------------------------------------------ + # MoonrakerController hooks + # ------------------------------------------------------------------ + def on_connect(self): + self._send_register_agent() + time.sleep(0.05) + for offset, method in enumerate(self._handlers): + self._send_register_remote_method(method, _MSG_ID_REGISTER_METHOD_BASE + offset) + time.sleep(0.02) + + # Start (or restart) the broadcast pump. + self._stop_broadcast.set() + if self._broadcast_thread and self._broadcast_thread.is_alive(): + self._broadcast_thread.join(timeout=1.0) + self._stop_broadcast = threading.Event() + self._broadcast_thread = threading.Thread( + target=self._broadcast_loop, name="openrfid-agent-broadcast", daemon=True + ) + self._broadcast_thread.start() + + def on_message(self, message: dict): + method = message.get("method") + if not method or method not in self._handlers: + return + + msg_id = message.get("id") + params = message.get("params") or {} + if isinstance(params, list): + params = params[0] if params and isinstance(params[0], dict) else {} + + try: + result = self._handlers[method](params) + response: dict[str, Any] = {"jsonrpc": "2.0", "result": result} + except Exception as exc: # pragma: no cover - defensive + self.logger.exception("Handler for %s failed", method) + response = {"jsonrpc": "2.0", "error": {"code": -32000, "message": str(exc)}} + + if msg_id is not None: + response["id"] = msg_id + try: + self.send_message(response) + except Exception: + self.logger.exception("Failed to send response for %s", method) + + # ------------------------------------------------------------------ + # Outgoing messages + # ------------------------------------------------------------------ + def _send_register_agent(self): + self.send_message({ + "jsonrpc": "2.0", + "method": "server.connection.identify", + "params": { + "client_name": AGENT_NAME, + "version": "0.1.0", + "type": "agent", + "url": "https://github.com/macdylan/SnapmakerU1-Extended-Firmware", + }, + "id": _MSG_ID_REGISTER_AGENT, + }) + + def _send_register_remote_method(self, method_name: str, msg_id: int): + self.send_message({ + "jsonrpc": "2.0", + "method": "connection.register_remote_method", + "params": {"method_name": method_name}, + "id": msg_id, + }) + + def _broadcast_loop(self): + exporter = self._resolve_exporter() + if exporter is None: + self.logger.info("No openrfid_agent_event_exporter configured; agent events disabled") + return + + while not self._stop_broadcast.is_set(): + event = exporter.pop_event(timeout=0.5) + if event is None: + continue + try: + self.send_message({ + "jsonrpc": "2.0", + "method": "notify_agent_event", + "params": { + "agent": AGENT_NAME, + "event": "openrfid/scan", + "data": event, + }, + }) + except Exception: + self.logger.exception("Failed to broadcast agent event; reconnect imminent") + return + + # ------------------------------------------------------------------ + # Handlers + # ------------------------------------------------------------------ + def _handle_list_channels(self, _params: dict) -> dict: + channels = [] + for i, reader in enumerate(self.runtime.rfid_readers): + channels.append({ + "slot": getattr(reader, "slot", i), + "name": reader.name, + "last_scan": self.runtime.last_scans.get(getattr(reader, "slot", i)), + }) + return {"channels": channels, "write_enabled": self.enable_write} + + def _handle_scan_slot(self, params: dict) -> dict: + slot = self._require_slot(params) + self.runtime.start_reading_tag(slot) + return {"ok": True, "slot": slot} + + def _handle_write_tag(self, params: dict) -> dict: + if not self.enable_write: + return {"ok": False, "error": "writes disabled (set enable_write = true in [openrfid_api])"} + slot = self._require_slot(params) + data_hex = params.get("data_hex") or params.get("data") + if not isinstance(data_hex, str): + return {"ok": False, "error": "data_hex (string) required"} + try: + data = bytes.fromhex(data_hex) + except ValueError as exc: + return {"ok": False, "error": f"invalid hex: {exc}"} + start_page = int(params.get("start_page", 4)) + timeout = float(params.get("timeout", 10.0)) + return self.runtime.submit_write(slot, data, start_page=start_page, timeout=timeout) + + def _handle_clear_tag(self, params: dict) -> dict: + if not self.enable_write: + return {"ok": False, "error": "writes disabled (set enable_write = true in [openrfid_api])"} + slot = self._require_slot(params) + pages = int(params.get("pages", tigertag_encoder.PAYLOAD_LENGTH_BYTES // 4)) + start_page = int(params.get("start_page", tigertag_encoder.USER_DATA_START_PAGE)) + data = b"\x00" * (pages * 4) + return self.runtime.submit_write(slot, data, start_page=start_page) + + def _handle_tigertag_encode(self, params: dict) -> dict: + spec = params.get("spec") or {} + if not isinstance(spec, dict): + return {"ok": False, "error": "spec must be an object"} + try: + payload = tigertag_encoder.encode(spec) + except Exception as exc: + return {"ok": False, "error": str(exc)} + return { + "ok": True, + "data_hex": payload.hex(), + "start_page": tigertag_encoder.USER_DATA_START_PAGE, + "bytes": len(payload), + } + + @staticmethod + def _require_slot(params: dict) -> int: + slot = params.get("slot") + if slot is None: + raise ValueError("slot is required") + return int(slot) diff --git a/src/exporters/openrfid_agent_event.py b/src/exporters/openrfid_agent_event.py new file mode 100644 index 0000000..73ba6f0 --- /dev/null +++ b/src/exporters/openrfid_agent_event.py @@ -0,0 +1,87 @@ +"""Exporter that buffers scan events for the OpenRFID Moonraker agent API. + +This exporter is intentionally decoupled from any controller: it simply pushes +serialised event dictionaries onto a thread-safe queue. The matching +:class:`controllers.openrfid_api.OpenrfidApiController` (when configured) +drains the queue and broadcasts the events as Moonraker ``notify_agent_event`` +JSON-RPC notifications. + +If no controller is wired up, events are still queued (bounded) so the +exporter never raises and stays cheap. +""" + +from __future__ import annotations + +import queue +import time +from typing import Any + +from exporters.exporter import Exporter, ExporterEvent +from filament.generic import GenericFilament +from reader.rfid_reader import RfidReader +from reader.scan_result import ScanResult + + +_DEFAULT_MAX_QUEUED = 64 + + +class OpenrfidAgentEventExporter(Exporter): + """Push every configured scan event onto a bounded in-memory queue.""" + + def __init__(self, config: dict): + # Default to all three events when none specified — the agent API + # consumer normally wants the full stream. + if "events" not in config and "event" not in config: + config = {**config, "events": "tag_read,tag_parse_error,tag_not_present"} + + super().__init__(config) + + self.max_queued = int(config.get("max_queued", _DEFAULT_MAX_QUEUED)) + self._queue: "queue.Queue[dict]" = queue.Queue(maxsize=self.max_queued) + + def export_data(self, scan: ScanResult | None, filament: GenericFilament | None, reader: RfidReader) -> None: + # Determine which event this call corresponds to. Exporter.has_event + # has already filtered by the configured event set, so we infer here. + if filament is not None: + event = ExporterEvent.TAG_READ + elif scan is not None: + event = ExporterEvent.TAG_PARSE_ERROR + else: + event = ExporterEvent.TAG_NOT_PRESENT + + payload: dict[str, Any] = { + "event": event.value, + "ts": time.time(), + "slot": getattr(reader, "slot", None), + "reader": getattr(reader, "name", None), + "uid": scan.uid.hex().upper() if scan is not None else None, + "tag_type": scan.tag_type.name if scan is not None else None, + "filament": filament.to_dict() if filament is not None else None, + } + + # Drop oldest if full so the producer never blocks. + if self._queue.full(): + try: + self._queue.get_nowait() + except queue.Empty: + pass + + try: + self._queue.put_nowait(payload) + except queue.Full: + # Last-ditch: silently drop. We never block the read loop. + self.logger.debug("Agent event queue full, dropping event") + + def pop_event(self, timeout: float | None = 0.5) -> dict | None: + try: + return self._queue.get(timeout=timeout) + except queue.Empty: + return None + + def drain(self) -> list[dict]: + events: list[dict] = [] + while True: + try: + events.append(self._queue.get_nowait()) + except queue.Empty: + return events diff --git a/src/filament/generic.py b/src/filament/generic.py index 81a71bc..2a17b35 100644 --- a/src/filament/generic.py +++ b/src/filament/generic.py @@ -26,7 +26,9 @@ def __init__(self, drying_temp_c: float, drying_time_hours: float, manufacturing_date: str, # ISO 8601 date string - td: float = 0.0 # Transmission Distance in mm for HueForge/OrcaSlicer-FullSpectrum + td: float = 0.0, # Transmission Distance in mm for HueForge/OrcaSlicer-FullSpectrum + bed_temp_max_c: float = 0.0, + message: str = "", # Custom message (e.g. from TigerTag to support a name for the filament or a short description) ): self.source_processor = source_processor self.unique_id = unique_id @@ -43,6 +45,8 @@ def __init__(self, self.drying_time_hours = drying_time_hours self.manufacturing_date = manufacturing_date self.td = td + self.bed_temp_max_c = bed_temp_max_c + self.message = message if "CF" in self.modifiers: self.type += "-CF" @@ -99,10 +103,12 @@ def to_dict(self) -> dict: "hotend_min_temp_c": self.hotend_min_temp_c, "hotend_max_temp_c": self.hotend_max_temp_c, "bed_temp_c": self.bed_temp_c, + "bed_temp_max_c": self.bed_temp_max_c, "drying_temp_c": self.drying_temp_c, "drying_time_hours": self.drying_time_hours, "manufacturing_date": self.manufacturing_date, - "td": self.td + "td": self.td, + "message": self.message, } @staticmethod diff --git a/src/main.py b/src/main.py index 3553733..7f37965 100644 --- a/src/main.py +++ b/src/main.py @@ -4,7 +4,10 @@ from config import register_configurable_entity, get_required_configurable_entity_by_name, TYPE_RUNTIME, TYPE_EXPORTER, TYPE_TAG_PROCESSOR, TYPE_RFID_READER, ConfigurableEntity from config.configuration import Configuration from controllers.moonraker_remote_method import MoonrakerRemoteMethodController +from controllers.moonraker_on_property_change import MoonrakerOnPropertyChangeController +from controllers.openrfid_api import OpenrfidApiController from exporters.webhook import WebhookExporter +from exporters.openrfid_agent_event import OpenrfidAgentEventExporter from reader.fm175xx.rfid import Fm175xx from reader.gpio_enabled_rfid_reader import GpioEnabledRfidReader import time @@ -25,7 +28,6 @@ from tag.qidi.processor import QidiTagProcessor from tag.snapmaker import SnapmakerTagProcessor from tag.tigertag import TigerTagProcessor -from controllers.moonraker_on_property_change import MoonrakerOnPropertyChangeController def consume_config(config: dict) -> Runtime: for key, value in config.items(): @@ -68,10 +70,14 @@ def create_configurable_entity(key: str, config: dict) -> ConfigurableEntity: return QidiTagProcessor(config) case "webhook_exporter": return WebhookExporter(config) + case "openrfid_agent_event_exporter": + return OpenrfidAgentEventExporter(config) case "moonraker_remote_method": return MoonrakerRemoteMethodController(config) case "moonraker_on_property_change": return MoonrakerOnPropertyChangeController(config) + case "openrfid_api": + return OpenrfidApiController(config) case _: raise ValueError(f"Unknown configurable entity type: {key_split[0]}") diff --git a/src/reader/fm175xx/rfid.py b/src/reader/fm175xx/rfid.py index c860a26..140d270 100644 --- a/src/reader/fm175xx/rfid.py +++ b/src/reader/fm175xx/rfid.py @@ -80,6 +80,70 @@ def read_mifare_ultralight(self, scan_result: ScanResult) -> bytes | None: return bytes(data.out_data) + def write_ntag_pages(self, start_page: int, data: bytes) -> int: + """Write ``data`` to NTAG21x user pages starting at ``start_page``. + + ``data`` must be a multiple of 4 bytes (one NTAG page). The carrier + wave must be enabled (i.e. the caller wraps this in + :meth:`start_session` / :meth:`end_session`). Returns one of the + ``FM175XX_*`` constants from :mod:`reader.fm175xx.constants`. + """ + if not isinstance(data, (bytes, bytearray)): + return Constants.FM175XX_PARAM_ERR + if len(data) == 0 or len(data) % Constants.FM175XX_NTAG215_BYTES_PER_PAGE != 0: + return Constants.FM175XX_PARAM_ERR + + total_pages = len(data) // Constants.FM175XX_NTAG215_BYTES_PER_PAGE + end_page = start_page + total_pages - 1 + if start_page < Constants.FM175XX_NTAG215_USER_START_PAGE or end_page > Constants.FM175XX_NTAG215_USER_END_PAGE: + self.logger.error( + "NTAG write out of user-data range: pages %d..%d (allowed %d..%d)", + start_page, + end_page, + Constants.FM175XX_NTAG215_USER_START_PAGE, + Constants.FM175XX_NTAG215_USER_END_PAGE, + ) + return Constants.FM175XX_PARAM_ERR + + ret, _UID, _ATQA, _BCC, _SAK = self.__reader_a_activate() + if ret != Constants.FM175XX_OK: + self.logger.error("NTAG write activate failed: %d", ret) + return ret + + for i in range(total_pages): + page_no = start_page + i + chunk = list(data[i * 4:(i + 1) * 4]) + wret = self.__ntag_page_write(page_no, chunk) + if wret != Constants.FM175XX_OK: + self.logger.error("NTAG write failed at page %d: %d", page_no, wret) + return wret + + return Constants.FM175XX_OK + + # Reader-A: NTAG/Ultralight, write a page (4 bytes) + def __ntag_page_write(self, page: int, data: list[int]) -> int: + cmd = Fm175xxCmdMetaData() + cmd.send_crc_en = Constants.FM175XX_SET + cmd.recv_crc_en = Constants.FM175XX_RESET + cmd.send_buff = [0xA2, page & 0xFF, data[0] & 0xFF, data[1] & 0xFF, data[2] & 0xFF, data[3] & 0xFF] + cmd.recv_buff = [0] + cmd.bytes_to_send = 6 + cmd.bits_to_send = 0 + cmd.bits_to_recv = 0 + cmd.bytes_to_recv = 1 + cmd.timeout = 10 + cmd.cmd = Constants.FM175XX_CMD_TRANSCEIVE + + result = self.__command_exe(cmd) + if result.err_code != Constants.FM175XX_OK: + return result.err_code + # NTAG ACK: 4 bits, low nibble == 0x0A + if cmd.bits_recved != 4: + return Constants.FM175XX_CARD_COMM_ERR + if (cmd.recv_buff[0] & 0x0F) != 0x0A: + return Constants.FM175XX_CARD_COMM_ERR + return Constants.FM175XX_OK + # read register def __register_read(self, addr:int) -> int: addr = (addr << 1) | 0x80 diff --git a/src/reader/gpio_enabled_rfid_reader.py b/src/reader/gpio_enabled_rfid_reader.py index a43d4d5..ea50063 100644 --- a/src/reader/gpio_enabled_rfid_reader.py +++ b/src/reader/gpio_enabled_rfid_reader.py @@ -49,4 +49,20 @@ def read_mifare_ultralight(self, scan_result : ScanResult) -> bytes|None: if isinstance(self.rfid_reader, MifareUltralightReader): return self.rfid_reader.read_mifare_ultralight(scan_result) - return None \ No newline at end of file + return None + + def write_ntag_pages(self, start_page: int, data: bytes) -> int | None: + """Write NTAG21x pages via the inner reader, framed by start/end + session so the slot's GPIO pins and the carrier wave are correctly + toggled. Returns the FM175XX status code from the inner reader, or + ``None`` if the inner reader does not support NTAG writes. + """ + write = getattr(self.rfid_reader, "write_ntag_pages", None) + if not callable(write): + return None + + self.start_session() + try: + return write(start_page, data) + finally: + self.end_session() \ No newline at end of file diff --git a/src/runtime.py b/src/runtime.py index 1becce5..698cfe5 100644 --- a/src/runtime.py +++ b/src/runtime.py @@ -11,10 +11,24 @@ from exporters.exporter import Exporter, ExporterEvent from reader.scan_result import ScanResult from filament import GenericFilament -from typing import cast +from typing import cast, Any +import threading import time import logging + +class _PendingWrite: + """State for a write request awaiting drain by :meth:`Runtime.loop`.""" + + __slots__ = ("start_page", "data", "event", "result") + + def __init__(self, start_page: int, data: bytes): + self.start_page = start_page + self.data = data + self.event = threading.Event() + self.result: dict[str, Any] | None = None + + class Runtime: def __init__(self): configs = cast(list[Configuration], get_entities_by_type(TYPE_CONFIGURATION)) @@ -42,11 +56,96 @@ def __init__(self): self.read_retries_left = [0] * len(self.rfid_readers) + # Pending NTAG write requests keyed by slot index. Populated via + # :meth:`submit_write` and drained inside :meth:`loop` so the write + # happens on the read thread (single-threaded RFID bus access). + self._pending_writes: dict[int, _PendingWrite] = {} + self._pending_write_lock = threading.Lock() + + # Most recent scan event per slot. Populated whenever exporters are + # notified so the Moonraker agent API can answer ``list_channels`` + # without re-scanning. + self.last_scans: dict[int, dict] = {} + def _notify_exporters(self, scan: ScanResult|None, filament: GenericFilament|None, reader: RfidReader, event: ExporterEvent): + slot = getattr(reader, "slot", None) + if slot is not None: + self.last_scans[int(slot)] = { + "event": event.value, + "ts": time.time(), + "slot": int(slot), + "reader": getattr(reader, "name", None), + "uid": scan.uid.hex().upper() if scan is not None else None, + "tag_type": scan.tag_type.name if scan is not None else None, + "filament": filament.to_dict() if filament is not None else None, + } + for exporter in self.exporters: if exporter.has_event(event): exporter.export_data(scan, filament, reader) + # ------------------------------------------------------------------ + # Pending NTAG writes (used by the OpenRFID agent API). + # ------------------------------------------------------------------ + def submit_write(self, slot: int, data: bytes, start_page: int = 4, timeout: float = 10.0) -> dict: + """Queue an NTAG write for ``slot`` and block until the read loop + services it (or ``timeout`` elapses). + + Returns a result dict with at least ``ok`` (bool) and ``status`` + (int|None — FM175XX status code) and optional ``error`` (str). + """ + if slot < 0 or slot >= len(self.rfid_readers): + return {"ok": False, "error": f"invalid slot {slot}"} + if not isinstance(data, (bytes, bytearray)): + return {"ok": False, "error": "data must be bytes"} + if len(data) == 0 or len(data) % 4 != 0: + return {"ok": False, "error": "data length must be a positive multiple of 4 bytes"} + + pending = _PendingWrite(start_page, bytes(data)) + with self._pending_write_lock: + self._pending_writes[slot] = pending + + # Use the existing scan-trigger path so auto/manual modes both wake. + self.start_reading_tag(slot) + + if not pending.event.wait(timeout=timeout): + with self._pending_write_lock: + self._pending_writes.pop(slot, None) + return {"ok": False, "error": "timeout waiting for write"} + + return pending.result or {"ok": False, "error": "no result"} + + def _drain_pending_write(self, slot: int, reader: RfidReader) -> bool: + """If a write is pending for ``slot``, perform it now and signal the + waiting submitter. Returns ``True`` when a write was handled (and the + normal read for this iteration should be skipped). + """ + with self._pending_write_lock: + pending = self._pending_writes.pop(slot, None) + if pending is None: + return False + + write = getattr(reader, "write_ntag_pages", None) + if not callable(write): + pending.result = {"ok": False, "error": "reader does not support NTAG writes"} + pending.event.set() + return True + + try: + status = write(pending.start_page, pending.data) + pending.result = { + "ok": status == 0, + "status": status, + "start_page": pending.start_page, + "bytes_written": len(pending.data), + } + except Exception as exc: + logging.exception("NTAG write failed on slot %d", slot) + pending.result = {"ok": False, "error": str(exc)} + finally: + pending.event.set() + return True + def start_reading_tag(self, slot: int): if slot < 0 or slot >= len(self.rfid_readers): logging.error(f"Invalid slot number: {slot}") @@ -58,6 +157,12 @@ def start_reading_tag(self, slot: int): def loop(self): while True: for i, reader in enumerate(self.rfid_readers): + # Pending writes take priority over reads — they have an + # impatient caller blocked on them. Skip the read this tick. + if self._drain_pending_write(i, reader): + self.read_retries_left[i] = 0 + continue + if not self.config.auto_read_mode and self.read_retries_left[i] <= 0: continue diff --git a/src/tag/tigertag/constants.py b/src/tag/tigertag/constants.py index 75cdd52..d2b7e13 100644 --- a/src/tag/tigertag/constants.py +++ b/src/tag/tigertag/constants.py @@ -22,6 +22,14 @@ OFF_TIMESTAMP = 32 OFF_TD = 44 OFF_METADATA = 48 +# The 32-byte metadata region holds an optional emoji glyph followed by a +# UTF-8 custom message. The emoji encoding is not well-defined across writers +# (and most writers omit it), so the parser exposes the message starting at +# OFF_METADATA and lets the application strip any decorative prefix. The +# spec caps the message at 28 bytes; the trailing 4 bytes of the metadata +# region are reserved. +OFF_MESSAGE = OFF_METADATA +MESSAGE_LENGTH = 28 OFF_SIGNATURE = 80 MIN_DATA_LENGTH = 96 diff --git a/src/tag/tigertag/encoder.py b/src/tag/tigertag/encoder.py new file mode 100644 index 0000000..c8b2b8d --- /dev/null +++ b/src/tag/tigertag/encoder.py @@ -0,0 +1,281 @@ +"""TigerTag 96-byte payload encoder. + +The encoder mirrors the byte layout consumed by :mod:`tag.tigertag.processor`. +Output is the 96-byte block written starting at NTAG215 user page 4 +(``Constants.USER_DATA_PAGE_OFFSET``), suitable for handing to +:meth:`reader.fm175xx.rfid.Fm175xx.write_ntag_pages`. + +The encoder loads label-to-id lookup tables from the bundled TigerTag JSON +database under ``tag/tigertag/database/`` (same directory used by +:mod:`tag.tigertag.registry`) so callers can pass either numeric ids or +human-readable labels for the registry-backed fields. +""" + +from __future__ import annotations + +import json +import threading +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from . import constants as Constants + + +_DB_DIRECTORY = Path(__file__).resolve().parent / "database" + +# label.lower() -> id, per registry kind +_LABEL_INDEX_CACHE: dict[str, dict[str, int]] | None = None +_LABEL_INDEX_LOCK = threading.Lock() + +_DB_FILES = { + "material": "id_material.json", + "brand": "id_brand.json", + "aspect": "id_aspect.json", + "diameter": "id_diameter.json", + "unit": "id_measure_unit.json", + "type": "id_type.json", +} + +# Pages 4..N inclusive: TigerTag user payload starts at user-data page 4. +USER_DATA_START_PAGE = Constants.USER_DATA_PAGE_OFFSET +PAYLOAD_LENGTH_BYTES = Constants.MIN_DATA_LENGTH + +# Tag IDs accepted by the parser; the encoder always emits the unsigned +# variant. Both values are accepted on read. +TIGERTAG_TAG_ID = 0xBC0FCB97 + + +def _load_db_file(name: str) -> list[dict]: + path = _DB_DIRECTORY / name + try: + with path.open("r", encoding="utf-8") as handle: + payload = json.load(handle) + except (OSError, json.JSONDecodeError): + return [] + if isinstance(payload, list): + return [item for item in payload if isinstance(item, dict)] + if isinstance(payload, dict): + for key in ("data", "items", "results", "response", "content"): + value = payload.get(key) + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + return [] + + +def _build_label_index(records: list[dict]) -> dict[str, int]: + out: dict[str, int] = {} + for record in records: + rid = record.get("id") + if rid is None: + continue + try: + rid_int = int(rid) + except (TypeError, ValueError): + continue + for key in ("label", "name", "title", "description", "symbol", "value"): + label = record.get(key) + if isinstance(label, (int, float)): + label = str(label) + if isinstance(label, str) and label.strip(): + out[label.strip().lower()] = rid_int + break + return out + + +def _label_indexes() -> dict[str, dict[str, int]]: + global _LABEL_INDEX_CACHE + with _LABEL_INDEX_LOCK: + if _LABEL_INDEX_CACHE is None: + _LABEL_INDEX_CACHE = { + kind: _build_label_index(_load_db_file(filename)) + for kind, filename in _DB_FILES.items() + } + return _LABEL_INDEX_CACHE + + +def _resolve_id(kind: str, value: Any, default: int = 0) -> int: + if value is None or value == "": + return default + if isinstance(value, bool): + return default + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + stripped = value.strip() + if not stripped: + return default + if stripped.isdigit(): + return int(stripped) + return _label_indexes().get(kind, {}).get(stripped.lower(), default) + return default + + +def _utf8_message_bytes(text: Any, max_bytes: int = Constants.MESSAGE_LENGTH) -> bytes: + """Encode ``text`` to UTF-8, truncated to ``max_bytes`` without splitting a + codepoint, right-padded with zeros.""" + if not text: + return b"\x00" * max_bytes + enc = str(text).encode("utf-8") + if len(enc) > max_bytes: + end = max_bytes + while end > 0 and (enc[end] & 0xC0) == 0x80: + end -= 1 + enc = enc[:end] + return enc + b"\x00" * (max_bytes - len(enc)) + + +def _hex_to_rgba(value: Any) -> tuple[int, int, int, int]: + """Parse ``#RRGGBB`` / ``RRGGBB`` / ``RRGGBBAA`` / ``[r,g,b,a?]`` to + ``(R, G, B, A)``. Default is opaque black.""" + default = (0, 0, 0, 0xFF) + if isinstance(value, (list, tuple)) and len(value) >= 3: + r = int(value[0]) & 0xFF + g = int(value[1]) & 0xFF + b = int(value[2]) & 0xFF + a = int(value[3]) & 0xFF if len(value) >= 4 else 0xFF + return (r, g, b, a) + if not isinstance(value, str): + return default + s = value.strip().lstrip("#") + if len(s) == 6: + try: + return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16), 0xFF) + except ValueError: + return default + if len(s) == 8: + try: + return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16), int(s[6:8], 16)) + except ValueError: + return default + return default + + +def _resolve_mfg_timestamp(mfg_raw: Any) -> int: + """Convert a manufacturing-date spec into a TigerTag timestamp (seconds + since 2000-01-01). Falls back to ``now`` on parse failure.""" + if isinstance(mfg_raw, bool): + return int(time.time()) - Constants.TIGERTAG_EPOCH_OFFSET + if isinstance(mfg_raw, (int, float)): + return int(mfg_raw) + if isinstance(mfg_raw, str) and mfg_raw.strip(): + s = mfg_raw.strip() + for fmt in ( + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%dT%H:%M", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d", + ): + try: + # Parse as naive UTC (matches parser, which uses UTC for the inverse). + dt = datetime.strptime(s[: len(fmt) + 4], fmt).replace(tzinfo=timezone.utc) + return int(dt.timestamp()) - Constants.TIGERTAG_EPOCH_OFFSET + except (ValueError, OverflowError): + continue + return int(time.time()) - Constants.TIGERTAG_EPOCH_OFFSET + + +def encode(spec: dict) -> bytes: + """Encode a TigerTag spec dict into a 96-byte user-data block. + + All multi-byte fields are big-endian. The signature region (bytes 80..95) + is left zeroed; the parser does not enforce a signature. + + Spec fields (all optional unless noted):: + + material: str label or numeric id (default 0) + brand: str label or numeric id (default 0) + aspect_1: str label or numeric id (default 0) + aspect_2: str label or numeric id (default 0) + type: str label or numeric id (default 0) + diameter: str label or numeric id (e.g. "1.75") + product_id: int 0..0xFFFFFFFF (default 0) + color: "#RRGGBB" / "RRGGBBAA" / [r,g,b,a] (default opaque black) + weight_g: int 0..16777215 (3-byte BE) + unit: str label or numeric id (default 0) + temp_min_c: int 0..65535 + temp_max_c: int 0..65535 + dry_temp_c: int 0..255 + dry_time_h: int 0..255 + bed_temp_min_c: int 0..255 + bed_temp_max_c: int 0..255 + td_mm: float 0..6553.5 (encoded as round(mm * 10)) + td: int 0..65535 (legacy raw, ignored if td_mm given) + manufacturing_date: ISO date/datetime str or epoch number + message: str up to 28 UTF-8 bytes (written at OFF_MESSAGE) + """ + if not isinstance(spec, dict): + spec = {} + + buf = bytearray(PAYLOAD_LENGTH_BYTES) + + # Header / IDs + buf[Constants.OFF_TAG_ID:Constants.OFF_TAG_ID + 4] = TIGERTAG_TAG_ID.to_bytes(4, "big") + pid = int(spec.get("product_id", 0) or 0) & 0xFFFFFFFF + buf[Constants.OFF_PRODUCT_ID:Constants.OFF_PRODUCT_ID + 4] = pid.to_bytes(4, "big") + mid = _resolve_id("material", spec.get("material")) + buf[Constants.OFF_MATERIAL_ID:Constants.OFF_MATERIAL_ID + 2] = (mid & 0xFFFF).to_bytes(2, "big") + buf[Constants.OFF_ASPECT1_ID] = _resolve_id("aspect", spec.get("aspect_1")) & 0xFF + buf[Constants.OFF_ASPECT2_ID] = _resolve_id("aspect", spec.get("aspect_2")) & 0xFF + buf[Constants.OFF_TYPE_ID] = _resolve_id("type", spec.get("type")) & 0xFF + buf[Constants.OFF_DIAMETER_ID] = _resolve_id("diameter", spec.get("diameter")) & 0xFF + bid = _resolve_id("brand", spec.get("brand")) + buf[Constants.OFF_BRAND_ID:Constants.OFF_BRAND_ID + 2] = (bid & 0xFFFF).to_bytes(2, "big") + + # Color (RGBA, written R,G,B,A) + r, g, b, a = _hex_to_rgba(spec.get("color")) + buf[Constants.OFF_COLOR_RGBA + 0] = r + buf[Constants.OFF_COLOR_RGBA + 1] = g + buf[Constants.OFF_COLOR_RGBA + 2] = b + buf[Constants.OFF_COLOR_RGBA + 3] = a + + # Weight (3-byte BE) + unit + weight = max(0, min(int(spec.get("weight_g", 0) or 0), 0xFFFFFF)) + buf[Constants.OFF_WEIGHT + 0] = (weight >> 16) & 0xFF + buf[Constants.OFF_WEIGHT + 1] = (weight >> 8) & 0xFF + buf[Constants.OFF_WEIGHT + 2] = weight & 0xFF + buf[Constants.OFF_UNIT_ID] = _resolve_id("unit", spec.get("unit")) & 0xFF + + # Temps + tmin = max(0, min(int(spec.get("temp_min_c", 0) or 0), 0xFFFF)) + tmax = max(0, min(int(spec.get("temp_max_c", 0) or 0), 0xFFFF)) + buf[Constants.OFF_TEMP_MIN:Constants.OFF_TEMP_MIN + 2] = tmin.to_bytes(2, "big") + buf[Constants.OFF_TEMP_MAX:Constants.OFF_TEMP_MAX + 2] = tmax.to_bytes(2, "big") + buf[Constants.OFF_DRY_TEMP] = max(0, min(int(spec.get("dry_temp_c", 0) or 0), 0xFF)) + buf[Constants.OFF_DRY_TIME] = max(0, min(int(spec.get("dry_time_h", 0) or 0), 0xFF)) + buf[Constants.OFF_BED_TEMP_MIN] = max(0, min(int(spec.get("bed_temp_min_c", 0) or 0), 0xFF)) + buf[Constants.OFF_BED_TEMP_MAX] = max(0, min(int(spec.get("bed_temp_max_c", 0) or 0), 0xFF)) + + # Manufacturing timestamp (seconds since 2000-01-01) + ts = max(0, min(_resolve_mfg_timestamp(spec.get("manufacturing_date")), 0xFFFFFFFF)) + buf[Constants.OFF_TIMESTAMP:Constants.OFF_TIMESTAMP + 4] = ts.to_bytes(4, "big") + + # bytes 36..43 reserved — leave zero + + # TD (transmission distance), big-endian uint16, value = mm * 10 + if spec.get("td_mm") is not None: + try: + td = int(round(float(spec.get("td_mm") or 0) * 10)) + except (TypeError, ValueError): + td = 0 + else: + td = int(spec.get("td", 0) or 0) + td = max(0, min(td, 0xFFFF)) + buf[Constants.OFF_TD:Constants.OFF_TD + 2] = td.to_bytes(2, "big") + + # bytes 46..47 reserved — leave zero + # OFF_MESSAGE = 48: 28-byte UTF-8 message + 4 reserved bytes (signature + # region starts at OFF_SIGNATURE = 80 and is left zeroed). + buf[Constants.OFF_MESSAGE:Constants.OFF_MESSAGE + Constants.MESSAGE_LENGTH] = _utf8_message_bytes( + spec.get("message"), max_bytes=Constants.MESSAGE_LENGTH + ) + + return bytes(buf) + + +def encode_blank() -> bytes: + """Return a 96-byte payload of zeros (used to erase NTAG215 user pages).""" + return b"\x00" * PAYLOAD_LENGTH_BYTES diff --git a/src/tag/tigertag/processor.py b/src/tag/tigertag/processor.py index 975670b..bf67511 100644 --- a/src/tag/tigertag/processor.py +++ b/src/tag/tigertag/processor.py @@ -68,6 +68,19 @@ def __parse_tigertag(self, scan_result: ScanResult, user_data: bytes, tag_id: in timestamp_raw = struct.unpack_from('>I', user_data, Constants.OFF_TIMESTAMP)[0] + # Custom message: the 32-byte metadata block is exposed verbatim as + # a UTF-8 string. The TigerTag spec reserves the first few bytes + # for an optional emoji glyph, but the encoding is not well-defined + # across writers (and most writers omit it), so we hand the whole + # region to the application untouched. + message = "" + if len(user_data) >= Constants.OFF_MESSAGE + Constants.MESSAGE_LENGTH: + raw_msg = user_data[Constants.OFF_MESSAGE:Constants.OFF_MESSAGE + Constants.MESSAGE_LENGTH] + try: + message = raw_msg.rstrip(b'\x00').decode('utf-8', errors='replace').strip() + except Exception: + message = "" + material_label = self.registry.material_ids.get(material_id, f"Unknown({material_id})") material_type = self.registry.material_type_ids.get(material_id, material_label) filled_type = self.registry.filled_type_ids.get(material_id, "") @@ -99,6 +112,7 @@ def __parse_tigertag(self, scan_result: ScanResult, user_data: bytes, tag_id: in self.logger.debug(" Dry: %d °C for %d hours", dry_temp, dry_time) self.logger.debug(" Bed Temp: %d-%d °C", bed_temp_min, bed_temp_max) self.logger.debug(" TD: raw=%d → %.1f mm", td_raw, td_mm) + self.logger.debug(" Message: %r", message) self.logger.debug(" Timestamp: %d (%s)", timestamp_raw, manufacturing_date) return GenericFilament( @@ -115,11 +129,13 @@ def __parse_tigertag(self, scan_result: ScanResult, user_data: bytes, tag_id: in weight_grams=weight_grams, hotend_min_temp_c=float(temp_min), hotend_max_temp_c=float(temp_max), - bed_temp_c=0.0, + bed_temp_c=float(bed_temp_min), + bed_temp_max_c=float(bed_temp_max), drying_temp_c=float(dry_temp), drying_time_hours=float(dry_time), manufacturing_date=manufacturing_date, td=td_mm, + message=message, ) except Exception as e: diff --git a/test/tags/Tigertag/Elegoo PLA Basic Grey.yml b/test/tags/Tigertag/Elegoo PLA Basic Grey.yml index 08bade3..537e900 100644 --- a/test/tags/Tigertag/Elegoo PLA Basic Grey.yml +++ b/test/tags/Tigertag/Elegoo PLA Basic Grey.yml @@ -10,8 +10,10 @@ diameter_mm: 1.75 weight_grams: 1000.0 hotend_min_temp_c: 190.0 hotend_max_temp_c: 240.0 -bed_temp_c: 0.0 +bed_temp_c: 45.0 drying_temp_c: 50.0 drying_time_hours: 8.0 -manufacturing_date: "2026-04-03" -td: 0.0 \ No newline at end of file +manufacturing_date: '2026-04-03' +td: 0.0 +bed_temp_max_c: 55.0 +message: '' diff --git a/test/tags/Tigertag/R3D PETG HS Carbon Fiber Black TigerTag+.yml b/test/tags/Tigertag/R3D PETG HS Carbon Fiber Black TigerTag+.yml index 6cba9f9..57e921e 100644 --- a/test/tags/Tigertag/R3D PETG HS Carbon Fiber Black TigerTag+.yml +++ b/test/tags/Tigertag/R3D PETG HS Carbon Fiber Black TigerTag+.yml @@ -1,5 +1,4 @@ - -bed_temp_c: 0.0 +bed_temp_c: 70.0 colors: - 0xFF141618 diameter_mm: 1.75 @@ -16,3 +15,5 @@ td: 0.0 type: PETG-CF unique_id: f97ed8a8deccab7724f6b498c41a0b18c8e9250307b5f332a34055e7308f47a4 weight_grams: 1000.0 +bed_temp_max_c: 90.0 +message: '' diff --git a/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Cyan TigerTag+.yml b/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Cyan TigerTag+.yml index 088b3fc..0b7d6cc 100644 --- a/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Cyan TigerTag+.yml +++ b/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Cyan TigerTag+.yml @@ -1,4 +1,4 @@ -bed_temp_c: 0.0 +bed_temp_c: 40.0 colors: - 0xFF00A3E0 diameter_mm: 1.75 @@ -15,3 +15,5 @@ td: 0.0 type: PLA unique_id: f90c55783e801ae94761b4ac89be2c9699de24bd1f946b9ee523c5d58b325a04 weight_grams: 1000.0 +bed_temp_max_c: 60.0 +message: '' diff --git a/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Magenta TigerTag+.yml b/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Magenta TigerTag+.yml index 1c1804b..8e8cbdc 100644 --- a/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Magenta TigerTag+.yml +++ b/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Magenta TigerTag+.yml @@ -1,4 +1,4 @@ -bed_temp_c: 0.0 +bed_temp_c: 40.0 colors: - 0xFFC6007E diameter_mm: 1.75 @@ -15,3 +15,5 @@ td: 0.0 type: PLA unique_id: f2773567e0bd0ae8d2dac1dc8a9565f5bb4ee497572304c84e3b0b70bdb16b4e weight_grams: 1000.0 +bed_temp_max_c: 60.0 +message: '' diff --git a/test/tags/Tigertag/Rosa3D PLA Starter Lithophane White TigerTag+.yml b/test/tags/Tigertag/Rosa3D PLA Starter Lithophane White TigerTag+.yml index 880aa82..07e21fd 100644 --- a/test/tags/Tigertag/Rosa3D PLA Starter Lithophane White TigerTag+.yml +++ b/test/tags/Tigertag/Rosa3D PLA Starter Lithophane White TigerTag+.yml @@ -1,4 +1,4 @@ -bed_temp_c: 0.0 +bed_temp_c: 40.0 colors: - 0xFFFAFAFA diameter_mm: 1.75 @@ -15,3 +15,5 @@ td: 0.0 type: PLA unique_id: ca98c88138a8c8f300f07c240dc834677e59656f983b34b8c9c8d8c641bbdec1 weight_grams: 1000.0 +bed_temp_max_c: 60.0 +message: '' diff --git a/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Yellow TigerTag+.yml b/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Yellow TigerTag+.yml index 3a469f4..4358158 100644 --- a/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Yellow TigerTag+.yml +++ b/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Yellow TigerTag+.yml @@ -1,4 +1,4 @@ -bed_temp_c: 0.0 +bed_temp_c: 40.0 colors: - 0xFFF6EB61 diameter_mm: 1.75 @@ -15,3 +15,5 @@ td: 0.0 type: PLA unique_id: e51a87ddc3e239f0a2bb81c370d1c99d8dbf2ef9b2e6506fa0d908a640cab0f2 weight_grams: 1000.0 +bed_temp_max_c: 60.0 +message: '' diff --git a/test/tags/Tigertag/Sunlu PLA Roasted Chestnut TigerTag+.yml b/test/tags/Tigertag/Sunlu PLA Roasted Chestnut TigerTag+.yml index 2043ee4..c04d6c3 100644 --- a/test/tags/Tigertag/Sunlu PLA Roasted Chestnut TigerTag+.yml +++ b/test/tags/Tigertag/Sunlu PLA Roasted Chestnut TigerTag+.yml @@ -1,4 +1,4 @@ -bed_temp_c: 0.0 +bed_temp_c: 50.0 colors: - 0xFF3E2B2E diameter_mm: 1.75 @@ -15,3 +15,5 @@ td: 0.0 type: PLA unique_id: b537b06ab1f153b9176caa3cb12492d87094703848da8e682f242c052bdf940f weight_grams: 1000.0 +bed_temp_max_c: 60.0 +message: '' diff --git a/test/tags/Tigertag/eSun PLA Basic Refill Cold White TigerTag+.yml b/test/tags/Tigertag/eSun PLA Basic Refill Cold White TigerTag+.yml index fa50170..4387c7d 100644 --- a/test/tags/Tigertag/eSun PLA Basic Refill Cold White TigerTag+.yml +++ b/test/tags/Tigertag/eSun PLA Basic Refill Cold White TigerTag+.yml @@ -1,4 +1,4 @@ -bed_temp_c: 0.0 +bed_temp_c: 45.0 colors: - 0xFFDDE5ED diameter_mm: 1.75 @@ -15,3 +15,5 @@ td: 0.0 type: PLA unique_id: c97d79c4ec83c89e5063834556625468b602e492996bfd674fbbeb238469d031 weight_grams: 1000.0 +bed_temp_max_c: 65.0 +message: '' diff --git a/test/test_runtime_pending_write.py b/test/test_runtime_pending_write.py new file mode 100644 index 0000000..ea36e7f --- /dev/null +++ b/test/test_runtime_pending_write.py @@ -0,0 +1,160 @@ +"""Tests for the Runtime NTAG pending-write queue. + +The runtime serialises NTAG writes onto its read loop so the RFID bus is only +touched from one thread. ``submit_write`` blocks the caller on a +``threading.Event`` until the loop drains the request via +``_drain_pending_write``. These tests exercise that handshake without spinning +the full ``loop()`` (which is a ``while True``). +""" + +from __future__ import annotations + +import sys +import threading +import time +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = ROOT / "src" +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from config import register_configurable_entity +from config.config_manager import LOADED_MODULES +from config.configuration import default_configuration +from runtime import Runtime + + +class _FakeReader: + """Minimal stand-in for an ``RfidReader`` with NTAG write support.""" + + def __init__(self, slot: int = 0, write_status: int = 0): + self.name = f"fake_slot_{slot}" + self.slot = slot + self.last_read_uid = None + self._write_status = write_status + self.calls: list[tuple[int, bytes]] = [] + + def write_ntag_pages(self, start_page: int, data: bytes) -> int: + self.calls.append((start_page, bytes(data))) + return self._write_status + + +class _FakeReaderNoWrite: + def __init__(self, slot: int = 0): + self.name = f"fake_slot_{slot}" + self.slot = slot + self.last_read_uid = None + + +@pytest.fixture(autouse=True) +def _reset_entities(): + LOADED_MODULES.clear() + register_configurable_entity(default_configuration()) + yield + LOADED_MODULES.clear() + + +def _build_runtime_with(reader) -> Runtime: + runtime = Runtime() + runtime.rfid_readers = [reader] + runtime.read_retries_left = [0] + return runtime + + +def test_submit_write_returns_ok_when_drained() -> None: + reader = _FakeReader(slot=0, write_status=0) + runtime = _build_runtime_with(reader) + + payload = b"\xDE\xAD\xBE\xEF" * 24 # 96 bytes (TigerTag-sized) + + result_box: dict = {} + + def _submit(): + result_box["result"] = runtime.submit_write(0, payload, start_page=4, timeout=2.0) + + submitter = threading.Thread(target=_submit, daemon=True) + submitter.start() + + # Wait until the request is registered, then drain it from the "loop". + deadline = time.time() + 1.0 + while time.time() < deadline: + if 0 in runtime._pending_writes: + break + time.sleep(0.01) + assert 0 in runtime._pending_writes, "submit_write did not register pending write" + + handled = runtime._drain_pending_write(0, reader) + assert handled is True + + submitter.join(timeout=2.0) + assert not submitter.is_alive(), "submitter did not unblock" + + result = result_box["result"] + assert result["ok"] is True + assert result["status"] == 0 + assert result["start_page"] == 4 + assert result["bytes_written"] == 96 + + assert reader.calls == [(4, payload)] + + +def test_submit_write_rejects_invalid_slot() -> None: + reader = _FakeReader(slot=0) + runtime = _build_runtime_with(reader) + result = runtime.submit_write(5, b"\x00" * 4) + assert result["ok"] is False + assert "invalid slot" in result["error"] + + +def test_submit_write_rejects_misaligned_data() -> None: + reader = _FakeReader(slot=0) + runtime = _build_runtime_with(reader) + assert runtime.submit_write(0, b"\x00" * 5)["ok"] is False + assert runtime.submit_write(0, b"")["ok"] is False + + +def test_submit_write_times_out_when_loop_idle() -> None: + reader = _FakeReader(slot=0) + runtime = _build_runtime_with(reader) + + result = runtime.submit_write(0, b"\x00" * 4, timeout=0.1) + assert result["ok"] is False + assert "timeout" in result["error"] + # Pending entry must be cleared on timeout so next submit can register. + assert 0 not in runtime._pending_writes + + +def test_drain_returns_false_when_no_pending() -> None: + reader = _FakeReader(slot=0) + runtime = _build_runtime_with(reader) + assert runtime._drain_pending_write(0, reader) is False + + +def test_drain_handles_reader_without_write_support() -> None: + reader = _FakeReaderNoWrite(slot=0) + runtime = _build_runtime_with(reader) + + result_box: dict = {} + + def _submit(): + result_box["result"] = runtime.submit_write(0, b"\x00" * 4, timeout=2.0) + + submitter = threading.Thread(target=_submit, daemon=True) + submitter.start() + + deadline = time.time() + 1.0 + while time.time() < deadline: + if 0 in runtime._pending_writes: + break + time.sleep(0.01) + + handled = runtime._drain_pending_write(0, reader) + assert handled is True + + submitter.join(timeout=2.0) + result = result_box["result"] + assert result["ok"] is False + assert "does not support" in result["error"] diff --git a/test/test_tigertag_encoder.py b/test/test_tigertag_encoder.py new file mode 100644 index 0000000..17ab39c --- /dev/null +++ b/test/test_tigertag_encoder.py @@ -0,0 +1,124 @@ +"""Round-trip tests for the TigerTag encoder. + +Verifies that ``encode(spec)`` produces a 96-byte payload that the existing +:class:`TigerTagProcessor` can parse back into a :class:`GenericFilament` whose +fields match the spec. +""" + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = ROOT / "src" + +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from reader.scan_result import ScanResult +from tag.tag_types import TagType +from tag.tigertag import constants as Constants +from tag.tigertag.encoder import ( + PAYLOAD_LENGTH_BYTES, + TIGERTAG_TAG_ID, + USER_DATA_START_PAGE, + encode, + encode_blank, +) +from tag.tigertag.processor import TigerTagProcessor + + +def _build_processor() -> TigerTagProcessor: + processor = TigerTagProcessor({"__name": "TigerTagProcessor"}) + processor.enabled = True + return processor + + +def _build_scan_result() -> ScanResult: + return ScanResult( + tag_type=TagType.MifareUltralight, + uid=b"\x01\x02\x03\x04", + atqa=b"\x00\x44", + bcc=b"\x00", + sak=b"\x00", + ) + + +def _wrap_user_payload(payload: bytes) -> bytes: + """Prepend ``USER_DATA_BYTE_OFFSET`` zero bytes so the parser sees the + payload at user-data page 4 (the parser slices past the first + ``USER_DATA_BYTE_OFFSET`` bytes).""" + return b"\x00" * Constants.USER_DATA_BYTE_OFFSET + payload + + +def test_encoder_payload_length_and_start_page() -> None: + assert PAYLOAD_LENGTH_BYTES == 96 + assert USER_DATA_START_PAGE == 4 + assert len(encode({})) == PAYLOAD_LENGTH_BYTES + assert encode_blank() == b"\x00" * PAYLOAD_LENGTH_BYTES + + +def test_encoder_emits_tag_id_header() -> None: + payload = encode({}) + assert int.from_bytes(payload[0:4], "big") == TIGERTAG_TAG_ID + + +def test_encoder_round_trip_minimal_pla_spec() -> None: + spec = { + "material": "PLA", + "brand": "ELEGOO", + "diameter": "1.75", + "color": "#BCBCBC", + "weight_g": 1000, + "unit": "g", + "temp_min_c": 190, + "temp_max_c": 240, + "dry_temp_c": 50, + "dry_time_h": 8, + "bed_temp_min_c": 45, + "bed_temp_max_c": 55, + "td_mm": 0.0, + "manufacturing_date": "2026-04-03", + "message": "Hello, world!", + } + + payload = encode(spec) + assert len(payload) == PAYLOAD_LENGTH_BYTES + + filament = _build_processor().process_tag(_build_scan_result(), _wrap_user_payload(payload)) + assert filament is not None + + assert filament.diameter_mm == pytest.approx(1.75) + assert filament.weight_grams == pytest.approx(1000.0) + assert filament.hotend_min_temp_c == pytest.approx(190.0) + assert filament.hotend_max_temp_c == pytest.approx(240.0) + assert filament.bed_temp_c == pytest.approx(45.0) + assert filament.bed_temp_max_c == pytest.approx(55.0) + assert filament.drying_temp_c == pytest.approx(50.0) + assert filament.drying_time_hours == pytest.approx(8.0) + assert filament.manufacturing_date == "2026-04-03" + assert filament.message == "Hello, world!" + + # Color: encoder takes "#RRGGBB" (opaque). Parser stores ARGB, alpha=0xFF. + assert filament.colors == [(0xFF << 24) | (0xBC << 16) | (0xBC << 8) | 0xBC] + + +def test_encoder_message_truncation_and_padding() -> None: + long_msg = "x" * 100 + payload = encode({"message": long_msg}) + msg_region = payload[Constants.OFF_MESSAGE:Constants.OFF_MESSAGE + Constants.MESSAGE_LENGTH] + assert msg_region == b"x" * Constants.MESSAGE_LENGTH + + short_payload = encode({"message": "ab"}) + short_region = short_payload[Constants.OFF_MESSAGE:Constants.OFF_MESSAGE + Constants.MESSAGE_LENGTH] + assert short_region == b"ab" + b"\x00" * (Constants.MESSAGE_LENGTH - 2) + + +def test_encoder_message_does_not_split_utf8_codepoint() -> None: + # 4-byte UTF-8 emoji repeated; should truncate at a codepoint boundary. + payload = encode({"message": "\U0001F600" * 10}) # 😀 × 10 = 40 bytes + msg_region = payload[Constants.OFF_MESSAGE:Constants.OFF_MESSAGE + Constants.MESSAGE_LENGTH] + # Decoding the truncated region must succeed without errors. + decoded = msg_region.rstrip(b"\x00").decode("utf-8") + assert decoded == "\U0001F600" * 7 # 28 bytes / 4 bytes per emoji = 7 From 981902d59099a903ad35813a2f122ed1060381e4 Mon Sep 17 00:00:00 2001 From: Gordian Zomer Date: Mon, 11 May 2026 17:40:21 +0200 Subject: [PATCH 2/8] Update event handling to comply with Moonraker's agent protocol --- src/controllers/openrfid_api.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/controllers/openrfid_api.py b/src/controllers/openrfid_api.py index 47a2583..a01c75e 100644 --- a/src/controllers/openrfid_api.py +++ b/src/controllers/openrfid_api.py @@ -165,11 +165,17 @@ def _broadcast_loop(self): if event is None: continue try: + # Per Moonraker's agent protocol, agents emit events via + # `connection.send_event` with `{event, data}`. Moonraker + # stamps the agent name from the connection identity and + # rebroadcasts to subscribers as `notify_agent_event` + # notifications. Sending `notify_agent_event` directly + # is treated as an RPC call and rejected with -32601. + # https://moonraker.readthedocs.io/en/latest/external_api/extensions/#send-an-agent-event self.send_message({ "jsonrpc": "2.0", - "method": "notify_agent_event", + "method": "connection.send_event", "params": { - "agent": AGENT_NAME, "event": "openrfid/scan", "data": event, }, From 483380ab8dfcdb73b697771a43779865ab83c871 Mon Sep 17 00:00:00 2001 From: Gordian Zomer Date: Mon, 11 May 2026 13:36:01 +0200 Subject: [PATCH 3/8] Add NTAG write support and message handling - Implemented `write_ntag_pages` method in `GpioEnabledRfidReader` to handle NTAG21x page writes. - Enhanced `Runtime` class to manage pending NTAG write requests with a dedicated queue and threading support. - Introduced `_PendingWrite` class to encapsulate write request state. - Updated `TigerTagProcessor` to parse and log custom messages from the NTAG payload. - Created `encoder` module for TigerTag to encode specifications into a 96-byte payload, including message handling. - Added tests for pending write functionality and encoder round-trip validation. - Updated existing TigerTag YAML configurations to include bed temperature and message fields. --- examples/u1.cfg | 15 + src/controllers/openrfid_api.py | 243 +++++++++++++++ src/exporters/openrfid_agent_event.py | 87 ++++++ src/filament/generic.py | 10 +- src/main.py | 8 +- src/reader/fm175xx/rfid.py | 64 ++++ src/reader/gpio_enabled_rfid_reader.py | 18 +- src/runtime.py | 107 ++++++- src/tag/tigertag/constants.py | 8 + src/tag/tigertag/encoder.py | 281 ++++++++++++++++++ src/tag/tigertag/processor.py | 18 +- test/tags/Tigertag/Elegoo PLA Basic Grey.yml | 8 +- ...D PETG HS Carbon Fiber Black TigerTag+.yml | 5 +- ... PLA Starter Lithophane Cyan TigerTag+.yml | 4 +- ...A Starter Lithophane Magenta TigerTag+.yml | 4 +- ...PLA Starter Lithophane White TigerTag+.yml | 4 +- ...LA Starter Lithophane Yellow TigerTag+.yml | 4 +- .../Sunlu PLA Roasted Chestnut TigerTag+.yml | 4 +- ... PLA Basic Refill Cold White TigerTag+.yml | 4 +- test/test_runtime_pending_write.py | 160 ++++++++++ test/test_tigertag_encoder.py | 124 ++++++++ 21 files changed, 1163 insertions(+), 17 deletions(-) create mode 100644 src/controllers/openrfid_api.py create mode 100644 src/exporters/openrfid_agent_event.py create mode 100644 src/tag/tigertag/encoder.py create mode 100644 test/test_runtime_pending_write.py create mode 100644 test/test_tigertag_encoder.py diff --git a/examples/u1.cfg b/examples/u1.cfg index 40f5fec..e756924 100644 --- a/examples/u1.cfg +++ b/examples/u1.cfg @@ -122,6 +122,21 @@ track_field = state get_index_from_field = array act_on_value = 1 +# Optional: expose OpenRFID over Moonraker as a JSON-RPC agent so a Mainsail/ +# Fluidd-style web UI can list channels, trigger scans, and (optionally) write +# tags. Pair with [openrfid_agent_event_exporter] below to also receive +# notify_agent_event broadcasts on every scan. +#[openrfid_api] +#moonraker_socket_path = /oem/printer_data/comms/moonraker.sock +## Set enable_write = true to allow openrfid/write_tag and openrfid/clear_tag. +## Leave false (the default) for read-only deployments. +#enable_write = false + +#[openrfid_agent_event_exporter] +## Defaults to all three events (tag_read, tag_parse_error, tag_not_present). +#events = tag_read,tag_parse_error,tag_not_present +#max_queued = 64 + [configuration] auto_read_mode = false retries = 20 diff --git a/src/controllers/openrfid_api.py b/src/controllers/openrfid_api.py new file mode 100644 index 0000000..47a2583 --- /dev/null +++ b/src/controllers/openrfid_api.py @@ -0,0 +1,243 @@ +"""Moonraker agent controller exposing OpenRFID over JSON-RPC. + +Registers a Moonraker *agent* named ``openrfid`` that: + +* exposes the remote methods ``openrfid/list_channels``, ``openrfid/scan_slot``, + ``openrfid/write_tag``, ``openrfid/clear_tag`` and ``openrfid/tigertag_encode``, +* broadcasts ``notify_agent_event`` notifications (event name + ``openrfid/scan``) for every scan event picked up from a configured + :class:`exporters.openrfid_agent_event.OpenrfidAgentEventExporter`. + +Write operations (``write_tag`` / ``clear_tag``) are gated behind the +``enable_write`` config flag (default ``false``) so installations that don't +want write access stay safe by default. +""" + +from __future__ import annotations + +import threading +import time +from typing import Any, Callable + +from config import get_entities_by_type, TYPE_EXPORTER +from controllers.moonraker_controller import MoonrakerController +from exporters.openrfid_agent_event import OpenrfidAgentEventExporter +from runtime import Runtime +from tag.tigertag import encoder as tigertag_encoder + + +AGENT_NAME = "openrfid" + +_MSG_ID_REGISTER_AGENT = 0x6F1A0001 +_MSG_ID_REGISTER_METHOD_BASE = 0x6F1A0010 + +REMOTE_METHOD_LIST_CHANNELS = "openrfid/list_channels" +REMOTE_METHOD_SCAN_SLOT = "openrfid/scan_slot" +REMOTE_METHOD_WRITE_TAG = "openrfid/write_tag" +REMOTE_METHOD_CLEAR_TAG = "openrfid/clear_tag" +REMOTE_METHOD_TIGERTAG_ENCODE = "openrfid/tigertag_encode" + + +class OpenrfidApiController(MoonrakerController): + """Moonraker agent that surfaces OpenRFID to Mainsail/Fluidd-style UIs.""" + + def __init__(self, config: dict): + super().__init__(config) + self.runtime: Runtime + self.enable_write = str(config.get("enable_write", "false")).lower() == "true" + self.agent_event_exporter_name = config.get("agent_event_exporter", None) + + self._exporter: OpenrfidAgentEventExporter | None = None + self._broadcast_thread: threading.Thread | None = None + self._stop_broadcast = threading.Event() + + self._handlers: dict[str, Callable[[dict], Any]] = { + REMOTE_METHOD_LIST_CHANNELS: self._handle_list_channels, + REMOTE_METHOD_SCAN_SLOT: self._handle_scan_slot, + REMOTE_METHOD_WRITE_TAG: self._handle_write_tag, + REMOTE_METHOD_CLEAR_TAG: self._handle_clear_tag, + REMOTE_METHOD_TIGERTAG_ENCODE: self._handle_tigertag_encode, + } + + # ------------------------------------------------------------------ + # Wiring helpers + # ------------------------------------------------------------------ + def _resolve_exporter(self) -> OpenrfidAgentEventExporter | None: + if self._exporter is not None: + return self._exporter + + exporters = [e for e in get_entities_by_type(TYPE_EXPORTER) if isinstance(e, OpenrfidAgentEventExporter)] + if not exporters: + return None + + if self.agent_event_exporter_name: + for e in exporters: + if e.name == self.agent_event_exporter_name: + self._exporter = e + return e + self.logger.warning("Configured agent_event_exporter '%s' not found", self.agent_event_exporter_name) + return None + + if len(exporters) > 1: + self.logger.warning( + "Multiple openrfid_agent_event_exporter entities found; using '%s'", + exporters[0].name, + ) + self._exporter = exporters[0] + return self._exporter + + # ------------------------------------------------------------------ + # MoonrakerController hooks + # ------------------------------------------------------------------ + def on_connect(self): + self._send_register_agent() + time.sleep(0.05) + for offset, method in enumerate(self._handlers): + self._send_register_remote_method(method, _MSG_ID_REGISTER_METHOD_BASE + offset) + time.sleep(0.02) + + # Start (or restart) the broadcast pump. + self._stop_broadcast.set() + if self._broadcast_thread and self._broadcast_thread.is_alive(): + self._broadcast_thread.join(timeout=1.0) + self._stop_broadcast = threading.Event() + self._broadcast_thread = threading.Thread( + target=self._broadcast_loop, name="openrfid-agent-broadcast", daemon=True + ) + self._broadcast_thread.start() + + def on_message(self, message: dict): + method = message.get("method") + if not method or method not in self._handlers: + return + + msg_id = message.get("id") + params = message.get("params") or {} + if isinstance(params, list): + params = params[0] if params and isinstance(params[0], dict) else {} + + try: + result = self._handlers[method](params) + response: dict[str, Any] = {"jsonrpc": "2.0", "result": result} + except Exception as exc: # pragma: no cover - defensive + self.logger.exception("Handler for %s failed", method) + response = {"jsonrpc": "2.0", "error": {"code": -32000, "message": str(exc)}} + + if msg_id is not None: + response["id"] = msg_id + try: + self.send_message(response) + except Exception: + self.logger.exception("Failed to send response for %s", method) + + # ------------------------------------------------------------------ + # Outgoing messages + # ------------------------------------------------------------------ + def _send_register_agent(self): + self.send_message({ + "jsonrpc": "2.0", + "method": "server.connection.identify", + "params": { + "client_name": AGENT_NAME, + "version": "0.1.0", + "type": "agent", + "url": "https://github.com/macdylan/SnapmakerU1-Extended-Firmware", + }, + "id": _MSG_ID_REGISTER_AGENT, + }) + + def _send_register_remote_method(self, method_name: str, msg_id: int): + self.send_message({ + "jsonrpc": "2.0", + "method": "connection.register_remote_method", + "params": {"method_name": method_name}, + "id": msg_id, + }) + + def _broadcast_loop(self): + exporter = self._resolve_exporter() + if exporter is None: + self.logger.info("No openrfid_agent_event_exporter configured; agent events disabled") + return + + while not self._stop_broadcast.is_set(): + event = exporter.pop_event(timeout=0.5) + if event is None: + continue + try: + self.send_message({ + "jsonrpc": "2.0", + "method": "notify_agent_event", + "params": { + "agent": AGENT_NAME, + "event": "openrfid/scan", + "data": event, + }, + }) + except Exception: + self.logger.exception("Failed to broadcast agent event; reconnect imminent") + return + + # ------------------------------------------------------------------ + # Handlers + # ------------------------------------------------------------------ + def _handle_list_channels(self, _params: dict) -> dict: + channels = [] + for i, reader in enumerate(self.runtime.rfid_readers): + channels.append({ + "slot": getattr(reader, "slot", i), + "name": reader.name, + "last_scan": self.runtime.last_scans.get(getattr(reader, "slot", i)), + }) + return {"channels": channels, "write_enabled": self.enable_write} + + def _handle_scan_slot(self, params: dict) -> dict: + slot = self._require_slot(params) + self.runtime.start_reading_tag(slot) + return {"ok": True, "slot": slot} + + def _handle_write_tag(self, params: dict) -> dict: + if not self.enable_write: + return {"ok": False, "error": "writes disabled (set enable_write = true in [openrfid_api])"} + slot = self._require_slot(params) + data_hex = params.get("data_hex") or params.get("data") + if not isinstance(data_hex, str): + return {"ok": False, "error": "data_hex (string) required"} + try: + data = bytes.fromhex(data_hex) + except ValueError as exc: + return {"ok": False, "error": f"invalid hex: {exc}"} + start_page = int(params.get("start_page", 4)) + timeout = float(params.get("timeout", 10.0)) + return self.runtime.submit_write(slot, data, start_page=start_page, timeout=timeout) + + def _handle_clear_tag(self, params: dict) -> dict: + if not self.enable_write: + return {"ok": False, "error": "writes disabled (set enable_write = true in [openrfid_api])"} + slot = self._require_slot(params) + pages = int(params.get("pages", tigertag_encoder.PAYLOAD_LENGTH_BYTES // 4)) + start_page = int(params.get("start_page", tigertag_encoder.USER_DATA_START_PAGE)) + data = b"\x00" * (pages * 4) + return self.runtime.submit_write(slot, data, start_page=start_page) + + def _handle_tigertag_encode(self, params: dict) -> dict: + spec = params.get("spec") or {} + if not isinstance(spec, dict): + return {"ok": False, "error": "spec must be an object"} + try: + payload = tigertag_encoder.encode(spec) + except Exception as exc: + return {"ok": False, "error": str(exc)} + return { + "ok": True, + "data_hex": payload.hex(), + "start_page": tigertag_encoder.USER_DATA_START_PAGE, + "bytes": len(payload), + } + + @staticmethod + def _require_slot(params: dict) -> int: + slot = params.get("slot") + if slot is None: + raise ValueError("slot is required") + return int(slot) diff --git a/src/exporters/openrfid_agent_event.py b/src/exporters/openrfid_agent_event.py new file mode 100644 index 0000000..73ba6f0 --- /dev/null +++ b/src/exporters/openrfid_agent_event.py @@ -0,0 +1,87 @@ +"""Exporter that buffers scan events for the OpenRFID Moonraker agent API. + +This exporter is intentionally decoupled from any controller: it simply pushes +serialised event dictionaries onto a thread-safe queue. The matching +:class:`controllers.openrfid_api.OpenrfidApiController` (when configured) +drains the queue and broadcasts the events as Moonraker ``notify_agent_event`` +JSON-RPC notifications. + +If no controller is wired up, events are still queued (bounded) so the +exporter never raises and stays cheap. +""" + +from __future__ import annotations + +import queue +import time +from typing import Any + +from exporters.exporter import Exporter, ExporterEvent +from filament.generic import GenericFilament +from reader.rfid_reader import RfidReader +from reader.scan_result import ScanResult + + +_DEFAULT_MAX_QUEUED = 64 + + +class OpenrfidAgentEventExporter(Exporter): + """Push every configured scan event onto a bounded in-memory queue.""" + + def __init__(self, config: dict): + # Default to all three events when none specified — the agent API + # consumer normally wants the full stream. + if "events" not in config and "event" not in config: + config = {**config, "events": "tag_read,tag_parse_error,tag_not_present"} + + super().__init__(config) + + self.max_queued = int(config.get("max_queued", _DEFAULT_MAX_QUEUED)) + self._queue: "queue.Queue[dict]" = queue.Queue(maxsize=self.max_queued) + + def export_data(self, scan: ScanResult | None, filament: GenericFilament | None, reader: RfidReader) -> None: + # Determine which event this call corresponds to. Exporter.has_event + # has already filtered by the configured event set, so we infer here. + if filament is not None: + event = ExporterEvent.TAG_READ + elif scan is not None: + event = ExporterEvent.TAG_PARSE_ERROR + else: + event = ExporterEvent.TAG_NOT_PRESENT + + payload: dict[str, Any] = { + "event": event.value, + "ts": time.time(), + "slot": getattr(reader, "slot", None), + "reader": getattr(reader, "name", None), + "uid": scan.uid.hex().upper() if scan is not None else None, + "tag_type": scan.tag_type.name if scan is not None else None, + "filament": filament.to_dict() if filament is not None else None, + } + + # Drop oldest if full so the producer never blocks. + if self._queue.full(): + try: + self._queue.get_nowait() + except queue.Empty: + pass + + try: + self._queue.put_nowait(payload) + except queue.Full: + # Last-ditch: silently drop. We never block the read loop. + self.logger.debug("Agent event queue full, dropping event") + + def pop_event(self, timeout: float | None = 0.5) -> dict | None: + try: + return self._queue.get(timeout=timeout) + except queue.Empty: + return None + + def drain(self) -> list[dict]: + events: list[dict] = [] + while True: + try: + events.append(self._queue.get_nowait()) + except queue.Empty: + return events diff --git a/src/filament/generic.py b/src/filament/generic.py index 81a71bc..2a17b35 100644 --- a/src/filament/generic.py +++ b/src/filament/generic.py @@ -26,7 +26,9 @@ def __init__(self, drying_temp_c: float, drying_time_hours: float, manufacturing_date: str, # ISO 8601 date string - td: float = 0.0 # Transmission Distance in mm for HueForge/OrcaSlicer-FullSpectrum + td: float = 0.0, # Transmission Distance in mm for HueForge/OrcaSlicer-FullSpectrum + bed_temp_max_c: float = 0.0, + message: str = "", # Custom message (e.g. from TigerTag to support a name for the filament or a short description) ): self.source_processor = source_processor self.unique_id = unique_id @@ -43,6 +45,8 @@ def __init__(self, self.drying_time_hours = drying_time_hours self.manufacturing_date = manufacturing_date self.td = td + self.bed_temp_max_c = bed_temp_max_c + self.message = message if "CF" in self.modifiers: self.type += "-CF" @@ -99,10 +103,12 @@ def to_dict(self) -> dict: "hotend_min_temp_c": self.hotend_min_temp_c, "hotend_max_temp_c": self.hotend_max_temp_c, "bed_temp_c": self.bed_temp_c, + "bed_temp_max_c": self.bed_temp_max_c, "drying_temp_c": self.drying_temp_c, "drying_time_hours": self.drying_time_hours, "manufacturing_date": self.manufacturing_date, - "td": self.td + "td": self.td, + "message": self.message, } @staticmethod diff --git a/src/main.py b/src/main.py index 1124c7f..0ef1a08 100644 --- a/src/main.py +++ b/src/main.py @@ -4,7 +4,10 @@ from config import register_configurable_entity, get_required_configurable_entity_by_name, TYPE_RUNTIME, TYPE_EXPORTER, TYPE_TAG_PROCESSOR, TYPE_RFID_READER, ConfigurableEntity from config.configuration import Configuration from controllers.moonraker_remote_method import MoonrakerRemoteMethodController +from controllers.moonraker_on_property_change import MoonrakerOnPropertyChangeController +from controllers.openrfid_api import OpenrfidApiController from exporters.webhook import WebhookExporter +from exporters.openrfid_agent_event import OpenrfidAgentEventExporter from reader.fm175xx.rfid import Fm175xx from reader.gpio_enabled_rfid_reader import GpioEnabledRfidReader import time @@ -26,7 +29,6 @@ from tag.snapmaker import SnapmakerTagProcessor from tag.spoolease import SpooleaseTagProcessor from tag.tigertag import TigerTagProcessor -from controllers.moonraker_on_property_change import MoonrakerOnPropertyChangeController def consume_config(config: dict) -> Runtime: for key, value in config.items(): @@ -71,10 +73,14 @@ def create_configurable_entity(key: str, config: dict) -> ConfigurableEntity: return QidiTagProcessor(config) case "webhook_exporter": return WebhookExporter(config) + case "openrfid_agent_event_exporter": + return OpenrfidAgentEventExporter(config) case "moonraker_remote_method": return MoonrakerRemoteMethodController(config) case "moonraker_on_property_change": return MoonrakerOnPropertyChangeController(config) + case "openrfid_api": + return OpenrfidApiController(config) case _: raise ValueError(f"Unknown configurable entity type: {key_split[0]}") diff --git a/src/reader/fm175xx/rfid.py b/src/reader/fm175xx/rfid.py index 1a8b968..c6ad78c 100644 --- a/src/reader/fm175xx/rfid.py +++ b/src/reader/fm175xx/rfid.py @@ -82,6 +82,70 @@ def read_mifare_ultralight(self, scan_result: ScanResult) -> bytes | None: return bytes(data.out_data) + def write_ntag_pages(self, start_page: int, data: bytes) -> int: + """Write ``data`` to NTAG21x user pages starting at ``start_page``. + + ``data`` must be a multiple of 4 bytes (one NTAG page). The carrier + wave must be enabled (i.e. the caller wraps this in + :meth:`start_session` / :meth:`end_session`). Returns one of the + ``FM175XX_*`` constants from :mod:`reader.fm175xx.constants`. + """ + if not isinstance(data, (bytes, bytearray)): + return Constants.FM175XX_PARAM_ERR + if len(data) == 0 or len(data) % Constants.FM175XX_NTAG215_BYTES_PER_PAGE != 0: + return Constants.FM175XX_PARAM_ERR + + total_pages = len(data) // Constants.FM175XX_NTAG215_BYTES_PER_PAGE + end_page = start_page + total_pages - 1 + if start_page < Constants.FM175XX_NTAG215_USER_START_PAGE or end_page > Constants.FM175XX_NTAG215_USER_END_PAGE: + self.logger.error( + "NTAG write out of user-data range: pages %d..%d (allowed %d..%d)", + start_page, + end_page, + Constants.FM175XX_NTAG215_USER_START_PAGE, + Constants.FM175XX_NTAG215_USER_END_PAGE, + ) + return Constants.FM175XX_PARAM_ERR + + ret, _UID, _ATQA, _BCC, _SAK = self.__reader_a_activate() + if ret != Constants.FM175XX_OK: + self.logger.error("NTAG write activate failed: %d", ret) + return ret + + for i in range(total_pages): + page_no = start_page + i + chunk = list(data[i * 4:(i + 1) * 4]) + wret = self.__ntag_page_write(page_no, chunk) + if wret != Constants.FM175XX_OK: + self.logger.error("NTAG write failed at page %d: %d", page_no, wret) + return wret + + return Constants.FM175XX_OK + + # Reader-A: NTAG/Ultralight, write a page (4 bytes) + def __ntag_page_write(self, page: int, data: list[int]) -> int: + cmd = Fm175xxCmdMetaData() + cmd.send_crc_en = Constants.FM175XX_SET + cmd.recv_crc_en = Constants.FM175XX_RESET + cmd.send_buff = [0xA2, page & 0xFF, data[0] & 0xFF, data[1] & 0xFF, data[2] & 0xFF, data[3] & 0xFF] + cmd.recv_buff = [0] + cmd.bytes_to_send = 6 + cmd.bits_to_send = 0 + cmd.bits_to_recv = 0 + cmd.bytes_to_recv = 1 + cmd.timeout = 10 + cmd.cmd = Constants.FM175XX_CMD_TRANSCEIVE + + result = self.__command_exe(cmd) + if result.err_code != Constants.FM175XX_OK: + return result.err_code + # NTAG ACK: 4 bits, low nibble == 0x0A + if cmd.bits_recved != 4: + return Constants.FM175XX_CARD_COMM_ERR + if (cmd.recv_buff[0] & 0x0F) != 0x0A: + return Constants.FM175XX_CARD_COMM_ERR + return Constants.FM175XX_OK + # read register def __register_read(self, addr:int) -> int: addr = (addr << 1) | 0x80 diff --git a/src/reader/gpio_enabled_rfid_reader.py b/src/reader/gpio_enabled_rfid_reader.py index 105b5a2..316e947 100644 --- a/src/reader/gpio_enabled_rfid_reader.py +++ b/src/reader/gpio_enabled_rfid_reader.py @@ -54,4 +54,20 @@ def read_mifare_ultralight(self, scan_result : ScanResult) -> bytes|None: if isinstance(self.rfid_reader, MifareUltralightReader): return self.rfid_reader.read_mifare_ultralight(scan_result) - return None \ No newline at end of file + return None + + def write_ntag_pages(self, start_page: int, data: bytes) -> int | None: + """Write NTAG21x pages via the inner reader, framed by start/end + session so the slot's GPIO pins and the carrier wave are correctly + toggled. Returns the FM175XX status code from the inner reader, or + ``None`` if the inner reader does not support NTAG writes. + """ + write = getattr(self.rfid_reader, "write_ntag_pages", None) + if not callable(write): + return None + + self.start_session() + try: + return write(start_page, data) + finally: + self.end_session() \ No newline at end of file diff --git a/src/runtime.py b/src/runtime.py index 48f1fc8..ed64c73 100644 --- a/src/runtime.py +++ b/src/runtime.py @@ -11,10 +11,24 @@ from exporters.exporter import Exporter, ExporterEvent from reader.scan_result import ScanResult from filament import GenericFilament -from typing import cast +from typing import cast, Any +import threading import time import logging + +class _PendingWrite: + """State for a write request awaiting drain by :meth:`Runtime.loop`.""" + + __slots__ = ("start_page", "data", "event", "result") + + def __init__(self, start_page: int, data: bytes): + self.start_page = start_page + self.data = data + self.event = threading.Event() + self.result: dict[str, Any] | None = None + + class Runtime: def __init__(self): configs = cast(list[Configuration], get_entities_by_type(TYPE_CONFIGURATION)) @@ -42,11 +56,96 @@ def __init__(self): self.read_retries_left = [0] * len(self.rfid_readers) + # Pending NTAG write requests keyed by slot index. Populated via + # :meth:`submit_write` and drained inside :meth:`loop` so the write + # happens on the read thread (single-threaded RFID bus access). + self._pending_writes: dict[int, _PendingWrite] = {} + self._pending_write_lock = threading.Lock() + + # Most recent scan event per slot. Populated whenever exporters are + # notified so the Moonraker agent API can answer ``list_channels`` + # without re-scanning. + self.last_scans: dict[int, dict] = {} + def _notify_exporters(self, scan: ScanResult|None, filament: GenericFilament|None, reader: RfidReader, event: ExporterEvent): + slot = getattr(reader, "slot", None) + if slot is not None: + self.last_scans[int(slot)] = { + "event": event.value, + "ts": time.time(), + "slot": int(slot), + "reader": getattr(reader, "name", None), + "uid": scan.uid.hex().upper() if scan is not None else None, + "tag_type": scan.tag_type.name if scan is not None else None, + "filament": filament.to_dict() if filament is not None else None, + } + for exporter in self.exporters: if exporter.has_event(event): exporter.export_data(scan, filament, reader) + # ------------------------------------------------------------------ + # Pending NTAG writes (used by the OpenRFID agent API). + # ------------------------------------------------------------------ + def submit_write(self, slot: int, data: bytes, start_page: int = 4, timeout: float = 10.0) -> dict: + """Queue an NTAG write for ``slot`` and block until the read loop + services it (or ``timeout`` elapses). + + Returns a result dict with at least ``ok`` (bool) and ``status`` + (int|None — FM175XX status code) and optional ``error`` (str). + """ + if slot < 0 or slot >= len(self.rfid_readers): + return {"ok": False, "error": f"invalid slot {slot}"} + if not isinstance(data, (bytes, bytearray)): + return {"ok": False, "error": "data must be bytes"} + if len(data) == 0 or len(data) % 4 != 0: + return {"ok": False, "error": "data length must be a positive multiple of 4 bytes"} + + pending = _PendingWrite(start_page, bytes(data)) + with self._pending_write_lock: + self._pending_writes[slot] = pending + + # Use the existing scan-trigger path so auto/manual modes both wake. + self.start_reading_tag(slot) + + if not pending.event.wait(timeout=timeout): + with self._pending_write_lock: + self._pending_writes.pop(slot, None) + return {"ok": False, "error": "timeout waiting for write"} + + return pending.result or {"ok": False, "error": "no result"} + + def _drain_pending_write(self, slot: int, reader: RfidReader) -> bool: + """If a write is pending for ``slot``, perform it now and signal the + waiting submitter. Returns ``True`` when a write was handled (and the + normal read for this iteration should be skipped). + """ + with self._pending_write_lock: + pending = self._pending_writes.pop(slot, None) + if pending is None: + return False + + write = getattr(reader, "write_ntag_pages", None) + if not callable(write): + pending.result = {"ok": False, "error": "reader does not support NTAG writes"} + pending.event.set() + return True + + try: + status = write(pending.start_page, pending.data) + pending.result = { + "ok": status == 0, + "status": status, + "start_page": pending.start_page, + "bytes_written": len(pending.data), + } + except Exception as exc: + logging.exception("NTAG write failed on slot %d", slot) + pending.result = {"ok": False, "error": str(exc)} + finally: + pending.event.set() + return True + def start_reading_tag(self, slot: int): if slot < 0 or slot >= len(self.rfid_readers): logging.error(f"Invalid slot number: {slot}") @@ -58,6 +157,12 @@ def start_reading_tag(self, slot: int): def loop(self): while True: for i, reader in enumerate(self.rfid_readers): + # Pending writes take priority over reads — they have an + # impatient caller blocked on them. Skip the read this tick. + if self._drain_pending_write(i, reader): + self.read_retries_left[i] = 0 + continue + if not self.config.auto_read_mode and self.read_retries_left[i] <= 0: continue diff --git a/src/tag/tigertag/constants.py b/src/tag/tigertag/constants.py index 75cdd52..d2b7e13 100644 --- a/src/tag/tigertag/constants.py +++ b/src/tag/tigertag/constants.py @@ -22,6 +22,14 @@ OFF_TIMESTAMP = 32 OFF_TD = 44 OFF_METADATA = 48 +# The 32-byte metadata region holds an optional emoji glyph followed by a +# UTF-8 custom message. The emoji encoding is not well-defined across writers +# (and most writers omit it), so the parser exposes the message starting at +# OFF_METADATA and lets the application strip any decorative prefix. The +# spec caps the message at 28 bytes; the trailing 4 bytes of the metadata +# region are reserved. +OFF_MESSAGE = OFF_METADATA +MESSAGE_LENGTH = 28 OFF_SIGNATURE = 80 MIN_DATA_LENGTH = 96 diff --git a/src/tag/tigertag/encoder.py b/src/tag/tigertag/encoder.py new file mode 100644 index 0000000..c8b2b8d --- /dev/null +++ b/src/tag/tigertag/encoder.py @@ -0,0 +1,281 @@ +"""TigerTag 96-byte payload encoder. + +The encoder mirrors the byte layout consumed by :mod:`tag.tigertag.processor`. +Output is the 96-byte block written starting at NTAG215 user page 4 +(``Constants.USER_DATA_PAGE_OFFSET``), suitable for handing to +:meth:`reader.fm175xx.rfid.Fm175xx.write_ntag_pages`. + +The encoder loads label-to-id lookup tables from the bundled TigerTag JSON +database under ``tag/tigertag/database/`` (same directory used by +:mod:`tag.tigertag.registry`) so callers can pass either numeric ids or +human-readable labels for the registry-backed fields. +""" + +from __future__ import annotations + +import json +import threading +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from . import constants as Constants + + +_DB_DIRECTORY = Path(__file__).resolve().parent / "database" + +# label.lower() -> id, per registry kind +_LABEL_INDEX_CACHE: dict[str, dict[str, int]] | None = None +_LABEL_INDEX_LOCK = threading.Lock() + +_DB_FILES = { + "material": "id_material.json", + "brand": "id_brand.json", + "aspect": "id_aspect.json", + "diameter": "id_diameter.json", + "unit": "id_measure_unit.json", + "type": "id_type.json", +} + +# Pages 4..N inclusive: TigerTag user payload starts at user-data page 4. +USER_DATA_START_PAGE = Constants.USER_DATA_PAGE_OFFSET +PAYLOAD_LENGTH_BYTES = Constants.MIN_DATA_LENGTH + +# Tag IDs accepted by the parser; the encoder always emits the unsigned +# variant. Both values are accepted on read. +TIGERTAG_TAG_ID = 0xBC0FCB97 + + +def _load_db_file(name: str) -> list[dict]: + path = _DB_DIRECTORY / name + try: + with path.open("r", encoding="utf-8") as handle: + payload = json.load(handle) + except (OSError, json.JSONDecodeError): + return [] + if isinstance(payload, list): + return [item for item in payload if isinstance(item, dict)] + if isinstance(payload, dict): + for key in ("data", "items", "results", "response", "content"): + value = payload.get(key) + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + return [] + + +def _build_label_index(records: list[dict]) -> dict[str, int]: + out: dict[str, int] = {} + for record in records: + rid = record.get("id") + if rid is None: + continue + try: + rid_int = int(rid) + except (TypeError, ValueError): + continue + for key in ("label", "name", "title", "description", "symbol", "value"): + label = record.get(key) + if isinstance(label, (int, float)): + label = str(label) + if isinstance(label, str) and label.strip(): + out[label.strip().lower()] = rid_int + break + return out + + +def _label_indexes() -> dict[str, dict[str, int]]: + global _LABEL_INDEX_CACHE + with _LABEL_INDEX_LOCK: + if _LABEL_INDEX_CACHE is None: + _LABEL_INDEX_CACHE = { + kind: _build_label_index(_load_db_file(filename)) + for kind, filename in _DB_FILES.items() + } + return _LABEL_INDEX_CACHE + + +def _resolve_id(kind: str, value: Any, default: int = 0) -> int: + if value is None or value == "": + return default + if isinstance(value, bool): + return default + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + stripped = value.strip() + if not stripped: + return default + if stripped.isdigit(): + return int(stripped) + return _label_indexes().get(kind, {}).get(stripped.lower(), default) + return default + + +def _utf8_message_bytes(text: Any, max_bytes: int = Constants.MESSAGE_LENGTH) -> bytes: + """Encode ``text`` to UTF-8, truncated to ``max_bytes`` without splitting a + codepoint, right-padded with zeros.""" + if not text: + return b"\x00" * max_bytes + enc = str(text).encode("utf-8") + if len(enc) > max_bytes: + end = max_bytes + while end > 0 and (enc[end] & 0xC0) == 0x80: + end -= 1 + enc = enc[:end] + return enc + b"\x00" * (max_bytes - len(enc)) + + +def _hex_to_rgba(value: Any) -> tuple[int, int, int, int]: + """Parse ``#RRGGBB`` / ``RRGGBB`` / ``RRGGBBAA`` / ``[r,g,b,a?]`` to + ``(R, G, B, A)``. Default is opaque black.""" + default = (0, 0, 0, 0xFF) + if isinstance(value, (list, tuple)) and len(value) >= 3: + r = int(value[0]) & 0xFF + g = int(value[1]) & 0xFF + b = int(value[2]) & 0xFF + a = int(value[3]) & 0xFF if len(value) >= 4 else 0xFF + return (r, g, b, a) + if not isinstance(value, str): + return default + s = value.strip().lstrip("#") + if len(s) == 6: + try: + return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16), 0xFF) + except ValueError: + return default + if len(s) == 8: + try: + return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16), int(s[6:8], 16)) + except ValueError: + return default + return default + + +def _resolve_mfg_timestamp(mfg_raw: Any) -> int: + """Convert a manufacturing-date spec into a TigerTag timestamp (seconds + since 2000-01-01). Falls back to ``now`` on parse failure.""" + if isinstance(mfg_raw, bool): + return int(time.time()) - Constants.TIGERTAG_EPOCH_OFFSET + if isinstance(mfg_raw, (int, float)): + return int(mfg_raw) + if isinstance(mfg_raw, str) and mfg_raw.strip(): + s = mfg_raw.strip() + for fmt in ( + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%dT%H:%M", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d", + ): + try: + # Parse as naive UTC (matches parser, which uses UTC for the inverse). + dt = datetime.strptime(s[: len(fmt) + 4], fmt).replace(tzinfo=timezone.utc) + return int(dt.timestamp()) - Constants.TIGERTAG_EPOCH_OFFSET + except (ValueError, OverflowError): + continue + return int(time.time()) - Constants.TIGERTAG_EPOCH_OFFSET + + +def encode(spec: dict) -> bytes: + """Encode a TigerTag spec dict into a 96-byte user-data block. + + All multi-byte fields are big-endian. The signature region (bytes 80..95) + is left zeroed; the parser does not enforce a signature. + + Spec fields (all optional unless noted):: + + material: str label or numeric id (default 0) + brand: str label or numeric id (default 0) + aspect_1: str label or numeric id (default 0) + aspect_2: str label or numeric id (default 0) + type: str label or numeric id (default 0) + diameter: str label or numeric id (e.g. "1.75") + product_id: int 0..0xFFFFFFFF (default 0) + color: "#RRGGBB" / "RRGGBBAA" / [r,g,b,a] (default opaque black) + weight_g: int 0..16777215 (3-byte BE) + unit: str label or numeric id (default 0) + temp_min_c: int 0..65535 + temp_max_c: int 0..65535 + dry_temp_c: int 0..255 + dry_time_h: int 0..255 + bed_temp_min_c: int 0..255 + bed_temp_max_c: int 0..255 + td_mm: float 0..6553.5 (encoded as round(mm * 10)) + td: int 0..65535 (legacy raw, ignored if td_mm given) + manufacturing_date: ISO date/datetime str or epoch number + message: str up to 28 UTF-8 bytes (written at OFF_MESSAGE) + """ + if not isinstance(spec, dict): + spec = {} + + buf = bytearray(PAYLOAD_LENGTH_BYTES) + + # Header / IDs + buf[Constants.OFF_TAG_ID:Constants.OFF_TAG_ID + 4] = TIGERTAG_TAG_ID.to_bytes(4, "big") + pid = int(spec.get("product_id", 0) or 0) & 0xFFFFFFFF + buf[Constants.OFF_PRODUCT_ID:Constants.OFF_PRODUCT_ID + 4] = pid.to_bytes(4, "big") + mid = _resolve_id("material", spec.get("material")) + buf[Constants.OFF_MATERIAL_ID:Constants.OFF_MATERIAL_ID + 2] = (mid & 0xFFFF).to_bytes(2, "big") + buf[Constants.OFF_ASPECT1_ID] = _resolve_id("aspect", spec.get("aspect_1")) & 0xFF + buf[Constants.OFF_ASPECT2_ID] = _resolve_id("aspect", spec.get("aspect_2")) & 0xFF + buf[Constants.OFF_TYPE_ID] = _resolve_id("type", spec.get("type")) & 0xFF + buf[Constants.OFF_DIAMETER_ID] = _resolve_id("diameter", spec.get("diameter")) & 0xFF + bid = _resolve_id("brand", spec.get("brand")) + buf[Constants.OFF_BRAND_ID:Constants.OFF_BRAND_ID + 2] = (bid & 0xFFFF).to_bytes(2, "big") + + # Color (RGBA, written R,G,B,A) + r, g, b, a = _hex_to_rgba(spec.get("color")) + buf[Constants.OFF_COLOR_RGBA + 0] = r + buf[Constants.OFF_COLOR_RGBA + 1] = g + buf[Constants.OFF_COLOR_RGBA + 2] = b + buf[Constants.OFF_COLOR_RGBA + 3] = a + + # Weight (3-byte BE) + unit + weight = max(0, min(int(spec.get("weight_g", 0) or 0), 0xFFFFFF)) + buf[Constants.OFF_WEIGHT + 0] = (weight >> 16) & 0xFF + buf[Constants.OFF_WEIGHT + 1] = (weight >> 8) & 0xFF + buf[Constants.OFF_WEIGHT + 2] = weight & 0xFF + buf[Constants.OFF_UNIT_ID] = _resolve_id("unit", spec.get("unit")) & 0xFF + + # Temps + tmin = max(0, min(int(spec.get("temp_min_c", 0) or 0), 0xFFFF)) + tmax = max(0, min(int(spec.get("temp_max_c", 0) or 0), 0xFFFF)) + buf[Constants.OFF_TEMP_MIN:Constants.OFF_TEMP_MIN + 2] = tmin.to_bytes(2, "big") + buf[Constants.OFF_TEMP_MAX:Constants.OFF_TEMP_MAX + 2] = tmax.to_bytes(2, "big") + buf[Constants.OFF_DRY_TEMP] = max(0, min(int(spec.get("dry_temp_c", 0) or 0), 0xFF)) + buf[Constants.OFF_DRY_TIME] = max(0, min(int(spec.get("dry_time_h", 0) or 0), 0xFF)) + buf[Constants.OFF_BED_TEMP_MIN] = max(0, min(int(spec.get("bed_temp_min_c", 0) or 0), 0xFF)) + buf[Constants.OFF_BED_TEMP_MAX] = max(0, min(int(spec.get("bed_temp_max_c", 0) or 0), 0xFF)) + + # Manufacturing timestamp (seconds since 2000-01-01) + ts = max(0, min(_resolve_mfg_timestamp(spec.get("manufacturing_date")), 0xFFFFFFFF)) + buf[Constants.OFF_TIMESTAMP:Constants.OFF_TIMESTAMP + 4] = ts.to_bytes(4, "big") + + # bytes 36..43 reserved — leave zero + + # TD (transmission distance), big-endian uint16, value = mm * 10 + if spec.get("td_mm") is not None: + try: + td = int(round(float(spec.get("td_mm") or 0) * 10)) + except (TypeError, ValueError): + td = 0 + else: + td = int(spec.get("td", 0) or 0) + td = max(0, min(td, 0xFFFF)) + buf[Constants.OFF_TD:Constants.OFF_TD + 2] = td.to_bytes(2, "big") + + # bytes 46..47 reserved — leave zero + # OFF_MESSAGE = 48: 28-byte UTF-8 message + 4 reserved bytes (signature + # region starts at OFF_SIGNATURE = 80 and is left zeroed). + buf[Constants.OFF_MESSAGE:Constants.OFF_MESSAGE + Constants.MESSAGE_LENGTH] = _utf8_message_bytes( + spec.get("message"), max_bytes=Constants.MESSAGE_LENGTH + ) + + return bytes(buf) + + +def encode_blank() -> bytes: + """Return a 96-byte payload of zeros (used to erase NTAG215 user pages).""" + return b"\x00" * PAYLOAD_LENGTH_BYTES diff --git a/src/tag/tigertag/processor.py b/src/tag/tigertag/processor.py index 975670b..bf67511 100644 --- a/src/tag/tigertag/processor.py +++ b/src/tag/tigertag/processor.py @@ -68,6 +68,19 @@ def __parse_tigertag(self, scan_result: ScanResult, user_data: bytes, tag_id: in timestamp_raw = struct.unpack_from('>I', user_data, Constants.OFF_TIMESTAMP)[0] + # Custom message: the 32-byte metadata block is exposed verbatim as + # a UTF-8 string. The TigerTag spec reserves the first few bytes + # for an optional emoji glyph, but the encoding is not well-defined + # across writers (and most writers omit it), so we hand the whole + # region to the application untouched. + message = "" + if len(user_data) >= Constants.OFF_MESSAGE + Constants.MESSAGE_LENGTH: + raw_msg = user_data[Constants.OFF_MESSAGE:Constants.OFF_MESSAGE + Constants.MESSAGE_LENGTH] + try: + message = raw_msg.rstrip(b'\x00').decode('utf-8', errors='replace').strip() + except Exception: + message = "" + material_label = self.registry.material_ids.get(material_id, f"Unknown({material_id})") material_type = self.registry.material_type_ids.get(material_id, material_label) filled_type = self.registry.filled_type_ids.get(material_id, "") @@ -99,6 +112,7 @@ def __parse_tigertag(self, scan_result: ScanResult, user_data: bytes, tag_id: in self.logger.debug(" Dry: %d °C for %d hours", dry_temp, dry_time) self.logger.debug(" Bed Temp: %d-%d °C", bed_temp_min, bed_temp_max) self.logger.debug(" TD: raw=%d → %.1f mm", td_raw, td_mm) + self.logger.debug(" Message: %r", message) self.logger.debug(" Timestamp: %d (%s)", timestamp_raw, manufacturing_date) return GenericFilament( @@ -115,11 +129,13 @@ def __parse_tigertag(self, scan_result: ScanResult, user_data: bytes, tag_id: in weight_grams=weight_grams, hotend_min_temp_c=float(temp_min), hotend_max_temp_c=float(temp_max), - bed_temp_c=0.0, + bed_temp_c=float(bed_temp_min), + bed_temp_max_c=float(bed_temp_max), drying_temp_c=float(dry_temp), drying_time_hours=float(dry_time), manufacturing_date=manufacturing_date, td=td_mm, + message=message, ) except Exception as e: diff --git a/test/tags/Tigertag/Elegoo PLA Basic Grey.yml b/test/tags/Tigertag/Elegoo PLA Basic Grey.yml index 08bade3..537e900 100644 --- a/test/tags/Tigertag/Elegoo PLA Basic Grey.yml +++ b/test/tags/Tigertag/Elegoo PLA Basic Grey.yml @@ -10,8 +10,10 @@ diameter_mm: 1.75 weight_grams: 1000.0 hotend_min_temp_c: 190.0 hotend_max_temp_c: 240.0 -bed_temp_c: 0.0 +bed_temp_c: 45.0 drying_temp_c: 50.0 drying_time_hours: 8.0 -manufacturing_date: "2026-04-03" -td: 0.0 \ No newline at end of file +manufacturing_date: '2026-04-03' +td: 0.0 +bed_temp_max_c: 55.0 +message: '' diff --git a/test/tags/Tigertag/R3D PETG HS Carbon Fiber Black TigerTag+.yml b/test/tags/Tigertag/R3D PETG HS Carbon Fiber Black TigerTag+.yml index 6cba9f9..57e921e 100644 --- a/test/tags/Tigertag/R3D PETG HS Carbon Fiber Black TigerTag+.yml +++ b/test/tags/Tigertag/R3D PETG HS Carbon Fiber Black TigerTag+.yml @@ -1,5 +1,4 @@ - -bed_temp_c: 0.0 +bed_temp_c: 70.0 colors: - 0xFF141618 diameter_mm: 1.75 @@ -16,3 +15,5 @@ td: 0.0 type: PETG-CF unique_id: f97ed8a8deccab7724f6b498c41a0b18c8e9250307b5f332a34055e7308f47a4 weight_grams: 1000.0 +bed_temp_max_c: 90.0 +message: '' diff --git a/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Cyan TigerTag+.yml b/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Cyan TigerTag+.yml index 088b3fc..0b7d6cc 100644 --- a/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Cyan TigerTag+.yml +++ b/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Cyan TigerTag+.yml @@ -1,4 +1,4 @@ -bed_temp_c: 0.0 +bed_temp_c: 40.0 colors: - 0xFF00A3E0 diameter_mm: 1.75 @@ -15,3 +15,5 @@ td: 0.0 type: PLA unique_id: f90c55783e801ae94761b4ac89be2c9699de24bd1f946b9ee523c5d58b325a04 weight_grams: 1000.0 +bed_temp_max_c: 60.0 +message: '' diff --git a/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Magenta TigerTag+.yml b/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Magenta TigerTag+.yml index 1c1804b..8e8cbdc 100644 --- a/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Magenta TigerTag+.yml +++ b/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Magenta TigerTag+.yml @@ -1,4 +1,4 @@ -bed_temp_c: 0.0 +bed_temp_c: 40.0 colors: - 0xFFC6007E diameter_mm: 1.75 @@ -15,3 +15,5 @@ td: 0.0 type: PLA unique_id: f2773567e0bd0ae8d2dac1dc8a9565f5bb4ee497572304c84e3b0b70bdb16b4e weight_grams: 1000.0 +bed_temp_max_c: 60.0 +message: '' diff --git a/test/tags/Tigertag/Rosa3D PLA Starter Lithophane White TigerTag+.yml b/test/tags/Tigertag/Rosa3D PLA Starter Lithophane White TigerTag+.yml index 880aa82..07e21fd 100644 --- a/test/tags/Tigertag/Rosa3D PLA Starter Lithophane White TigerTag+.yml +++ b/test/tags/Tigertag/Rosa3D PLA Starter Lithophane White TigerTag+.yml @@ -1,4 +1,4 @@ -bed_temp_c: 0.0 +bed_temp_c: 40.0 colors: - 0xFFFAFAFA diameter_mm: 1.75 @@ -15,3 +15,5 @@ td: 0.0 type: PLA unique_id: ca98c88138a8c8f300f07c240dc834677e59656f983b34b8c9c8d8c641bbdec1 weight_grams: 1000.0 +bed_temp_max_c: 60.0 +message: '' diff --git a/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Yellow TigerTag+.yml b/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Yellow TigerTag+.yml index 3a469f4..4358158 100644 --- a/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Yellow TigerTag+.yml +++ b/test/tags/Tigertag/Rosa3D PLA Starter Lithophane Yellow TigerTag+.yml @@ -1,4 +1,4 @@ -bed_temp_c: 0.0 +bed_temp_c: 40.0 colors: - 0xFFF6EB61 diameter_mm: 1.75 @@ -15,3 +15,5 @@ td: 0.0 type: PLA unique_id: e51a87ddc3e239f0a2bb81c370d1c99d8dbf2ef9b2e6506fa0d908a640cab0f2 weight_grams: 1000.0 +bed_temp_max_c: 60.0 +message: '' diff --git a/test/tags/Tigertag/Sunlu PLA Roasted Chestnut TigerTag+.yml b/test/tags/Tigertag/Sunlu PLA Roasted Chestnut TigerTag+.yml index 2043ee4..c04d6c3 100644 --- a/test/tags/Tigertag/Sunlu PLA Roasted Chestnut TigerTag+.yml +++ b/test/tags/Tigertag/Sunlu PLA Roasted Chestnut TigerTag+.yml @@ -1,4 +1,4 @@ -bed_temp_c: 0.0 +bed_temp_c: 50.0 colors: - 0xFF3E2B2E diameter_mm: 1.75 @@ -15,3 +15,5 @@ td: 0.0 type: PLA unique_id: b537b06ab1f153b9176caa3cb12492d87094703848da8e682f242c052bdf940f weight_grams: 1000.0 +bed_temp_max_c: 60.0 +message: '' diff --git a/test/tags/Tigertag/eSun PLA Basic Refill Cold White TigerTag+.yml b/test/tags/Tigertag/eSun PLA Basic Refill Cold White TigerTag+.yml index fa50170..4387c7d 100644 --- a/test/tags/Tigertag/eSun PLA Basic Refill Cold White TigerTag+.yml +++ b/test/tags/Tigertag/eSun PLA Basic Refill Cold White TigerTag+.yml @@ -1,4 +1,4 @@ -bed_temp_c: 0.0 +bed_temp_c: 45.0 colors: - 0xFFDDE5ED diameter_mm: 1.75 @@ -15,3 +15,5 @@ td: 0.0 type: PLA unique_id: c97d79c4ec83c89e5063834556625468b602e492996bfd674fbbeb238469d031 weight_grams: 1000.0 +bed_temp_max_c: 65.0 +message: '' diff --git a/test/test_runtime_pending_write.py b/test/test_runtime_pending_write.py new file mode 100644 index 0000000..ea36e7f --- /dev/null +++ b/test/test_runtime_pending_write.py @@ -0,0 +1,160 @@ +"""Tests for the Runtime NTAG pending-write queue. + +The runtime serialises NTAG writes onto its read loop so the RFID bus is only +touched from one thread. ``submit_write`` blocks the caller on a +``threading.Event`` until the loop drains the request via +``_drain_pending_write``. These tests exercise that handshake without spinning +the full ``loop()`` (which is a ``while True``). +""" + +from __future__ import annotations + +import sys +import threading +import time +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = ROOT / "src" +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from config import register_configurable_entity +from config.config_manager import LOADED_MODULES +from config.configuration import default_configuration +from runtime import Runtime + + +class _FakeReader: + """Minimal stand-in for an ``RfidReader`` with NTAG write support.""" + + def __init__(self, slot: int = 0, write_status: int = 0): + self.name = f"fake_slot_{slot}" + self.slot = slot + self.last_read_uid = None + self._write_status = write_status + self.calls: list[tuple[int, bytes]] = [] + + def write_ntag_pages(self, start_page: int, data: bytes) -> int: + self.calls.append((start_page, bytes(data))) + return self._write_status + + +class _FakeReaderNoWrite: + def __init__(self, slot: int = 0): + self.name = f"fake_slot_{slot}" + self.slot = slot + self.last_read_uid = None + + +@pytest.fixture(autouse=True) +def _reset_entities(): + LOADED_MODULES.clear() + register_configurable_entity(default_configuration()) + yield + LOADED_MODULES.clear() + + +def _build_runtime_with(reader) -> Runtime: + runtime = Runtime() + runtime.rfid_readers = [reader] + runtime.read_retries_left = [0] + return runtime + + +def test_submit_write_returns_ok_when_drained() -> None: + reader = _FakeReader(slot=0, write_status=0) + runtime = _build_runtime_with(reader) + + payload = b"\xDE\xAD\xBE\xEF" * 24 # 96 bytes (TigerTag-sized) + + result_box: dict = {} + + def _submit(): + result_box["result"] = runtime.submit_write(0, payload, start_page=4, timeout=2.0) + + submitter = threading.Thread(target=_submit, daemon=True) + submitter.start() + + # Wait until the request is registered, then drain it from the "loop". + deadline = time.time() + 1.0 + while time.time() < deadline: + if 0 in runtime._pending_writes: + break + time.sleep(0.01) + assert 0 in runtime._pending_writes, "submit_write did not register pending write" + + handled = runtime._drain_pending_write(0, reader) + assert handled is True + + submitter.join(timeout=2.0) + assert not submitter.is_alive(), "submitter did not unblock" + + result = result_box["result"] + assert result["ok"] is True + assert result["status"] == 0 + assert result["start_page"] == 4 + assert result["bytes_written"] == 96 + + assert reader.calls == [(4, payload)] + + +def test_submit_write_rejects_invalid_slot() -> None: + reader = _FakeReader(slot=0) + runtime = _build_runtime_with(reader) + result = runtime.submit_write(5, b"\x00" * 4) + assert result["ok"] is False + assert "invalid slot" in result["error"] + + +def test_submit_write_rejects_misaligned_data() -> None: + reader = _FakeReader(slot=0) + runtime = _build_runtime_with(reader) + assert runtime.submit_write(0, b"\x00" * 5)["ok"] is False + assert runtime.submit_write(0, b"")["ok"] is False + + +def test_submit_write_times_out_when_loop_idle() -> None: + reader = _FakeReader(slot=0) + runtime = _build_runtime_with(reader) + + result = runtime.submit_write(0, b"\x00" * 4, timeout=0.1) + assert result["ok"] is False + assert "timeout" in result["error"] + # Pending entry must be cleared on timeout so next submit can register. + assert 0 not in runtime._pending_writes + + +def test_drain_returns_false_when_no_pending() -> None: + reader = _FakeReader(slot=0) + runtime = _build_runtime_with(reader) + assert runtime._drain_pending_write(0, reader) is False + + +def test_drain_handles_reader_without_write_support() -> None: + reader = _FakeReaderNoWrite(slot=0) + runtime = _build_runtime_with(reader) + + result_box: dict = {} + + def _submit(): + result_box["result"] = runtime.submit_write(0, b"\x00" * 4, timeout=2.0) + + submitter = threading.Thread(target=_submit, daemon=True) + submitter.start() + + deadline = time.time() + 1.0 + while time.time() < deadline: + if 0 in runtime._pending_writes: + break + time.sleep(0.01) + + handled = runtime._drain_pending_write(0, reader) + assert handled is True + + submitter.join(timeout=2.0) + result = result_box["result"] + assert result["ok"] is False + assert "does not support" in result["error"] diff --git a/test/test_tigertag_encoder.py b/test/test_tigertag_encoder.py new file mode 100644 index 0000000..17ab39c --- /dev/null +++ b/test/test_tigertag_encoder.py @@ -0,0 +1,124 @@ +"""Round-trip tests for the TigerTag encoder. + +Verifies that ``encode(spec)`` produces a 96-byte payload that the existing +:class:`TigerTagProcessor` can parse back into a :class:`GenericFilament` whose +fields match the spec. +""" + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = ROOT / "src" + +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from reader.scan_result import ScanResult +from tag.tag_types import TagType +from tag.tigertag import constants as Constants +from tag.tigertag.encoder import ( + PAYLOAD_LENGTH_BYTES, + TIGERTAG_TAG_ID, + USER_DATA_START_PAGE, + encode, + encode_blank, +) +from tag.tigertag.processor import TigerTagProcessor + + +def _build_processor() -> TigerTagProcessor: + processor = TigerTagProcessor({"__name": "TigerTagProcessor"}) + processor.enabled = True + return processor + + +def _build_scan_result() -> ScanResult: + return ScanResult( + tag_type=TagType.MifareUltralight, + uid=b"\x01\x02\x03\x04", + atqa=b"\x00\x44", + bcc=b"\x00", + sak=b"\x00", + ) + + +def _wrap_user_payload(payload: bytes) -> bytes: + """Prepend ``USER_DATA_BYTE_OFFSET`` zero bytes so the parser sees the + payload at user-data page 4 (the parser slices past the first + ``USER_DATA_BYTE_OFFSET`` bytes).""" + return b"\x00" * Constants.USER_DATA_BYTE_OFFSET + payload + + +def test_encoder_payload_length_and_start_page() -> None: + assert PAYLOAD_LENGTH_BYTES == 96 + assert USER_DATA_START_PAGE == 4 + assert len(encode({})) == PAYLOAD_LENGTH_BYTES + assert encode_blank() == b"\x00" * PAYLOAD_LENGTH_BYTES + + +def test_encoder_emits_tag_id_header() -> None: + payload = encode({}) + assert int.from_bytes(payload[0:4], "big") == TIGERTAG_TAG_ID + + +def test_encoder_round_trip_minimal_pla_spec() -> None: + spec = { + "material": "PLA", + "brand": "ELEGOO", + "diameter": "1.75", + "color": "#BCBCBC", + "weight_g": 1000, + "unit": "g", + "temp_min_c": 190, + "temp_max_c": 240, + "dry_temp_c": 50, + "dry_time_h": 8, + "bed_temp_min_c": 45, + "bed_temp_max_c": 55, + "td_mm": 0.0, + "manufacturing_date": "2026-04-03", + "message": "Hello, world!", + } + + payload = encode(spec) + assert len(payload) == PAYLOAD_LENGTH_BYTES + + filament = _build_processor().process_tag(_build_scan_result(), _wrap_user_payload(payload)) + assert filament is not None + + assert filament.diameter_mm == pytest.approx(1.75) + assert filament.weight_grams == pytest.approx(1000.0) + assert filament.hotend_min_temp_c == pytest.approx(190.0) + assert filament.hotend_max_temp_c == pytest.approx(240.0) + assert filament.bed_temp_c == pytest.approx(45.0) + assert filament.bed_temp_max_c == pytest.approx(55.0) + assert filament.drying_temp_c == pytest.approx(50.0) + assert filament.drying_time_hours == pytest.approx(8.0) + assert filament.manufacturing_date == "2026-04-03" + assert filament.message == "Hello, world!" + + # Color: encoder takes "#RRGGBB" (opaque). Parser stores ARGB, alpha=0xFF. + assert filament.colors == [(0xFF << 24) | (0xBC << 16) | (0xBC << 8) | 0xBC] + + +def test_encoder_message_truncation_and_padding() -> None: + long_msg = "x" * 100 + payload = encode({"message": long_msg}) + msg_region = payload[Constants.OFF_MESSAGE:Constants.OFF_MESSAGE + Constants.MESSAGE_LENGTH] + assert msg_region == b"x" * Constants.MESSAGE_LENGTH + + short_payload = encode({"message": "ab"}) + short_region = short_payload[Constants.OFF_MESSAGE:Constants.OFF_MESSAGE + Constants.MESSAGE_LENGTH] + assert short_region == b"ab" + b"\x00" * (Constants.MESSAGE_LENGTH - 2) + + +def test_encoder_message_does_not_split_utf8_codepoint() -> None: + # 4-byte UTF-8 emoji repeated; should truncate at a codepoint boundary. + payload = encode({"message": "\U0001F600" * 10}) # 😀 × 10 = 40 bytes + msg_region = payload[Constants.OFF_MESSAGE:Constants.OFF_MESSAGE + Constants.MESSAGE_LENGTH] + # Decoding the truncated region must succeed without errors. + decoded = msg_region.rstrip(b"\x00").decode("utf-8") + assert decoded == "\U0001F600" * 7 # 28 bytes / 4 bytes per emoji = 7 From 153c2bb4790246c708b709f1cd08e1b17e453c7e Mon Sep 17 00:00:00 2001 From: Gordian Zomer Date: Mon, 11 May 2026 17:40:21 +0200 Subject: [PATCH 4/8] Update event handling to comply with Moonraker's agent protocol --- src/controllers/openrfid_api.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/controllers/openrfid_api.py b/src/controllers/openrfid_api.py index 47a2583..a01c75e 100644 --- a/src/controllers/openrfid_api.py +++ b/src/controllers/openrfid_api.py @@ -165,11 +165,17 @@ def _broadcast_loop(self): if event is None: continue try: + # Per Moonraker's agent protocol, agents emit events via + # `connection.send_event` with `{event, data}`. Moonraker + # stamps the agent name from the connection identity and + # rebroadcasts to subscribers as `notify_agent_event` + # notifications. Sending `notify_agent_event` directly + # is treated as an RPC call and rejected with -32601. + # https://moonraker.readthedocs.io/en/latest/external_api/extensions/#send-an-agent-event self.send_message({ "jsonrpc": "2.0", - "method": "notify_agent_event", + "method": "connection.send_event", "params": { - "agent": AGENT_NAME, "event": "openrfid/scan", "data": event, }, From 251c64c85391aa4425a786f0b8c599abafe4c469 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:00:55 +0000 Subject: [PATCH 5/8] Address 5 review comments: write conflict, None status, verbatim message, scan_slot validation, ISO-8601 timestamp --- src/controllers/openrfid_api.py | 2 ++ src/runtime.py | 17 +++++++++++------ src/tag/tigertag/encoder.py | 10 ++++++++++ src/tag/tigertag/processor.py | 2 +- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/controllers/openrfid_api.py b/src/controllers/openrfid_api.py index a01c75e..3efcfa8 100644 --- a/src/controllers/openrfid_api.py +++ b/src/controllers/openrfid_api.py @@ -199,6 +199,8 @@ def _handle_list_channels(self, _params: dict) -> dict: def _handle_scan_slot(self, params: dict) -> dict: slot = self._require_slot(params) + if slot < 0 or slot >= len(self.runtime.rfid_readers): + return {"ok": False, "error": f"invalid slot {slot}"} self.runtime.start_reading_tag(slot) return {"ok": True, "slot": slot} diff --git a/src/runtime.py b/src/runtime.py index ed64c73..eb59428 100644 --- a/src/runtime.py +++ b/src/runtime.py @@ -103,6 +103,8 @@ def submit_write(self, slot: int, data: bytes, start_page: int = 4, timeout: flo pending = _PendingWrite(start_page, bytes(data)) with self._pending_write_lock: + if slot in self._pending_writes: + return {"ok": False, "error": f"write already pending for slot {slot}"} self._pending_writes[slot] = pending # Use the existing scan-trigger path so auto/manual modes both wake. @@ -133,12 +135,15 @@ def _drain_pending_write(self, slot: int, reader: RfidReader) -> bool: try: status = write(pending.start_page, pending.data) - pending.result = { - "ok": status == 0, - "status": status, - "start_page": pending.start_page, - "bytes_written": len(pending.data), - } + if status is None: + pending.result = {"ok": False, "error": "reader does not support NTAG writes"} + else: + pending.result = { + "ok": status == 0, + "status": status, + "start_page": pending.start_page, + "bytes_written": len(pending.data), + } except Exception as exc: logging.exception("NTAG write failed on slot %d", slot) pending.result = {"ok": False, "error": str(exc)} diff --git a/src/tag/tigertag/encoder.py b/src/tag/tigertag/encoder.py index c8b2b8d..4af91a4 100644 --- a/src/tag/tigertag/encoder.py +++ b/src/tag/tigertag/encoder.py @@ -163,6 +163,16 @@ def _resolve_mfg_timestamp(mfg_raw: Any) -> int: return int(mfg_raw) if isinstance(mfg_raw, str) and mfg_raw.strip(): s = mfg_raw.strip() + # Try fromisoformat first; replace trailing 'Z' with '+00:00' since + # Python < 3.11 does not parse 'Z' as UTC natively. + iso_s = s[:-1] + "+00:00" if s.endswith("Z") else s + try: + dt = datetime.fromisoformat(iso_s) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return int(dt.timestamp()) - Constants.TIGERTAG_EPOCH_OFFSET + except (ValueError, OverflowError): + pass for fmt in ( "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", diff --git a/src/tag/tigertag/processor.py b/src/tag/tigertag/processor.py index bf67511..ef4994d 100644 --- a/src/tag/tigertag/processor.py +++ b/src/tag/tigertag/processor.py @@ -77,7 +77,7 @@ def __parse_tigertag(self, scan_result: ScanResult, user_data: bytes, tag_id: in if len(user_data) >= Constants.OFF_MESSAGE + Constants.MESSAGE_LENGTH: raw_msg = user_data[Constants.OFF_MESSAGE:Constants.OFF_MESSAGE + Constants.MESSAGE_LENGTH] try: - message = raw_msg.rstrip(b'\x00').decode('utf-8', errors='replace').strip() + message = raw_msg.rstrip(b'\x00').decode('utf-8', errors='replace') except Exception: message = "" From 1ffc25251fca45f7da1a74ca735d369caa20dcdf Mon Sep 17 00:00:00 2001 From: physicsG Date: Fri, 31 Jul 2026 13:19:36 +0200 Subject: [PATCH 6/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/controllers/openrfid_api.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/controllers/openrfid_api.py b/src/controllers/openrfid_api.py index 3efcfa8..b9d5992 100644 --- a/src/controllers/openrfid_api.py +++ b/src/controllers/openrfid_api.py @@ -50,6 +50,7 @@ def __init__(self, config: dict): self._exporter: OpenrfidAgentEventExporter | None = None self._broadcast_thread: threading.Thread | None = None self._stop_broadcast = threading.Event() + self._send_lock = threading.Lock() self._handlers: dict[str, Callable[[dict], Any]] = { REMOTE_METHOD_LIST_CHANNELS: self._handle_list_channels, @@ -59,6 +60,10 @@ def __init__(self, config: dict): REMOTE_METHOD_TIGERTAG_ENCODE: self._handle_tigertag_encode, } + def send_message(self, message: Any): + with self._send_lock: + super().send_message(message) + # ------------------------------------------------------------------ # Wiring helpers # ------------------------------------------------------------------ From 68fec6786bbe336bcc1fdb8c3d826a23a5c27412 Mon Sep 17 00:00:00 2001 From: Physics G Date: Fri, 31 Jul 2026 18:03:33 +0200 Subject: [PATCH 7/8] Add unit tests for TigerTag Maker functionality and enhance tag processing - Introduced comprehensive unit tests for the FM175XX guarded TigerTag Maker transaction in `test_safe_tigertag_writer.py`, covering various scenarios including header invalidation, tag recognition, and error handling. - Updated `test_tags.py` to include expected tag formats for various processors, ensuring consistency in tag processing. - Refactored `test_tigertag_encoder.py` to improve clarity and coverage of encoding and decoding functionalities, including validation of maker payloads and handling of legacy formats. - Added tests for message limits, color handling, and quantity units to ensure robust processing of filament specifications. --- examples/u1.cfg | 15 +- requirements.txt | 3 +- src/SAFE_TIGERTAG_WRITER_V2 | 5 + src/controllers/moonraker_controller.py | 118 ++++- src/controllers/openrfid_api.py | 471 ++++++++++++++-- src/filament/generic.py | 96 +++- src/main.py | 14 + src/reader/fm175xx/rfid.py | 480 ++++++++++++++++- src/reader/gpio_enabled_rfid_reader.py | 61 ++- src/reader/mifare_ultralight_reader.py | 53 +- src/runtime.py | 635 ++++++++++++++++++++-- src/tag/anycubic/processor.py | 5 +- src/tag/bambu/processor.py | 5 +- src/tag/creality/processor.py | 5 +- src/tag/elegoo/processor.py | 5 +- src/tag/openspool/processor.py | 5 +- src/tag/qidi/processor.py | 5 +- src/tag/snapmaker/processor.py | 5 +- src/tag/spoolease/processor.py | 3 + src/tag/tag_processor.py | 6 +- src/tag/tigertag/constants.py | 7 +- src/tag/tigertag/encoder.py | 678 +++++++++++++++--------- src/tag/tigertag/processor.py | 420 ++++++++++----- test/test_main_shutdown.py | 49 ++ test/test_openrfid_api.py | 481 +++++++++++++++++ test/test_runtime_pending_write.py | 636 +++++++++++++++++++--- test/test_safe_tigertag_writer.py | 438 +++++++++++++++ test/test_tags.py | 31 ++ test/test_tigertag_encoder.py | 366 +++++++++++-- 29 files changed, 4422 insertions(+), 679 deletions(-) create mode 100644 src/SAFE_TIGERTAG_WRITER_V2 create mode 100644 test/test_main_shutdown.py create mode 100644 test/test_openrfid_api.py create mode 100644 test/test_safe_tigertag_writer.py diff --git a/examples/u1.cfg b/examples/u1.cfg index e756924..1d4cfd7 100644 --- a/examples/u1.cfg +++ b/examples/u1.cfg @@ -125,12 +125,23 @@ act_on_value = 1 # Optional: expose OpenRFID over Moonraker as a JSON-RPC agent so a Mainsail/ # Fluidd-style web UI can list channels, trigger scans, and (optionally) write # tags. Pair with [openrfid_agent_event_exporter] below to also receive -# notify_agent_event broadcasts on every scan. +# notify_agent_event broadcasts on every scan. The writer accepts only an +# SDK-valid 80-byte TigerTag Maker record for pages 4..23, requires the exact +# expected NTAG UID, checks locks/capacity/printer state, and verifies reads. +# A timed-out running request is recovered by operation_id through +# openrfid/operation_status; callers must never retry it blindly. #[openrfid_api] #moonraker_socket_path = /oem/printer_data/comms/moonraker.sock ## Set enable_write = true to allow openrfid/write_tag and openrfid/clear_tag. ## Leave false (the default) for read-only deployments. #enable_write = false +## Independent server opt-in plus per-request allow_unrecognized=true are both +## required to initialize a blank or non-TigerTag NTAG. +#allow_unrecognized_write = false +## Independent server opt-in plus per-request allow_legacy_migration=true are +## both required to replace the exact retired OpenRFID v1 fingerprint. This +## never makes ordinary signed TigerTag+ records writable. +#allow_legacy_migration_write = false #[openrfid_agent_event_exporter] ## Defaults to all three events (tag_read, tag_parse_error, tag_not_present). @@ -140,4 +151,4 @@ act_on_value = 1 [configuration] auto_read_mode = false retries = 20 -read_interval_seconds = 0.2 \ No newline at end of file +read_interval_seconds = 0.2 diff --git a/requirements.txt b/requirements.txt index 3393775..abb9243 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ requests jinja2 spidev cryptography +tigertag[verify] @ git+https://github.com/TigerTag-Project/TigerTag-SDK-Python.git@f3e2e2e8a1fdf88f91fb43ca4b5c5fbfb88f81af pytest PyYAML -# libgpiod 1.6.4 \ No newline at end of file +# libgpiod 1.6.4 diff --git a/src/SAFE_TIGERTAG_WRITER_V2 b/src/SAFE_TIGERTAG_WRITER_V2 new file mode 100644 index 0000000..873b1d2 --- /dev/null +++ b/src/SAFE_TIGERTAG_WRITER_V2 @@ -0,0 +1,5 @@ +Safe TigerTag Maker writer contract v2 + +This marker is installed only by OpenRFID revisions containing the fixed +80-byte, UID-bound, lock-preflighted, read-back-verified writer and guarded +Moonraker API. Firmware integrations must keep writes disabled when absent. diff --git a/src/controllers/moonraker_controller.py b/src/controllers/moonraker_controller.py index 63bed4d..368ced4 100644 --- a/src/controllers/moonraker_controller.py +++ b/src/controllers/moonraker_controller.py @@ -4,6 +4,7 @@ import json from typing import Any import os +import threading import time class MoonrakerController(Controller): @@ -12,35 +13,78 @@ def __init__(self, config: dict): self.moonraker_socket_path = str(config["moonraker_socket_path"]) self.socket : socket.socket|None = None self.buffer = b"" + # Agent event pumps and guarded write handlers may respond from worker + # threads. Keep JSON frames contiguous on the shared Unix socket. + self._send_lock = threading.Lock() + self._connection_generation = 0 + self._connection_connected = False + + def current_connection_generation(self) -> int | None: + """Return the active socket generation, or ``None`` while offline.""" + with self._send_lock: + if not self._connection_connected or self.socket is None: + return None + return self._connection_generation + + def is_connection_current(self, generation: int | None) -> bool: + """Whether *generation* still identifies the connected socket.""" + if generation is None: + return False + with self._send_lock: + return ( + self._connection_connected + and self.socket is not None + and self._connection_generation == generation + ) def __loop_inner(self): - if self.socket is not None: - self.socket.close() - - self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) if not os.path.exists(self.moonraker_socket_path): self.logger.warning(f"Moonraker socket not found at {self.moonraker_socket_path}") return - self.socket.connect(self.moonraker_socket_path) - time.sleep(0.1) - self.on_connect() - - while True: - data = self.socket.recv(4096) - if not data: - raise Exception("Moonraker socket connection closed") - - self.buffer += data + connected_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + connected = False + try: + connected_socket.connect(self.moonraker_socket_path) + with self._send_lock: + old_socket = self.socket + self.socket = connected_socket + self.buffer = b"" + self._connection_generation += 1 + self._connection_connected = True + connected = True + if old_socket is not None and old_socket is not connected_socket: + old_socket.close() + + time.sleep(0.1) + self.on_connect() + + while True: + data = connected_socket.recv(4096) + if not data: + raise Exception("Moonraker socket connection closed") - while b'\x03' in self.buffer: - message_data, self.buffer = self.buffer.split(b'\x03', 1) - try: - message = json.loads(message_data.decode("utf-8").strip()) - self.on_message(message) - except json.JSONDecodeError as e: - self.logger.error(f"Failed to decode JSON message: {e}") - continue + self.buffer += data + + while b'\x03' in self.buffer: + message_data, self.buffer = self.buffer.split(b'\x03', 1) + try: + message = json.loads(message_data.decode("utf-8").strip()) + self.on_message(message) + except json.JSONDecodeError as e: + self.logger.error(f"Failed to decode JSON message: {e}") + continue + finally: + with self._send_lock: + if self.socket is connected_socket: + self.socket = None + self._connection_connected = False + self.buffer = b"" + try: + connected_socket.close() + finally: + if connected: + self.on_disconnect() def loop(self): while True: @@ -52,15 +96,39 @@ def loop(self): self.logger.info("Retrying connection in 5 seconds...") time.sleep(5) - def send_message(self, message: Any): + def send_message( + self, + message: Any, + expected_generation: int | None = None, + ) -> bool: + """Send one frame, optionally only on the request's original socket. + + Returning ``False`` for a stale generation lets worker threads discard + replies after a reconnect instead of attaching an old JSON-RPC id to a + new Moonraker connection. + """ message_str = json.dumps(message) - self.socket.sendall(message_str.encode("utf-8") + b'\x03') + payload = message_str.encode("utf-8") + b'\x03' + with self._send_lock: + if expected_generation is not None and ( + not self._connection_connected + or self._connection_generation != expected_generation + ): + return False + if self.socket is None: + raise RuntimeError("Moonraker socket is not connected") + self.socket.sendall(payload) + return True def on_connect(self): """Called when the socket connection is established.""" pass + def on_disconnect(self): + """Called immediately after the active socket is disconnected.""" + pass + @abstractmethod def on_message(self, message: Any): """Handle a message received from the Moonraker socket.""" - raise NotImplementedError("Subclasses must implement this method") \ No newline at end of file + raise NotImplementedError("Subclasses must implement this method") diff --git a/src/controllers/openrfid_api.py b/src/controllers/openrfid_api.py index a01c75e..21286fc 100644 --- a/src/controllers/openrfid_api.py +++ b/src/controllers/openrfid_api.py @@ -3,7 +3,8 @@ Registers a Moonraker *agent* named ``openrfid`` that: * exposes the remote methods ``openrfid/list_channels``, ``openrfid/scan_slot``, - ``openrfid/write_tag``, ``openrfid/clear_tag`` and ``openrfid/tigertag_encode``, + ``openrfid/write_tag``, ``openrfid/clear_tag``, ``openrfid/tigertag_encode`` + ``openrfid/tigertag_options``, and ``openrfid/operation_status``, * broadcasts ``notify_agent_event`` notifications (event name ``openrfid/scan``) for every scan event picked up from a configured :class:`exporters.openrfid_agent_event.OpenrfidAgentEventExporter`. @@ -15,6 +16,7 @@ from __future__ import annotations +import math import threading import time from typing import Any, Callable @@ -29,13 +31,24 @@ AGENT_NAME = "openrfid" _MSG_ID_REGISTER_AGENT = 0x6F1A0001 -_MSG_ID_REGISTER_METHOD_BASE = 0x6F1A0010 +_MSG_ID_SERVER_INFO = 0x6F1A0100 +_MSG_ID_PRINT_STATE_SUBSCRIBE = 0x6F1A0101 + +_SAFE_PRINT_STATES = frozenset({"standby", "complete", "cancelled", "error"}) +_ACTIVE_PRINT_STATES = frozenset({"printing", "paused"}) REMOTE_METHOD_LIST_CHANNELS = "openrfid/list_channels" REMOTE_METHOD_SCAN_SLOT = "openrfid/scan_slot" REMOTE_METHOD_WRITE_TAG = "openrfid/write_tag" REMOTE_METHOD_CLEAR_TAG = "openrfid/clear_tag" REMOTE_METHOD_TIGERTAG_ENCODE = "openrfid/tigertag_encode" +REMOTE_METHOD_TIGERTAG_OPTIONS = "openrfid/tigertag_options" +REMOTE_METHOD_OPERATION_STATUS = "openrfid/operation_status" + +_ASYNC_REMOTE_METHODS = frozenset({ + REMOTE_METHOD_WRITE_TAG, + REMOTE_METHOD_CLEAR_TAG, +}) class OpenrfidApiController(MoonrakerController): @@ -45,11 +58,22 @@ def __init__(self, config: dict): super().__init__(config) self.runtime: Runtime self.enable_write = str(config.get("enable_write", "false")).lower() == "true" + self.allow_unrecognized_write = ( + str(config.get("allow_unrecognized_write", "false")).lower() == "true" + ) + self.allow_legacy_migration_write = ( + str(config.get("allow_legacy_migration_write", "false")).lower() + == "true" + ) self.agent_event_exporter_name = config.get("agent_event_exporter", None) self._exporter: OpenrfidAgentEventExporter | None = None self._broadcast_thread: threading.Thread | None = None self._stop_broadcast = threading.Event() + self._print_state_lock = threading.Lock() + self._print_state: str | None = None + self._print_state_updated_at: float | None = None + self._klippy_ready = False self._handlers: dict[str, Callable[[dict], Any]] = { REMOTE_METHOD_LIST_CHANNELS: self._handle_list_channels, @@ -57,6 +81,8 @@ def __init__(self, config: dict): REMOTE_METHOD_WRITE_TAG: self._handle_write_tag, REMOTE_METHOD_CLEAR_TAG: self._handle_clear_tag, REMOTE_METHOD_TIGERTAG_ENCODE: self._handle_tigertag_encode, + REMOTE_METHOD_TIGERTAG_OPTIONS: self._handle_tigertag_options, + REMOTE_METHOD_OPERATION_STATUS: self._handle_operation_status, } # ------------------------------------------------------------------ @@ -90,11 +116,9 @@ def _resolve_exporter(self) -> OpenrfidAgentEventExporter | None: # MoonrakerController hooks # ------------------------------------------------------------------ def on_connect(self): + self._set_print_state(None) + self._klippy_ready = False self._send_register_agent() - time.sleep(0.05) - for offset, method in enumerate(self._handlers): - self._send_register_remote_method(method, _MSG_ID_REGISTER_METHOD_BASE + offset) - time.sleep(0.02) # Start (or restart) the broadcast pump. self._stop_broadcast.set() @@ -102,22 +126,68 @@ def on_connect(self): self._broadcast_thread.join(timeout=1.0) self._stop_broadcast = threading.Event() self._broadcast_thread = threading.Thread( - target=self._broadcast_loop, name="openrfid-agent-broadcast", daemon=True + target=self._broadcast_loop, + args=(self.current_connection_generation(),), + name="openrfid-agent-broadcast", + daemon=True, ) self._broadcast_thread.start() + # The write gate is based on Moonraker's authoritative print_stats + # object. Until the initial subscription result arrives, writes fail + # closed with ``print_state_unknown``. + self._send_server_info_query() + + def on_disconnect(self): + # Fail closed immediately during Moonraker's reconnect delay. A worker + # also captures the socket generation, so a later reconnect cannot + # make its stale safety decision or JSON-RPC response valid again. + self._klippy_ready = False + self._set_print_state(None) + self._stop_broadcast.set() + def on_message(self, message: dict): + if self._handle_print_state_message(message): + return + method = message.get("method") if not method or method not in self._handlers: return msg_id = message.get("id") + request_generation = self.current_connection_generation() params = message.get("params") or {} if isinstance(params, list): params = params[0] if params and isinstance(params[0], dict) else {} + # A physical operation waits on Runtime's RFID-loop queue. Run it off + # the Moonraker receive thread so print-state notifications continue + # updating the authoritative cache while the request is pending. + if method in _ASYNC_REMOTE_METHODS: + threading.Thread( + target=self._handle_remote_method, + args=(method, params, msg_id, request_generation), + name="openrfid-agent-operation", + daemon=True, + ).start() + return + + self._handle_remote_method(method, params, msg_id, request_generation) + + def _handle_remote_method( + self, + method: str, + params: dict, + msg_id: Any, + request_generation: int | None = None, + ): + """Execute one registered method and send its JSON-RPC response.""" + try: - result = self._handlers[method](params) + if method in _ASYNC_REMOTE_METHODS: + result = self._handlers[method](params, request_generation) + else: + result = self._handlers[method](params) response: dict[str, Any] = {"jsonrpc": "2.0", "result": result} except Exception as exc: # pragma: no cover - defensive self.logger.exception("Handler for %s failed", method) @@ -126,7 +196,16 @@ def on_message(self, message: dict): if msg_id is not None: response["id"] = msg_id try: - self.send_message(response) + if request_generation is None: + self.send_message(response) + elif not self.send_message( + response, + expected_generation=request_generation, + ): + self.logger.info( + "Discarding stale response for %s after Moonraker reconnect", + method, + ) except Exception: self.logger.exception("Failed to send response for %s", method) @@ -139,22 +218,86 @@ def _send_register_agent(self): "method": "server.connection.identify", "params": { "client_name": AGENT_NAME, - "version": "0.1.0", + "version": "0.2.0", "type": "agent", - "url": "https://github.com/macdylan/SnapmakerU1-Extended-Firmware", + "url": "https://github.com/physicsG/OpenRFID", }, "id": _MSG_ID_REGISTER_AGENT, }) - def _send_register_remote_method(self, method_name: str, msg_id: int): + def _send_server_info_query(self): + self.send_message({ + "jsonrpc": "2.0", + "method": "server.info", + "id": _MSG_ID_SERVER_INFO, + }) + + def _send_print_state_subscribe(self): self.send_message({ "jsonrpc": "2.0", - "method": "connection.register_remote_method", - "params": {"method_name": method_name}, - "id": msg_id, + "method": "printer.objects.subscribe", + "params": {"objects": {"print_stats": ["state"]}}, + "id": _MSG_ID_PRINT_STATE_SUBSCRIBE, }) - def _broadcast_loop(self): + def _set_print_state(self, state: Any): + normalized = str(state).strip().lower() if state is not None else None + if not normalized: + normalized = None + with self._print_state_lock: + self._print_state = normalized + self._print_state_updated_at = time.time() if normalized is not None else None + + def _get_print_state(self) -> tuple[str | None, float | None]: + with self._print_state_lock: + return self._print_state, self._print_state_updated_at + + @staticmethod + def _status_objects(message: dict) -> dict: + params = message.get("params") or {} + if isinstance(params, list): + params = params[0] if params and isinstance(params[0], dict) else {} + return params if isinstance(params, dict) else {} + + def _handle_print_state_message(self, message: dict) -> bool: + msg_id = message.get("id") + # Moonraker extension callers choose their own JSON-RPC ids. Only a + # response (no method) can satisfy one of our internal request ids. + is_response = message.get("method") is None + if is_response and msg_id == _MSG_ID_SERVER_INFO: + info = message.get("result") or {} + self._klippy_ready = info.get("klippy_state") == "ready" + if self._klippy_ready: + self._send_print_state_subscribe() + else: + self._set_print_state(None) + return True + + if is_response and msg_id == _MSG_ID_PRINT_STATE_SUBSCRIBE: + result = message.get("result") or {} + state = (result.get("status") or {}).get("print_stats", {}).get("state") + self._set_print_state(state) + return True + + method = message.get("method") + if method in ("notify_klippy_disconnected", "notify_klippy_shutdown"): + self._klippy_ready = False + self._set_print_state(None) + return True + if method == "notify_klippy_ready": + self._klippy_ready = True + self._set_print_state(None) + self._send_print_state_subscribe() + return True + if method == "notify_status_update": + objects = self._status_objects(message) + state = (objects.get("print_stats") or {}).get("state") + if state is not None: + self._set_print_state(state) + return True + return False + + def _broadcast_loop(self, connection_generation: int | None = None): exporter = self._resolve_exporter() if exporter is None: self.logger.info("No openrfid_agent_event_exporter configured; agent events disabled") @@ -172,14 +315,16 @@ def _broadcast_loop(self): # notifications. Sending `notify_agent_event` directly # is treated as an RPC call and rejected with -32601. # https://moonraker.readthedocs.io/en/latest/external_api/extensions/#send-an-agent-event - self.send_message({ + sent = self.send_message({ "jsonrpc": "2.0", "method": "connection.send_event", "params": { "event": "openrfid/scan", "data": event, }, - }) + }, expected_generation=connection_generation) + if not sent: + return except Exception: self.logger.exception("Failed to broadcast agent event; reconnect imminent") return @@ -190,41 +335,145 @@ def _broadcast_loop(self): def _handle_list_channels(self, _params: dict) -> dict: channels = [] for i, reader in enumerate(self.runtime.rfid_readers): + slot = getattr(reader, "slot", i) + try: + write_capabilities = self.runtime.get_tigertag_write_capabilities(slot) + except Exception as exc: # pragma: no cover - defensive + self.logger.exception("Unable to inspect write capability for slot %s", slot) + write_capabilities = { + "supported": False, + "format": "tigertag", + "variant": "maker", + "error": str(exc), + } channels.append({ - "slot": getattr(reader, "slot", i), + "slot": slot, "name": reader.name, - "last_scan": self.runtime.last_scans.get(getattr(reader, "slot", i)), + "last_scan": self.runtime.last_scans.get(slot), + "capabilities": { + "scan": True, + "tigertag_write": write_capabilities, + }, }) - return {"channels": channels, "write_enabled": self.enable_write} + print_state, print_state_updated_at = self._get_print_state() + block = self._write_block() + return { + "api_version": 2, + "channels": channels, + "write_enabled": self.enable_write, + "write_allowed": block is None, + "write_block": block, + "allow_unrecognized_write": self.allow_unrecognized_write, + "allow_legacy_migration_write": self.allow_legacy_migration_write, + "print_state": print_state, + "print_state_updated_at": print_state_updated_at, + "capabilities": { + "scan": True, + "tigertag_encode": True, + "tigertag_options": True, + "tigertag_write": self.enable_write, + "tigertag_clear": self.enable_write, + "operation_status": True, + "expected_uid_required": True, + "expected_format": "tigertag", + "print_state_guard": True, + }, + } def _handle_scan_slot(self, params: dict) -> dict: - slot = self._require_slot(params) + try: + slot = self._require_slot(params) + except ValueError as exc: + return self._error("invalid_request", str(exc)) self.runtime.start_reading_tag(slot) return {"ok": True, "slot": slot} - def _handle_write_tag(self, params: dict) -> dict: - if not self.enable_write: - return {"ok": False, "error": "writes disabled (set enable_write = true in [openrfid_api])"} - slot = self._require_slot(params) - data_hex = params.get("data_hex") or params.get("data") - if not isinstance(data_hex, str): - return {"ok": False, "error": "data_hex (string) required"} + def _handle_write_tag( + self, + params: dict, + request_generation: int | None = None, + ) -> dict: + blocked = self._write_block_for_generation(request_generation) + if blocked is not None: + return blocked try: - data = bytes.fromhex(data_hex) + slot = self._require_slot(params) + expected_uid = self._require_expected_uid(params) + self._require_expected_format(params) + allow_unrecognized = self._allow_unrecognized(params) + allow_legacy_migration = self._allow_legacy_migration(params) + timeout = self._timeout(params) except ValueError as exc: - return {"ok": False, "error": f"invalid hex: {exc}"} - start_page = int(params.get("start_page", 4)) - timeout = float(params.get("timeout", 10.0)) - return self.runtime.submit_write(slot, data, start_page=start_page, timeout=timeout) + return self._error("invalid_request", str(exc)) - def _handle_clear_tag(self, params: dict) -> dict: - if not self.enable_write: - return {"ok": False, "error": "writes disabled (set enable_write = true in [openrfid_api])"} - slot = self._require_slot(params) - pages = int(params.get("pages", tigertag_encoder.PAYLOAD_LENGTH_BYTES // 4)) - start_page = int(params.get("start_page", tigertag_encoder.USER_DATA_START_PAGE)) - data = b"\x00" * (pages * 4) - return self.runtime.submit_write(slot, data, start_page=start_page) + spec = params.get("spec") + if spec is not None: + if not isinstance(spec, dict): + return self._error("invalid_spec", "spec must be an object") + try: + data = tigertag_encoder.encode(spec) + except (TypeError, ValueError) as exc: + return self._error("invalid_spec", str(exc)) + else: + data_hex = params.get("data_hex") or params.get("data") + if not isinstance(data_hex, str): + return self._error("missing_payload", "spec or data_hex is required") + try: + data = bytes.fromhex(data_hex) + except ValueError as exc: + return self._error("invalid_hex", f"invalid data_hex: {exc}") + + try: + tigertag_encoder.validate_maker_payload( + data, + uid=bytes.fromhex(expected_uid), + ) + except (TypeError, ValueError) as exc: + return self._error("invalid_payload", str(exc)) + + result = self.runtime.submit_tigertag_write( + slot, + expected_uid, + data, + timeout=timeout, + allow_unrecognized=allow_unrecognized, + allow_legacy_migration=allow_legacy_migration, + safety_check=lambda: self._write_block_for_generation(request_generation), + ) + if result.get("ok"): + result = { + **result, + "tag_format": "tigertag", + "tag_variant": "maker", + "payload_bytes": len(data), + } + return result + + def _handle_clear_tag( + self, + params: dict, + request_generation: int | None = None, + ) -> dict: + blocked = self._write_block_for_generation(request_generation) + if blocked is not None: + return blocked + try: + slot = self._require_slot(params) + expected_uid = self._require_expected_uid(params) + self._require_expected_format(params) + timeout = self._timeout(params) + allow_unrecognized = self._allow_unrecognized(params) + allow_legacy_migration = self._allow_legacy_migration(params) + except ValueError as exc: + return self._error("invalid_request", str(exc)) + return self.runtime.submit_tigertag_clear( + slot, + expected_uid, + timeout=timeout, + allow_unrecognized=allow_unrecognized, + allow_legacy_migration=allow_legacy_migration, + safety_check=lambda: self._write_block_for_generation(request_generation), + ) def _handle_tigertag_encode(self, params: dict) -> dict: spec = params.get("spec") or {} @@ -236,14 +485,148 @@ def _handle_tigertag_encode(self, params: dict) -> dict: return {"ok": False, "error": str(exc)} return { "ok": True, + "tag_format": "tigertag", + "tag_variant": "maker", "data_hex": payload.hex(), "start_page": tigertag_encoder.USER_DATA_START_PAGE, + "end_page": tigertag_encoder.USER_DATA_END_PAGE, "bytes": len(payload), + "pages": len(payload) // 4, } + def _handle_tigertag_options(self, _params: dict) -> dict: + try: + options = tigertag_encoder.get_options() + except Exception as exc: # pragma: no cover - defensive SDK/database error + self.logger.exception("Unable to load TigerTag authoring options") + return self._error("options_unavailable", str(exc)) + return {"ok": True, **options} + + def _handle_operation_status(self, params: dict) -> dict: + operation_id = params.get("operation_id") + if not isinstance(operation_id, str) or not operation_id.strip(): + return self._error( + "invalid_request", + "operation_id must be a non-empty string", + ) + return self.runtime.get_tigertag_operation_status(operation_id.strip()) + @staticmethod - def _require_slot(params: dict) -> int: + def _error(code: str, error: str, **details: Any) -> dict[str, Any]: + return {"ok": False, "code": code, "error": error, **details} + + def _write_block(self) -> dict[str, Any] | None: + if self.runtime.is_shutting_down(): + return self._error( + "service_stopping", + "OpenRFID is stopping; refusing to start a tag operation", + ) + if not self.enable_write: + return self._error( + "writes_disabled", + "writes disabled (set enable_write = true in [openrfid_api])", + ) + state, _updated_at = self._get_print_state() + if state is None: + return self._error( + "print_state_unknown", + "printer state is unavailable; refusing to write", + ) + if state in _ACTIVE_PRINT_STATES: + return self._error( + "print_active", + f"tag writes are blocked while print_stats.state is '{state}'", + print_state=state, + ) + if state not in _SAFE_PRINT_STATES: + return self._error( + "print_state_unsafe", + f"tag writes are not allowed while print_stats.state is '{state}'", + print_state=state, + ) + return None + + def _write_block_for_generation( + self, + request_generation: int | None, + ) -> dict[str, Any] | None: + # Direct handler calls used by integrations/tests do not carry a + # socket generation. Relayed Moonraker requests always do. + if request_generation is not None and not self.is_connection_current( + request_generation + ): + return self._error( + "moonraker_connection_lost", + "Moonraker connection changed while the tag operation was pending; refusing to write", + ) + return self._write_block() + + def _require_slot(self, params: dict) -> int: slot = params.get("slot") if slot is None: raise ValueError("slot is required") - return int(slot) + try: + normalized = int(slot) + except (TypeError, ValueError) as exc: + raise ValueError("slot must be an integer") from exc + configured_slots = { + getattr(reader, "slot", index) + for index, reader in enumerate(self.runtime.rfid_readers) + } + if isinstance(slot, bool) or normalized < 0 or normalized not in configured_slots: + raise ValueError(f"invalid slot {normalized}") + return normalized + + @staticmethod + def _require_expected_uid(params: dict) -> str: + value = params.get("expected_uid") + if not isinstance(value, str) or not value.strip(): + raise ValueError("expected_uid is required") + compact = value.strip() + if compact.lower().startswith("0x"): + compact = compact[2:] + for separator in (" ", ":", "_", "-"): + compact = compact.replace(separator, "") + try: + uid = bytes.fromhex(compact) + except ValueError as exc: + raise ValueError("expected_uid must be hexadecimal") from exc + if len(uid) != 7: + raise ValueError("expected_uid must contain the 7-byte NTAG UID") + return uid.hex().upper() + + @staticmethod + def _require_expected_format(params: dict): + value = params.get("expected_format") + if not isinstance(value, str) or value.strip().lower() != "tigertag": + raise ValueError("expected_format must be 'tigertag'") + + def _allow_unrecognized(self, params: dict) -> bool: + value = params.get("allow_unrecognized", False) + if not isinstance(value, bool): + raise ValueError("allow_unrecognized must be a boolean") + if value and not self.allow_unrecognized_write: + raise ValueError( + "allow_unrecognized requires allow_unrecognized_write = true in [openrfid_api]" + ) + return value + + def _allow_legacy_migration(self, params: dict) -> bool: + value = params.get("allow_legacy_migration", False) + if not isinstance(value, bool): + raise ValueError("allow_legacy_migration must be a boolean") + if value and not self.allow_legacy_migration_write: + raise ValueError( + "allow_legacy_migration requires allow_legacy_migration_write = true in [openrfid_api]" + ) + return value + + @staticmethod + def _timeout(params: dict) -> float: + try: + timeout = float(params.get("timeout", 10.0)) + except (TypeError, ValueError) as exc: + raise ValueError("timeout must be a number") from exc + if not math.isfinite(timeout) or timeout <= 0 or timeout > 30: + raise ValueError("timeout must be greater than 0 and at most 30 seconds") + return timeout diff --git a/src/filament/generic.py b/src/filament/generic.py index 2a17b35..ac78b8e 100644 --- a/src/filament/generic.py +++ b/src/filament/generic.py @@ -1,4 +1,6 @@ import hashlib +from typing import Any + from .valid_materials import VALID_BASE_MATERIALS def to_rgba(argb: int) -> int: @@ -19,16 +21,24 @@ def __init__(self, modifiers: list[str], colors : list[int], # Format 0xAARRGGBB diameter_mm: float, - weight_grams: float, + weight_grams: float | None, hotend_min_temp_c: float, hotend_max_temp_c: float, bed_temp_c: float, drying_temp_c: float, drying_time_hours: float, manufacturing_date: str, # ISO 8601 date string - td: float = 0.0, # Transmission Distance in mm for HueForge/OrcaSlicer-FullSpectrum - bed_temp_max_c: float = 0.0, - message: str = "", # Custom message (e.g. from TigerTag to support a name for the filament or a short description) + td: float | None = None, # Transmission Distance in mm for HueForge/OrcaSlicer-FullSpectrum + bed_temp_max_c: float | None = None, + message: str | None = None, # On-tag custom message; separate from an inventory/display name. + tag_format: str = "unknown", + present_fields: list[str] | None = None, + format_data: dict[str, Any] | None = None, + authentication: dict[str, Any] | None = None, + available_quantity: float | None = None, + quantity_unit: str | None = None, + bed_temp_min_c: float | None = None, + material_name: str | None = None, ): self.source_processor = source_processor self.unique_id = unique_id @@ -40,13 +50,63 @@ def __init__(self, self.weight_grams = weight_grams self.hotend_min_temp_c = hotend_min_temp_c self.hotend_max_temp_c = hotend_max_temp_c - self.bed_temp_c = bed_temp_c + self.bed_temp_min_c = bed_temp_c if bed_temp_min_c is None else bed_temp_min_c + # Keep the historical key as an alias for the minimum of the range. + self.bed_temp_c = self.bed_temp_min_c self.drying_temp_c = drying_temp_c self.drying_time_hours = drying_time_hours self.manufacturing_date = manufacturing_date - self.td = td - self.bed_temp_max_c = bed_temp_max_c - self.message = message + self.td = 0.0 if td is None else td + self.td_mm = self.td + self.bed_temp_max_c = 0.0 if bed_temp_max_c is None else bed_temp_max_c + self.message = "" if message is None else message + self.tag_format = str(tag_format or "unknown").strip().lower() + self.format_data = dict(format_data or {}) + self.authentication = dict(authentication) if authentication is not None else None + self.available_quantity = available_quantity + self.quantity_unit = quantity_unit + # Preserve a format's exact material/product label separately from the + # normalized printer profile type (for example "PLA Marble" vs PLA). + self.material_name = material_name + + if present_fields is None: + inferred_fields = [ + "manufacturer", + "type", + "modifiers", + "colors", + "colors_rgba", + "colors_rgba_hex", + "diameter_mm", + "hotend_min_temp_c", + "hotend_max_temp_c", + "bed_temp_c", + "bed_temp_min_c", + "drying_temp_c", + "drying_time_hours", + "manufacturing_date", + ] + if weight_grams is not None: + inferred_fields.append("weight_grams") + if bed_temp_max_c is not None: + inferred_fields.append("bed_temp_max_c") + if td is not None: + inferred_fields.extend(("td", "td_mm")) + if message is not None: + inferred_fields.append("message") + if available_quantity is not None: + inferred_fields.append("available_quantity") + if quantity_unit is not None: + inferred_fields.append("quantity_unit") + if material_name is not None: + inferred_fields.append("material_name") + present_fields = inferred_fields + + # Preserve caller order while removing duplicates and malformed entries. + self.present_fields = list(dict.fromkeys( + field for field in present_fields + if isinstance(field, str) and field + )) if "CF" in self.modifiers: self.type += "-CF" @@ -56,8 +116,12 @@ def __init__(self, self.type += "-GF" self.modifiers.remove("GF") - if self.type not in VALID_BASE_MATERIALS: - raise ValueError(f"Invalid filament type: {self.type}") + # A recognized RFID payload may legitimately contain a material that + # the printer's historical profile whitelist does not know yet. Keep + # that decoded identity visible and mark projection support instead of + # turning the entire tag into a parse error (notably official Init, + # empty Maker, and newer TigerTag registry entries). + self.type_supported = self.type in VALID_BASE_MATERIALS def pretty_text(self) -> str: modifiers = ' '.join(self.modifiers) @@ -88,9 +152,12 @@ def rgba(self) -> int: def to_dict(self) -> dict: return { "source_processor": self.source_processor, + "tag_format": self.tag_format, "unique_id": self.unique_id, "manufacturer": self.manufacturer, "type": self.type, + "type_supported": self.type_supported, + "material_name": self.material_name, "modifiers": self.modifiers, "colors": self.colors, "rgba": self.rgba, @@ -103,16 +170,23 @@ def to_dict(self) -> dict: "hotend_min_temp_c": self.hotend_min_temp_c, "hotend_max_temp_c": self.hotend_max_temp_c, "bed_temp_c": self.bed_temp_c, + "bed_temp_min_c": self.bed_temp_min_c, "bed_temp_max_c": self.bed_temp_max_c, "drying_temp_c": self.drying_temp_c, "drying_time_hours": self.drying_time_hours, "manufacturing_date": self.manufacturing_date, "td": self.td, + "td_mm": self.td_mm, "message": self.message, + "available_quantity": self.available_quantity, + "quantity_unit": self.quantity_unit, + "present_fields": self.present_fields, + "format_data": self.format_data, + "authentication": self.authentication, } @staticmethod def generate_unique_id(*args) -> str: strings = "|".join([str(arg) for arg in args]) hash = hashlib.sha256(strings.encode('utf-8')).hexdigest() - return hash \ No newline at end of file + return hash diff --git a/src/main.py b/src/main.py index 0ef1a08..ba52a66 100644 --- a/src/main.py +++ b/src/main.py @@ -18,6 +18,7 @@ import os import json import threading +import signal import configparser from tag.anycubic import AnycubicTagProcessor @@ -86,6 +87,17 @@ def create_configurable_entity(key: str, config: dict) -> ConfigurableEntity: logging.basicConfig(level=logging.DEBUG) + +def _install_shutdown_signal_handlers(run: Runtime) -> None: + """Translate process termination into a cooperative Runtime shutdown.""" + def request_shutdown(_signum, _frame): + # Keep this path lock-free. The RFID loop cancels queued work and lets + # an already mutating transaction reach verification and end_session. + run.request_shutdown() + + signal.signal(signal.SIGTERM, request_shutdown) + signal.signal(signal.SIGINT, request_shutdown) + def main(): if len(sys.argv) < 2: logging.error("No config file provided") @@ -115,6 +127,8 @@ def main(): logging.error("Unsupported config file format, only .json is supported") sys.exit(1) + _install_shutdown_signal_handlers(run) + for controller in run.controllers: threading.Thread(target=controller.loop, daemon=True).start() diff --git a/src/reader/fm175xx/rfid.py b/src/reader/fm175xx/rfid.py index c6ad78c..946d96c 100644 --- a/src/reader/fm175xx/rfid.py +++ b/src/reader/fm175xx/rfid.py @@ -1,14 +1,30 @@ from tag.mifare_classic_tag_processor import TagAuthentication -from tag.tag_types import tag_type_from_sak +from tag.tag_types import TagType, tag_type_from_sak from . import constants as Constants from bus import SoftwareSPI, OutputPin from reader.mifare_classic_reader import MifareClassicReader -from reader.mifare_ultralight_reader import MifareUltralightReader +from reader.mifare_ultralight_reader import ( + MifareUltralightReader, + TIGERTAG_INIT_ID, + TIGERTAG_MAKER_ID, + TIGERTAG_PLUS_ID, + TIGERTAG_OWNED_END_PAGE, + TIGERTAG_OWNED_LENGTH, + TIGERTAG_OWNED_START_PAGE, +) from reader.scan_result import ScanResult from config import get_required_configurable_entity_by_name, TYPE_SOFTWARE_SPI, TYPE_OUTPUT_PIN -from typing import cast +from typing import Any, Callable, cast import time + +_NTAG21X_VERSION_MODELS = { + # GET_VERSION storage-size byte: (model, dynamic-lock page). + 0x0F: ("NTAG213", 40), + 0x11: ("NTAG215", 130), + 0x13: ("NTAG216", 226), +} + # Reader command class Fm175xxCmdMetaData: def __init__(self) -> None: @@ -82,13 +98,12 @@ def read_mifare_ultralight(self, scan_result: ScanResult) -> bytes | None: return bytes(data.out_data) - def write_ntag_pages(self, start_page: int, data: bytes) -> int: - """Write ``data`` to NTAG21x user pages starting at ``start_page``. + def _write_ntag_pages_unchecked(self, start_page: int, data: bytes) -> int: + """Legacy general NTAG writer retained for low-level diagnostics. - ``data`` must be a multiple of 4 bytes (one NTAG page). The carrier - wave must be enabled (i.e. the caller wraps this in - :meth:`start_session` / :meth:`end_session`). Returns one of the - ``FM175XX_*`` constants from :mod:`reader.fm175xx.constants`. + This deliberately private method has no expected-UID, format or + read-back safeguards. Product/API writes must use + :meth:`write_tigertag_maker` or :meth:`clear_tigertag_maker`. """ if not isinstance(data, (bytes, bytearray)): return Constants.FM175XX_PARAM_ERR @@ -122,6 +137,436 @@ def write_ntag_pages(self, start_page: int, data: bytes) -> int: return Constants.FM175XX_OK + @staticmethod + def _tag_operation_error(code: str, error: str, **details: Any) -> dict[str, Any]: + return {"ok": False, "code": code, "error": error, **details} + + def _activate_expected_ultralight( + self, + expected_uid: bytes, + ) -> tuple[bytes | None, dict[str, Any] | None]: + ret, uid_raw, _atqa, _bcc, sak_raw = self.__reader_a_activate() + if ret != Constants.FM175XX_OK: + return None, self._tag_operation_error( + "tag_not_present", + "could not activate a tag", + status=ret, + ) + + uid = bytes(uid_raw) + tag_type = tag_type_from_sak(bytes(sak_raw)) + if tag_type != TagType.MifareUltralight: + return None, self._tag_operation_error( + "wrong_tag_type", + "activated tag is not MIFARE Ultralight/NTAG hardware", + actual_uid=uid.hex().upper(), + actual_tag_type=tag_type.name, + ) + if uid != expected_uid: + return None, self._tag_operation_error( + "uid_mismatch", + "activated tag UID does not match expected_uid", + expected_uid=expected_uid.hex().upper(), + actual_uid=uid.hex().upper(), + ) + return uid, None + + def _read_tigertag_owned_region( + self, + ) -> tuple[bytes | None, dict[str, Any] | None]: + data = bytearray() + for page in range(TIGERTAG_OWNED_START_PAGE, TIGERTAG_OWNED_END_PAGE + 1, 4): + result = self.__reader_a_ultralight_page_read(page) + if result.err_code != Constants.FM175XX_OK or len(result.out_data) != 16: + code = "capacity_probe_failed" if page == 20 else "read_failed" + message = ( + "tag does not expose writable user memory through page 23" + if page == 20 + else f"failed to read TigerTag-owned region at page {page}" + ) + return None, self._tag_operation_error( + code, + message, + page=page, + status=result.err_code, + ) + data.extend(result.out_data) + return bytes(data[:TIGERTAG_OWNED_LENGTH]), None + + def _validate_existing_tigertag( + self, + current: bytes, + allow_unrecognized: bool, + allow_legacy_migration: bool, + legacy_signature_prefix: bytes | None = None, + ) -> tuple[str | None, dict[str, Any] | None]: + header = int.from_bytes(current[:4], "big") + if header == TIGERTAG_PLUS_ID: + product_id = int.from_bytes(current[4:8], "big") + # The retired feature encoder used the Plus format ID, reserved + # product 0, and wrote a guaranteed zero signature prefix to pages + # 24..27. All three conditions are required for migration; a + # Plus-shaped tag without that exact read-only fingerprint stays + # protected even when an override is supplied. + if product_id == 0: + if legacy_signature_prefix != bytes(16): + return None, self._tag_operation_error( + "protected_tag_format", + "TigerTag+ product 0 does not match the exact legacy OpenRFID v1 signature prefix", + existing_header=f"0x{header:08X}", + existing_product_id=product_id, + ) + if allow_legacy_migration: + return "legacy_openrfid_v1", None + return None, self._tag_operation_error( + "legacy_upgrade_required", + "legacy unsigned TigerTag+ data requires the explicit legacy-migration override", + existing_header=f"0x{header:08X}", + existing_product_id=product_id, + ) + return None, self._tag_operation_error( + "protected_tag_format", + "TigerTag+ is not writable by the Maker writer", + existing_header=f"0x{header:08X}", + existing_product_id=product_id, + ) + if header == TIGERTAG_MAKER_ID: + return "maker", None + if header == TIGERTAG_INIT_ID: + return "init", None + if allow_unrecognized: + return "unrecognized", None + return None, self._tag_operation_error( + "unrecognized_tag", + "existing tag is neither TigerTag Maker nor TigerTag Init; explicit allow_unrecognized is required", + existing_header=f"0x{header:08X}", + ) + + def _read_legacy_signature_prefix( + self, + ) -> tuple[bytes | None, dict[str, Any] | None]: + result = self.__reader_a_ultralight_page_read(24) + if result.err_code != Constants.FM175XX_OK or len(result.out_data) != 16: + return None, self._tag_operation_error( + "legacy_probe_failed", + "could not verify pages 24..27 required for legacy OpenRFID v1 migration", + page=24, + status=result.err_code, + ) + return bytes(result.out_data), None + + def _preflight_tigertag_writable( + self, + ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + """Fail closed on model, locks, CC access, or password protection. + + The writer owns pages 4..23. NTAG21x static locks cover pages 4..15; + dynamic locks cover page 16 onward with model-specific granularity. + Every check happens before page 4 is invalidated. + """ + version_result = self.__ntag_get_version() + version = bytes(version_result.out_data) + if ( + version_result.err_code != Constants.FM175XX_OK + or len(version) != 8 + or version[1:3] != b"\x04\x04" + or version[6] not in _NTAG21X_VERSION_MODELS + ): + return None, self._tag_operation_error( + "unsupported_tag_model", + "safe writes require a recognized NTAG213, NTAG215, or NTAG216 GET_VERSION response", + status=version_result.err_code, + version_hex=version.hex().upper(), + ) + + model, dynamic_lock_page = _NTAG21X_VERSION_MODELS[version[6]] + + static_result = self.__reader_a_ultralight_page_read(2) + if static_result.err_code != Constants.FM175XX_OK or len(static_result.out_data) != 16: + return None, self._tag_operation_error( + "lock_probe_failed", + "could not read NTAG static lock bytes", + page=2, + status=static_result.err_code, + tag_model=model, + ) + static_window = bytes(static_result.out_data) + static_lock_0 = static_window[2] + static_lock_1 = static_window[3] + locked_pages = [ + page + for page in range(4, 8) + if static_lock_0 & (1 << page) + ] + locked_pages.extend( + page + for page in range(8, 16) + if static_lock_1 & (1 << (page - 8)) + ) + if locked_pages: + return None, self._tag_operation_error( + "tag_locked", + "one or more TigerTag-owned pages are statically locked read-only", + tag_model=model, + lock_source="static", + locked_pages=locked_pages, + ) + + # Page 3 is included in the READ(2) window. A non-zero Type 2 Tag CC + # write-access nibble is not an unrestricted writable data area. + cc_write_access = static_window[7] & 0x0F + if cc_write_access != 0: + return None, self._tag_operation_error( + "tag_read_only", + "the NFC Type 2 capability container does not allow unrestricted writes", + tag_model=model, + cc_write_access=cc_write_access, + ) + + dynamic_result = self.__reader_a_ultralight_page_read(dynamic_lock_page) + if dynamic_result.err_code != Constants.FM175XX_OK or len(dynamic_result.out_data) != 16: + return None, self._tag_operation_error( + "lock_probe_failed", + "could not read NTAG dynamic lock and configuration bytes", + page=dynamic_lock_page, + status=dynamic_result.err_code, + tag_model=model, + ) + dynamic_window = bytes(dynamic_result.out_data) + dynamic_lock_0 = dynamic_window[0] + if model == "NTAG213": + dynamic_locked_pages = [ + page + for page in range(16, 24) + if dynamic_lock_0 & (1 << ((page - 16) // 2)) + ] + else: + dynamic_locked_pages = list(range(16, 24)) if dynamic_lock_0 & 0x01 else [] + if dynamic_locked_pages: + return None, self._tag_operation_error( + "tag_locked", + "one or more TigerTag-owned pages are dynamically locked read-only", + tag_model=model, + lock_source="dynamic", + locked_pages=dynamic_locked_pages, + ) + + # The READ(dynamic-lock-page) response also includes CFG0 on the next + # page; AUTH0 is CFG0 byte 3. This writer intentionally has no password + # input, so any protection beginning inside pages 4..23 is rejected. + auth0 = dynamic_window[7] + if auth0 <= TIGERTAG_OWNED_END_PAGE: + return None, self._tag_operation_error( + "tag_password_protected", + "password protection begins inside the TigerTag-owned region", + tag_model=model, + auth0_page=auth0, + ) + + return { + "tag_model": model, + "version_hex": version.hex().upper(), + "dynamic_lock_page": dynamic_lock_page, + }, None + + def _replace_tigertag_owned_region( + self, + expected_uid: bytes, + target: bytes, + allow_unrecognized: bool, + allow_legacy_migration: bool, + action: str, + safety_check: Callable[[], dict[str, Any] | None] | None = None, + ) -> dict[str, Any]: + if not isinstance(expected_uid, (bytes, bytearray)) or len(expected_uid) not in (4, 7, 10): + return self._tag_operation_error( + "invalid_uid", + "expected_uid must be a 4, 7, or 10 byte ISO14443 UID", + ) + expected_uid = bytes(expected_uid) + if not isinstance(target, (bytes, bytearray)) or len(target) != TIGERTAG_OWNED_LENGTH: + return self._tag_operation_error( + "invalid_payload_length", + f"TigerTag-owned payload must be exactly {TIGERTAG_OWNED_LENGTH} bytes", + ) + target = bytes(target) + if action == "write" and int.from_bytes(target[:4], "big") != TIGERTAG_MAKER_ID: + return self._tag_operation_error( + "invalid_payload_header", + f"payload header must be TigerTag Maker 0x{TIGERTAG_MAKER_ID:08X}", + ) + + uid, error = self._activate_expected_ultralight(expected_uid) + if error is not None: + return error + + # Reading all five 16-byte windows before the first write both records + # the existing header and proves that page 23 is addressable. + current, error = self._read_tigertag_owned_region() + if error is not None: + return error + assert current is not None + legacy_signature_prefix = None + if ( + int.from_bytes(current[:4], "big") == TIGERTAG_PLUS_ID + and int.from_bytes(current[4:8], "big") == 0 + ): + legacy_signature_prefix, error = self._read_legacy_signature_prefix() + if error is not None: + return error + existing_format, error = self._validate_existing_tigertag( + current, + allow_unrecognized, + allow_legacy_migration, + legacy_signature_prefix=legacy_signature_prefix, + ) + if error is not None: + return error + + preflight, error = self._preflight_tigertag_writable() + if error is not None: + return error + assert preflight is not None + + # Recheck authoritative printer state after activation, UID matching, + # format inspection, and the page-23 capacity probe, immediately + # before the first mutating command. The Moonraker receive loop stays + # responsive while this operation waits, so this sees print starts + # that occurred after the API request was accepted. + if safety_check is not None: + try: + blocked = safety_check() + except Exception as exc: + return self._tag_operation_error( + "safety_check_failed", + f"pre-write safety check failed: {exc}", + phase="pre_write", + ) + if blocked is not None: + if not isinstance(blocked, dict) or blocked.get("ok") is not False: + return self._tag_operation_error( + "invalid_safety_check_result", + "pre-write safety check returned an invalid result", + phase="pre_write", + ) + return {**blocked, "phase": "pre_write"} + + # Invalidate the format first. A power loss or later page error leaves + # a deliberately unrecognized tag instead of a valid header over a + # partially updated body. + status = self.__ntag_page_write(TIGERTAG_OWNED_START_PAGE, [0, 0, 0, 0]) + if status != Constants.FM175XX_OK: + return self._tag_operation_error( + "header_invalidate_failed", + "failed to invalidate the existing TigerTag header", + page=TIGERTAG_OWNED_START_PAGE, + status=status, + ) + + for page in range(TIGERTAG_OWNED_START_PAGE + 1, TIGERTAG_OWNED_END_PAGE + 1): + offset = (page - TIGERTAG_OWNED_START_PAGE) * 4 + status = self.__ntag_page_write(page, list(target[offset:offset + 4])) + if status != Constants.FM175XX_OK: + return self._tag_operation_error( + "body_write_failed", + f"failed to write TigerTag body page {page}", + page=page, + status=status, + header_invalidated=True, + ) + + body_readback, error = self._read_tigertag_owned_region() + if error is not None: + return self._tag_operation_error( + "body_verify_read_failed", + error["error"], + page=error.get("page"), + status=error.get("status"), + cause_code=error["code"], + header_invalidated=True, + ) + assert body_readback is not None + if body_readback[4:] != target[4:]: + return self._tag_operation_error( + "body_verify_failed", + "TigerTag body read-back did not match the requested data", + header_invalidated=True, + ) + + # Publish the target header only after every body page verifies. Clear + # operations intentionally publish a zero header here. + status = self.__ntag_page_write(TIGERTAG_OWNED_START_PAGE, list(target[:4])) + if status != Constants.FM175XX_OK: + return self._tag_operation_error( + "header_write_failed", + "failed to write the final TigerTag header", + page=TIGERTAG_OWNED_START_PAGE, + status=status, + header_invalidated=True, + ) + + full_readback, error = self._read_tigertag_owned_region() + if error is not None: + return self._tag_operation_error( + "verify_read_failed", + error["error"], + page=error.get("page"), + status=error.get("status"), + cause_code=error["code"], + ) + if full_readback != target: + return self._tag_operation_error( + "verify_failed", + "full TigerTag read-back did not match the requested data", + ) + + return { + "ok": True, + "code": "written" if action == "write" else "cleared", + "uid": uid.hex().upper() if uid is not None else expected_uid.hex().upper(), + "previous_format": existing_format, + "start_page": TIGERTAG_OWNED_START_PAGE, + "end_page": TIGERTAG_OWNED_END_PAGE, + "bytes_written": TIGERTAG_OWNED_LENGTH, + "verified": True, + "tag_model": preflight["tag_model"], + } + + def write_tigertag_maker( + self, + expected_uid: bytes, + data: bytes, + allow_unrecognized: bool = False, + allow_legacy_migration: bool = False, + safety_check: Callable[[], dict[str, Any] | None] | None = None, + ) -> dict[str, Any]: + """Safely write one 80-byte Maker record while a session is active.""" + return self._replace_tigertag_owned_region( + expected_uid, + data, + allow_unrecognized, + allow_legacy_migration, + action="write", + safety_check=safety_check, + ) + + def clear_tigertag_maker( + self, + expected_uid: bytes, + allow_unrecognized: bool = False, + allow_legacy_migration: bool = False, + safety_check: Callable[[], dict[str, Any] | None] | None = None, + ) -> dict[str, Any]: + """Safely clear exactly pages 4..23 while a session is active.""" + return self._replace_tigertag_owned_region( + expected_uid, + b"\x00" * TIGERTAG_OWNED_LENGTH, + allow_unrecognized, + allow_legacy_migration, + action="clear", + safety_check=safety_check, + ) + # Reader-A: NTAG/Ultralight, write a page (4 bytes) def __ntag_page_write(self, page: int, data: list[int]) -> int: cmd = Fm175xxCmdMetaData() @@ -146,6 +591,21 @@ def __ntag_page_write(self, page: int, data: list[int]) -> int: return Constants.FM175XX_CARD_COMM_ERR return Constants.FM175XX_OK + def __ntag_get_version(self) -> Fm175xxReturnVal: + """Issue the NTAG21x GET_VERSION command (0x60).""" + cmd = Fm175xxCmdMetaData() + cmd.send_crc_en = Constants.FM175XX_SET + cmd.recv_crc_en = Constants.FM175XX_SET + cmd.send_buff = [0x60] + cmd.recv_buff = [0] * 8 + cmd.bytes_to_send = 1 + cmd.bits_to_send = 0 + cmd.bits_to_recv = 0 + cmd.bytes_to_recv = 8 + cmd.timeout = 10 + cmd.cmd = Constants.FM175XX_CMD_TRANSCEIVE + return self.__command_exe(cmd) + # read register def __register_read(self, addr:int) -> int: addr = (addr << 1) | 0x80 @@ -778,4 +1238,4 @@ def __reader_a_ultralight_read_all_data(self, retry_times = 3) -> Fm175xxReturnV ret.err_code = Constants.FM175XX_OK ret.out_data = card_data_tmp - return ret \ No newline at end of file + return ret diff --git a/src/reader/gpio_enabled_rfid_reader.py b/src/reader/gpio_enabled_rfid_reader.py index 316e947..a644dc7 100644 --- a/src/reader/gpio_enabled_rfid_reader.py +++ b/src/reader/gpio_enabled_rfid_reader.py @@ -1,4 +1,5 @@ from time import sleep +from typing import Any, Callable from reader.mifare_classic_reader import MifareClassicReader from reader.mifare_ultralight_reader import MifareUltralightReader @@ -56,18 +57,54 @@ def read_mifare_ultralight(self, scan_result : ScanResult) -> bytes|None: return None - def write_ntag_pages(self, start_page: int, data: bytes) -> int | None: - """Write NTAG21x pages via the inner reader, framed by start/end - session so the slot's GPIO pins and the carrier wave are correctly - toggled. Returns the FM175XX status code from the inner reader, or - ``None`` if the inner reader does not support NTAG writes. - """ - write = getattr(self.rfid_reader, "write_ntag_pages", None) + def _write_ntag_pages_unchecked(self, start_page: int, data: bytes) -> int | None: + """Private passthrough for the legacy diagnostic NTAG writer.""" + write = getattr(self.rfid_reader, "_write_ntag_pages_unchecked", None) if not callable(write): return None + return write(start_page, data) - self.start_session() - try: - return write(start_page, data) - finally: - self.end_session() \ No newline at end of file + def write_tigertag_maker( + self, + expected_uid: bytes, + data: bytes, + allow_unrecognized: bool = False, + allow_legacy_migration: bool = False, + safety_check: Callable[[], dict[str, Any] | None] | None = None, + ) -> dict: + """Delegate a safe write after the runtime has opened this session.""" + if not isinstance(self.rfid_reader, MifareUltralightReader): + return { + "ok": False, + "code": "reader_not_supported", + "error": "inner reader does not support safe TigerTag writes", + } + kwargs = { + "allow_unrecognized": allow_unrecognized, + "allow_legacy_migration": allow_legacy_migration, + } + if safety_check is not None: + kwargs["safety_check"] = safety_check + return self.rfid_reader.write_tigertag_maker(expected_uid, data, **kwargs) + + def clear_tigertag_maker( + self, + expected_uid: bytes, + allow_unrecognized: bool = False, + allow_legacy_migration: bool = False, + safety_check: Callable[[], dict[str, Any] | None] | None = None, + ) -> dict: + """Delegate a safe clear after the runtime has opened this session.""" + if not isinstance(self.rfid_reader, MifareUltralightReader): + return { + "ok": False, + "code": "reader_not_supported", + "error": "inner reader does not support safe TigerTag clears", + } + kwargs = { + "allow_unrecognized": allow_unrecognized, + "allow_legacy_migration": allow_legacy_migration, + } + if safety_check is not None: + kwargs["safety_check"] = safety_check + return self.rfid_reader.clear_tigertag_maker(expected_uid, **kwargs) diff --git a/src/reader/mifare_ultralight_reader.py b/src/reader/mifare_ultralight_reader.py index 436c582..b76413b 100644 --- a/src/reader/mifare_ultralight_reader.py +++ b/src/reader/mifare_ultralight_reader.py @@ -1,7 +1,19 @@ from abc import abstractmethod +from typing import Any, Callable + from reader.rfid_reader import RfidReader from reader.scan_result import ScanResult + +TIGERTAG_MAKER_ID = 0x5BF59264 +TIGERTAG_INIT_ID = 0x6C41A2E1 +TIGERTAG_PLUS_ID = 0xBC0FCB97 +TIGERTAG_OWNED_START_PAGE = 4 +TIGERTAG_OWNED_END_PAGE = 23 +TIGERTAG_OWNED_LENGTH = ( + TIGERTAG_OWNED_END_PAGE - TIGERTAG_OWNED_START_PAGE + 1 +) * 4 + class MifareUltralightReader(RfidReader): def __init__(self, config: dict): super().__init__(config) @@ -9,4 +21,43 @@ def __init__(self, config: dict): @abstractmethod def read_mifare_ultralight(self, scan_result : ScanResult) -> bytes|None: """Reads data from a Mifare Ultralight tag.""" - raise NotImplementedError("Subclasses must implement this method") \ No newline at end of file + raise NotImplementedError("Subclasses must implement this method") + + def write_tigertag_maker( + self, + expected_uid: bytes, + data: bytes, + allow_unrecognized: bool = False, + allow_legacy_migration: bool = False, + safety_check: Callable[[], dict[str, Any] | None] | None = None, + ) -> dict[str, Any]: + """Safely replace pages 4..23 with a TigerTag Maker payload. + + The caller owns the reader session. Implementations must verify the + activated tag and the complete write, and must never expand the owned + region beyond pages 4..23. + """ + return { + "ok": False, + "code": "reader_not_supported", + "error": "reader does not support safe TigerTag writes", + } + + def clear_tigertag_maker( + self, + expected_uid: bytes, + allow_unrecognized: bool = False, + allow_legacy_migration: bool = False, + safety_check: Callable[[], dict[str, Any] | None] | None = None, + ) -> dict[str, Any]: + """Safely clear exactly the TigerTag-owned pages 4..23. + + The caller owns the reader session. The same identity, hardware, + capacity and existing-format checks as :meth:`write_tigertag_maker` + apply. + """ + return { + "ok": False, + "code": "reader_not_supported", + "error": "reader does not support safe TigerTag clears", + } diff --git a/src/runtime.py b/src/runtime.py index ed64c73..3f3f8da 100644 --- a/src/runtime.py +++ b/src/runtime.py @@ -6,30 +6,73 @@ from tag.mifare_classic_tag_processor import MifareClassicTagProcessor from tag.mifare_ultralight_tag_processor import MifareUltralightTagProcessor from reader.mifare_classic_reader import MifareClassicReader -from reader.mifare_ultralight_reader import MifareUltralightReader +from reader.mifare_ultralight_reader import ( + MifareUltralightReader, + TIGERTAG_MAKER_ID, + TIGERTAG_OWNED_LENGTH, +) from reader.rfid_reader import RfidReader from exporters.exporter import Exporter, ExporterEvent from reader.scan_result import ScanResult from filament import GenericFilament -from typing import cast, Any +from typing import cast, Any, Callable +from collections import OrderedDict import threading import time import logging +import math +import re +import uuid class _PendingWrite: - """State for a write request awaiting drain by :meth:`Runtime.loop`.""" - - __slots__ = ("start_page", "data", "event", "result") - - def __init__(self, start_page: int, data: bytes): - self.start_page = start_page + """One immutable tag operation awaiting drain by :meth:`Runtime.loop`.""" + + __slots__ = ( + "operation_id", + "slot", + "action", + "expected_uid", + "data", + "allow_unrecognized", + "allow_legacy_migration", + "safety_check", + "event", + "result", + "state", + "created_at", + "started_at", + ) + + def __init__( + self, + operation_id: str, + slot: int, + action: str, + expected_uid: bytes, + data: bytes | None, + allow_unrecognized: bool, + allow_legacy_migration: bool, + safety_check: Callable[[], dict[str, Any] | None] | None, + ): + self.operation_id = operation_id + self.slot = slot + self.action = action + self.expected_uid = expected_uid self.data = data + self.allow_unrecognized = allow_unrecognized + self.allow_legacy_migration = allow_legacy_migration + self.safety_check = safety_check self.event = threading.Event() self.result: dict[str, Any] | None = None + self.state = "queued" + self.created_at = time.time() + self.started_at: float | None = None class Runtime: + TIGERTAG_OPERATION_HISTORY_LIMIT = 64 + def __init__(self): configs = cast(list[Configuration], get_entities_by_type(TYPE_CONFIGURATION)) @@ -41,6 +84,7 @@ def __init__(self): self.config = configs[0] self.rfid_readers : list[RfidReader] = [x for x in cast(list[RfidReader], get_entities_by_type(TYPE_RFID_READER)) if x.enabled] + self._rebuild_reader_slot_mapping() self.tag_processors : list[TagProcessor] = [x for x in cast(list[TagProcessor], get_entities_by_type(TYPE_TAG_PROCESSOR)) if x.enabled] self.exporters : list[Exporter] = [x for x in cast(list[Exporter], get_entities_by_type(TYPE_EXPORTER)) if x.enabled] self.controllers : list[Controller] = [x for x in cast(list[Controller], get_entities_by_type(TYPE_CONTROLLER)) if x.enabled] @@ -56,17 +100,53 @@ def __init__(self): self.read_retries_left = [0] * len(self.rfid_readers) - # Pending NTAG write requests keyed by slot index. Populated via - # :meth:`submit_write` and drained inside :meth:`loop` so the write - # happens on the read thread (single-threaded RFID bus access). + # Pending safe TigerTag operations keyed by logical reader slot. They + # are drained inside :meth:`loop` so all RFID bus access stays on the read + # thread. An entry remains present while running, which makes the + # per-slot busy check and timeout cleanup race-safe. self._pending_writes: dict[int, _PendingWrite] = {} self._pending_write_lock = threading.Lock() + self._tigertag_operation_history: OrderedDict[str, dict[str, Any]] = OrderedDict() + self._tigertag_operation_history_limit = self.TIGERTAG_OPERATION_HISTORY_LIMIT + # Signal handlers only set this event. The RFID loop performs queue + # cancellation so a signal cannot deadlock on the operation lock or + # interrupt a transaction after its first page mutation. + self._shutdown_requested = threading.Event() # Most recent scan event per slot. Populated whenever exporters are # notified so the Moonraker agent API can answer ``list_channels`` # without re-scanning. self.last_scans: dict[int, dict] = {} + def _rebuild_reader_slot_mapping(self) -> None: + """Build the logical-slot lookup shared by every runtime operation.""" + reader_index_by_slot: dict[int, int] = {} + reader_slot_by_index: list[int] = [] + for reader_index, reader in enumerate(self.rfid_readers): + slot = getattr(reader, "slot", None) + if not isinstance(slot, int) or isinstance(slot, bool) or slot < 0: + raise ValueError( + f"RFID reader {getattr(reader, 'name', reader_index)!r} has invalid slot {slot!r}" + ) + if slot in reader_index_by_slot: + first_index = reader_index_by_slot[slot] + raise ValueError( + f"duplicate RFID reader slot {slot} at indexes {first_index} and {reader_index}" + ) + reader_index_by_slot[slot] = reader_index + reader_slot_by_index.append(slot) + + self._reader_index_by_slot = reader_index_by_slot + self._reader_slot_by_index = tuple(reader_slot_by_index) + + def _resolve_reader_slot(self, slot: Any) -> tuple[int, RfidReader] | None: + if not isinstance(slot, int) or isinstance(slot, bool): + return None + reader_index = self._reader_index_by_slot.get(slot) + if reader_index is None: + return None + return reader_index, self.rfid_readers[reader_index] + def _notify_exporters(self, scan: ScanResult|None, filament: GenericFilament|None, reader: RfidReader, event: ExporterEvent): slot = getattr(reader, "slot", None) if slot is not None: @@ -85,82 +165,523 @@ def _notify_exporters(self, scan: ScanResult|None, filament: GenericFilament|Non exporter.export_data(scan, filament, reader) # ------------------------------------------------------------------ - # Pending NTAG writes (used by the OpenRFID agent API). + # Pending safe TigerTag operations (used by the OpenRFID agent API). # ------------------------------------------------------------------ - def submit_write(self, slot: int, data: bytes, start_page: int = 4, timeout: float = 10.0) -> dict: - """Queue an NTAG write for ``slot`` and block until the read loop - services it (or ``timeout`` elapses). + @staticmethod + def _write_error(code: str, error: str, **details: Any) -> dict[str, Any]: + return {"ok": False, "code": code, "error": error, **details} + + def request_shutdown(self) -> None: + """Request cooperative shutdown without blocking the signal handler.""" + self._shutdown_requested.set() + + def is_shutting_down(self) -> bool: + return self._shutdown_requested.is_set() + + def _cancel_queued_tigertag_operations(self) -> int: + """Cancel queued operations while allowing a running write to finish.""" + cancelled: list[_PendingWrite] = [] + with self._pending_write_lock: + for slot, pending in list(self._pending_writes.items()): + if pending.state != "queued": + continue + self._pending_writes.pop(slot, None) + result = self._write_error( + "service_stopping", + "OpenRFID is stopping; queued tag operation was cancelled", + operation_id=pending.operation_id, + slot=pending.slot, + ) + pending.result = result + pending.state = "cancelled" + self._remember_tigertag_operation_locked( + pending, + result, + "cancelled", + ) + cancelled.append(pending) + + for pending in cancelled: + pending.event.set() + return len(cancelled) + + def _remember_tigertag_operation_locked( + self, + pending: _PendingWrite, + result: dict[str, Any], + terminal_state: str, + ) -> None: + """Store one terminal operation while the operation lock is held.""" + status = { + "ok": True, + "operation_id": pending.operation_id, + "operation_state": terminal_state, + "completed": True, + "action": pending.action, + "slot": pending.slot, + "expected_uid": pending.expected_uid.hex().upper(), + "created_at": pending.created_at, + "started_at": pending.started_at, + "completed_at": time.time(), + "result": dict(result), + } + self._tigertag_operation_history[pending.operation_id] = status + self._tigertag_operation_history.move_to_end(pending.operation_id) + while len(self._tigertag_operation_history) > self._tigertag_operation_history_limit: + self._tigertag_operation_history.popitem(last=False) + + def get_tigertag_operation_status(self, operation_id: str) -> dict[str, Any]: + """Return active state or a retained terminal result for an operation.""" + if not isinstance(operation_id, str) or not operation_id.strip(): + return self._write_error( + "invalid_operation_id", + "operation_id must be a non-empty string", + ) - Returns a result dict with at least ``ok`` (bool) and ``status`` - (int|None — FM175XX status code) and optional ``error`` (str). + with self._pending_write_lock: + for pending in self._pending_writes.values(): + if pending.operation_id == operation_id: + return { + "ok": True, + "operation_id": pending.operation_id, + "operation_state": pending.state, + "completed": False, + "action": pending.action, + "slot": pending.slot, + "expected_uid": pending.expected_uid.hex().upper(), + "created_at": pending.created_at, + "started_at": pending.started_at, + } + + retained = self._tigertag_operation_history.get(operation_id) + if retained is not None: + response = dict(retained) + response["result"] = dict(retained["result"]) + return response + + return self._write_error( + "operation_not_found", + f"unknown or expired TigerTag operation {operation_id}", + operation_id=operation_id, + ) + + @staticmethod + def _normalize_expected_uid(value: Any) -> bytes: + if isinstance(value, (bytes, bytearray)): + uid = bytes(value) + elif isinstance(value, str): + compact = re.sub(r"[\s:_-]", "", value.strip()) + if compact.lower().startswith("0x"): + compact = compact[2:] + if not compact or len(compact) % 2: + raise ValueError("expected_uid must contain an even number of hex digits") + try: + uid = bytes.fromhex(compact) + except ValueError as exc: + raise ValueError("expected_uid must be hexadecimal") from exc + else: + raise ValueError("expected_uid is required as hex text or bytes") + + if len(uid) not in (4, 7, 10): + raise ValueError("expected_uid must be a 4, 7, or 10 byte ISO14443 UID") + return uid + + def get_tigertag_write_capabilities(self, slot: int) -> dict[str, Any]: + """Describe the narrow write contract supported by one reader slot.""" + resolved = self._resolve_reader_slot(slot) + if resolved is None: + return { + "supported": False, + "code": "invalid_slot", + "slot": slot, + } + _, reader = resolved + write_impl = getattr(type(reader), "write_tigertag_maker", None) + clear_impl = getattr(type(reader), "clear_tigertag_maker", None) + supported = ( + callable(getattr(reader, "write_tigertag_maker", None)) + and callable(getattr(reader, "clear_tigertag_maker", None)) + and write_impl is not MifareUltralightReader.write_tigertag_maker + and clear_impl is not MifareUltralightReader.clear_tigertag_maker + ) + with self._pending_write_lock: + active = self._pending_writes.get(slot) + busy = active is not None + active_operation_id = active.operation_id if active is not None else None + active_state = active.state if active is not None else None + return { + "supported": supported, + "format": "tigertag", + "variant": "maker", + "expected_uid_required": True, + "payload_bytes": TIGERTAG_OWNED_LENGTH, + "start_page": 4, + "end_page": 23, + "clear_supported": supported, + "allow_unrecognized_supported": True, + "allow_legacy_migration_supported": True, + "busy": busy, + "active_operation_id": active_operation_id, + "active_state": active_state, + "service_stopping": self._shutdown_requested.is_set(), + } + + def submit_tigertag_write( + self, + slot: int, + expected_uid: str | bytes, + data: bytes, + timeout: float = 10.0, + allow_unrecognized: bool = False, + allow_legacy_migration: bool = False, + safety_check: Callable[[], dict[str, Any] | None] | None = None, + ) -> dict[str, Any]: + """Write one fixed TigerTag Maker record to pages 4..23. + + ``expected_uid`` is mandatory. It binds the operation to the tag the + caller reviewed; the low-level reader activates the tag again and + requires an exact match before touching user memory. """ - if slot < 0 or slot >= len(self.rfid_readers): - return {"ok": False, "error": f"invalid slot {slot}"} if not isinstance(data, (bytes, bytearray)): - return {"ok": False, "error": "data must be bytes"} - if len(data) == 0 or len(data) % 4 != 0: - return {"ok": False, "error": "data length must be a positive multiple of 4 bytes"} - - pending = _PendingWrite(start_page, bytes(data)) + return self._write_error("invalid_payload", "data must be bytes") + payload = bytes(data) + if len(payload) != TIGERTAG_OWNED_LENGTH: + return self._write_error( + "invalid_payload_length", + f"TigerTag Maker payload must be exactly {TIGERTAG_OWNED_LENGTH} bytes", + expected_bytes=TIGERTAG_OWNED_LENGTH, + actual_bytes=len(payload), + ) + if int.from_bytes(payload[:4], "big") != TIGERTAG_MAKER_ID: + return self._write_error( + "invalid_payload_header", + f"payload header must be TigerTag Maker 0x{TIGERTAG_MAKER_ID:08X}", + ) + return self._submit_tigertag_operation( + slot=slot, + action="write", + expected_uid=expected_uid, + data=payload, + timeout=timeout, + allow_unrecognized=allow_unrecognized, + allow_legacy_migration=allow_legacy_migration, + safety_check=safety_check, + ) + + def submit_tigertag_clear( + self, + slot: int, + expected_uid: str | bytes, + timeout: float = 10.0, + allow_unrecognized: bool = False, + allow_legacy_migration: bool = False, + safety_check: Callable[[], dict[str, Any] | None] | None = None, + ) -> dict[str, Any]: + """Clear exactly pages 4..23 after the same guarded tag checks.""" + return self._submit_tigertag_operation( + slot=slot, + action="clear", + expected_uid=expected_uid, + data=None, + timeout=timeout, + allow_unrecognized=allow_unrecognized, + allow_legacy_migration=allow_legacy_migration, + safety_check=safety_check, + ) + + def _submit_tigertag_operation( + self, + slot: int, + action: str, + expected_uid: str | bytes, + data: bytes | None, + timeout: float, + allow_unrecognized: bool, + allow_legacy_migration: bool, + safety_check: Callable[[], dict[str, Any] | None] | None, + ) -> dict[str, Any]: + if self._resolve_reader_slot(slot) is None: + return self._write_error("invalid_slot", f"invalid slot {slot}") + if not isinstance(allow_unrecognized, bool): + return self._write_error( + "invalid_allow_unrecognized", + "allow_unrecognized must be a boolean", + ) + if not isinstance(allow_legacy_migration, bool): + return self._write_error( + "invalid_allow_legacy_migration", + "allow_legacy_migration must be a boolean", + ) + if safety_check is not None and not callable(safety_check): + return self._write_error( + "invalid_safety_check", + "safety_check must be callable", + ) + try: + uid = self._normalize_expected_uid(expected_uid) + except ValueError as exc: + return self._write_error("invalid_uid", str(exc)) + try: + wait_seconds = float(timeout) + except (TypeError, ValueError): + return self._write_error("invalid_timeout", "timeout must be a positive number") + if not math.isfinite(wait_seconds) or wait_seconds <= 0: + return self._write_error("invalid_timeout", "timeout must be a positive finite number") + + pending = _PendingWrite( + operation_id=uuid.uuid4().hex, + slot=slot, + action=action, + expected_uid=uid, + data=data, + allow_unrecognized=allow_unrecognized, + allow_legacy_migration=allow_legacy_migration, + safety_check=safety_check, + ) with self._pending_write_lock: + if self._shutdown_requested.is_set(): + return self._write_error( + "service_stopping", + "OpenRFID is stopping; refusing a new tag operation", + slot=slot, + ) + active = self._pending_writes.get(slot) + if active is not None: + return self._write_error( + "slot_busy", + f"slot {slot} already has a tag operation in progress", + slot=slot, + active_operation_id=active.operation_id, + active_state=active.state, + ) self._pending_writes[slot] = pending # Use the existing scan-trigger path so auto/manual modes both wake. - self.start_reading_tag(slot) - - if not pending.event.wait(timeout=timeout): + try: + self.start_reading_tag(slot) + except Exception as exc: + failure = self._write_error( + "queue_wakeup_failed", + f"could not wake the RFID loop: {exc}", + operation_id=pending.operation_id, + slot=slot, + ) + cancelled = False with self._pending_write_lock: + if self._pending_writes.get(slot) is pending and pending.state == "queued": + self._pending_writes.pop(slot, None) + pending.state = "cancelled" + pending.result = failure + self._remember_tigertag_operation_locked(pending, failure, "cancelled") + cancelled = True + if cancelled: + pending.event.set() + return failure + logging.warning( + "RFID loop wake failed after operation %s started; waiting for its result", + pending.operation_id, + ) + + if pending.event.wait(timeout=wait_seconds): + return pending.result or self._write_error( + "missing_result", + "tag operation completed without a result", + operation_id=pending.operation_id, + ) + + # Only cancel this exact operation while it is still queued. Once the + # read loop marks it running, leave it registered and busy: removing a + # running request would allow another operation to touch the same bus. + cancelled_result: dict[str, Any] | None = None + with self._pending_write_lock: + current = self._pending_writes.get(slot) + if pending.result is not None: + return pending.result + if current is pending and pending.state == "queued": self._pending_writes.pop(slot, None) - return {"ok": False, "error": "timeout waiting for write"} - - return pending.result or {"ok": False, "error": "no result"} + pending.state = "cancelled" + cancelled_result = self._write_error( + "timeout", + "timeout waiting for tag operation; queued operation was cancelled", + operation_id=pending.operation_id, + slot=slot, + ) + pending.result = cancelled_result + self._remember_tigertag_operation_locked( + pending, + cancelled_result, + "cancelled", + ) + elif current is pending: + return self._write_error( + "timeout_in_progress", + "timeout expired after tag operation started; slot remains busy until it finishes", + operation_id=pending.operation_id, + slot=slot, + operation_state=pending.state, + ) + + if cancelled_result is not None: + pending.event.set() + return cancelled_result + + return self._write_error( + "timeout", + "timeout waiting for tag operation", + operation_id=pending.operation_id, + slot=slot, + ) + + def _drain_pending_write(self, slot: int) -> bool: + """Run one queued safe tag operation on the RFID loop thread.""" + resolved = self._resolve_reader_slot(slot) + if resolved is None: + return False + reader_index, reader = resolved - def _drain_pending_write(self, slot: int, reader: RfidReader) -> bool: - """If a write is pending for ``slot``, perform it now and signal the - waiting submitter. Returns ``True`` when a write was handled (and the - normal read for this iteration should be skipped). - """ with self._pending_write_lock: - pending = self._pending_writes.pop(slot, None) + pending = self._pending_writes.get(slot) + cancel_for_shutdown = ( + pending is not None + and pending.state == "queued" + and self._shutdown_requested.is_set() + ) + should_run = ( + pending is not None + and pending.state == "queued" + and not cancel_for_shutdown + ) + if cancel_for_shutdown: + self._pending_writes.pop(slot, None) + result = self._write_error( + "service_stopping", + "OpenRFID is stopping; queued tag operation was cancelled", + operation_id=pending.operation_id, + slot=pending.slot, + ) + pending.result = result + pending.state = "cancelled" + self._remember_tigertag_operation_locked( + pending, + result, + "cancelled", + ) + elif pending is not None and pending.state == "queued": + pending.state = "running" + pending.started_at = time.time() if pending is None: return False - - write = getattr(reader, "write_ntag_pages", None) - if not callable(write): - pending.result = {"ok": False, "error": "reader does not support NTAG writes"} + if cancel_for_shutdown: pending.event.set() return True + if not should_run: + return True + + # Consume the wake-up used to run the operation. A successful result + # schedules a separate read below so callers can verify fresh content. + self.read_retries_left[reader_index] = 0 + method_name = "write_tigertag_maker" if pending.action == "write" else "clear_tigertag_maker" + operation = getattr(reader, method_name, None) + result: dict[str, Any] try: - status = write(pending.start_page, pending.data) - pending.result = { - "ok": status == 0, - "status": status, - "start_page": pending.start_page, - "bytes_written": len(pending.data), - } + if not callable(operation): + result = self._write_error( + "reader_not_supported", + f"reader does not support safe TigerTag {pending.action} operations", + ) + else: + session_started = False + try: + reader.start_session() + session_started = True + def operation_safety_check() -> dict[str, Any] | None: + if self._shutdown_requested.is_set(): + return self._write_error( + "service_stopping", + "OpenRFID is stopping; refusing to begin tag mutation", + phase="pre_write", + ) + if pending.safety_check is not None: + return pending.safety_check() + return None + + if pending.action == "write": + kwargs = { + "allow_unrecognized": pending.allow_unrecognized, + "allow_legacy_migration": pending.allow_legacy_migration, + } + kwargs["safety_check"] = operation_safety_check + result = operation(pending.expected_uid, pending.data, **kwargs) + else: + kwargs = { + "allow_unrecognized": pending.allow_unrecognized, + "allow_legacy_migration": pending.allow_legacy_migration, + } + kwargs["safety_check"] = operation_safety_check + result = operation(pending.expected_uid, **kwargs) + if not isinstance(result, dict) or "ok" not in result or "code" not in result: + result = self._write_error( + "invalid_reader_result", + "reader returned an invalid safe-write result", + ) + finally: + if session_started: + reader.end_session() except Exception as exc: - logging.exception("NTAG write failed on slot %d", slot) - pending.result = {"ok": False, "error": str(exc)} + logging.exception("Safe TigerTag %s failed on slot %d", pending.action, slot) + result = self._write_error("reader_exception", str(exc)) finally: + result.setdefault("operation_id", pending.operation_id) + result.setdefault("action", pending.action) + result.setdefault("slot", slot) + result.setdefault("expected_uid", pending.expected_uid.hex().upper()) + if result.get("ok") is True: + reader.last_read_uid = None + self.last_scans.pop(slot, None) + if not self._shutdown_requested.is_set(): + try: + self.start_reading_tag(slot) + except Exception: + logging.exception( + "Could not schedule verification scan after TigerTag %s on slot %d", + pending.action, + slot, + ) + with self._pending_write_lock: + if self._pending_writes.get(slot) is pending: + self._pending_writes.pop(slot, None) + pending.result = result + pending.state = "completed" + self._remember_tigertag_operation_locked(pending, result, "completed") pending.event.set() return True def start_reading_tag(self, slot: int): - if slot < 0 or slot >= len(self.rfid_readers): + resolved = self._resolve_reader_slot(slot) + if resolved is None: logging.error(f"Invalid slot number: {slot}") return - - self.read_retries_left[slot] = self.config.read_retries + + reader_index, _ = resolved + self.read_retries_left[reader_index] = self.config.read_retries logging.info(f"Received request to read tag on slot {slot} with {self.config.read_retries} retries") def loop(self): - while True: + try: + self._loop_until_shutdown() + finally: + self._cancel_queued_tigertag_operations() + + def _loop_until_shutdown(self): + while not self._shutdown_requested.is_set(): for i, reader in enumerate(self.rfid_readers): + if self._shutdown_requested.is_set(): + break + slot = self._reader_slot_by_index[i] # Pending writes take priority over reads — they have an # impatient caller blocked on them. Skip the read this tick. - if self._drain_pending_write(i, reader): - self.read_retries_left[i] = 0 + if self._drain_pending_write(slot): continue if not self.config.auto_read_mode and self.read_retries_left[i] <= 0: @@ -197,7 +718,7 @@ def loop(self): self._notify_exporters(None, None, reader, ExporterEvent.TAG_NOT_PRESENT) self.read_retries_left[i] = 0 - time.sleep(self.config.read_interval_seconds) + self._shutdown_requested.wait(self.config.read_interval_seconds) def process_reader_single(self, reader: RfidReader) -> tuple[ScanResult|None, GenericFilament|None, bool]: reader.start_session() @@ -285,4 +806,4 @@ def process_mifare_ultralight(self, reader: MifareUltralightReader, scan_result: return None, False else: logging.warning("Failed to read MIFARE Ultralight card data") - return None, True \ No newline at end of file + return None, True diff --git a/src/tag/anycubic/processor.py b/src/tag/anycubic/processor.py index 4823abc..1924ff6 100644 --- a/src/tag/anycubic/processor.py +++ b/src/tag/anycubic/processor.py @@ -8,6 +8,8 @@ # Adapted from https://github.com/DnG-Crafts/ACE-RFID class AnycubicTagProcessor(MifareUltralightTagProcessor): + tag_format = "anycubic" + def __init__(self, config : dict): super().__init__(config) @@ -59,6 +61,7 @@ def process_tag(self, scan_result: ScanResult, data: bytes) -> GenericFilament | return GenericFilament( source_processor=self.name, + tag_format=self.tag_format, unique_id=GenericFilament.generate_unique_id("Anycubic", sku, brand, filament_type, argb, filament_length_m), manufacturer=brand, type=filament_types[0] if filament_types else "PLA", @@ -72,4 +75,4 @@ def process_tag(self, scan_result: ScanResult, data: bytes) -> GenericFilament | drying_temp_c=0, drying_time_hours=0, manufacturing_date="0001-01-01" - ) \ No newline at end of file + ) diff --git a/src/tag/bambu/processor.py b/src/tag/bambu/processor.py index 33fe372..5549232 100644 --- a/src/tag/bambu/processor.py +++ b/src/tag/bambu/processor.py @@ -9,6 +9,8 @@ import tag.binary as binary class BambuTagProcessor(MifareClassicTagProcessor): + tag_format = "bambu" + def __init__(self, config : dict): super().__init__(config) @@ -116,6 +118,7 @@ def process_tag(self, scan_result: ScanResult, data: bytes) -> GenericFilament | return GenericFilament( source_processor=self.name, + tag_format=self.tag_format, unique_id=GenericFilament.generate_unique_id("Bambu Lab", filament_type, detailed_type, argb_color, production_datetime), manufacturer="Bambu", type=filament_type, @@ -157,4 +160,4 @@ def __parse_production_date(self, date_str: str) -> str: return f"{year}-{month}-{day}" except: pass - return "1970-01-01" # Default fallback date \ No newline at end of file + return "1970-01-01" # Default fallback date diff --git a/src/tag/creality/processor.py b/src/tag/creality/processor.py index cc7338f..6aede07 100644 --- a/src/tag/creality/processor.py +++ b/src/tag/creality/processor.py @@ -7,6 +7,8 @@ from . import constants as Constants class CrealityTagProcessor(MifareClassicTagProcessor): + tag_format = "creality" + def __init__(self, config : dict): super().__init__(config) @@ -100,6 +102,7 @@ def process_tag(self, scan_result: ScanResult, data: bytes) -> GenericFilament | return GenericFilament( source_processor=self.name, + tag_format=self.tag_format, unique_id=GenericFilament.generate_unique_id("Creality", data_str), manufacturer="Creality", type=extra_data.type, @@ -136,4 +139,4 @@ def __hkdf_create_key(self, uid: bytes) -> TagAuthentication: keys_a[1] = list(derived_key) - return TagAuthentication(keys_a, keys_b) \ No newline at end of file + return TagAuthentication(keys_a, keys_b) diff --git a/src/tag/elegoo/processor.py b/src/tag/elegoo/processor.py index 28b53d8..78d46af 100644 --- a/src/tag/elegoo/processor.py +++ b/src/tag/elegoo/processor.py @@ -6,6 +6,8 @@ from . import constants as Constants class ElegooTagProcessor(MifareUltralightTagProcessor): + tag_format = "elegoo" + def __init__(self, config : dict): super().__init__(config) @@ -44,6 +46,7 @@ def process_tag(self, scan_result: ScanResult, data: bytes) -> GenericFilament | return GenericFilament( source_processor=self.name, + tag_format=self.tag_format, unique_id=GenericFilament.generate_unique_id("Elegoo", material_type, material_subtype, argb, diameter, weight_grams), manufacturer="Elegoo", type=material.material_type, @@ -57,4 +60,4 @@ def process_tag(self, scan_result: ScanResult, data: bytes) -> GenericFilament | drying_temp_c=0, drying_time_hours=0, manufacturing_date="0001-01-01" - ) \ No newline at end of file + ) diff --git a/src/tag/openspool/processor.py b/src/tag/openspool/processor.py index f8eb523..02bee3d 100644 --- a/src/tag/openspool/processor.py +++ b/src/tag/openspool/processor.py @@ -7,6 +7,8 @@ # Adapted from https://github.com/paxx12/SnapmakerU1-Extended-Firmware/blob/3c97d1d80309d817ad37f2daac8e436712cc7865/overlays/firmware-extended/13-rfid-support/root/home/lava/klipper/klippy/extras/filament_protocol_ndef.py class OpenspoolTagProcessor(NdefTagProcessor): + tag_format = "openspool" + def __init__(self, config : dict): super().__init__(config) @@ -76,6 +78,7 @@ def __openspool_parse_payload(self, payload : bytes) -> GenericFilament | None: return GenericFilament( source_processor=self.name, + tag_format=self.tag_format, unique_id=GenericFilament.generate_unique_id("OpenSpool", brand, main_type, subtype, color_argb), manufacturer=brand, type=main_type, @@ -104,4 +107,4 @@ def __parse_color_hex(self, value : str): hex_str = hex_str[1:] return int(hex_str, 16) except (ValueError, TypeError): - return 0xFFFFFF \ No newline at end of file + return 0xFFFFFF diff --git a/src/tag/qidi/processor.py b/src/tag/qidi/processor.py index 2ddca00..027874a 100644 --- a/src/tag/qidi/processor.py +++ b/src/tag/qidi/processor.py @@ -6,6 +6,8 @@ from .constants import MATERIALS, COLORS class QidiTagProcessor(MifareClassicTagProcessor): + tag_format = "qidi" + def __init__(self, config : dict): super().__init__(config) @@ -53,6 +55,7 @@ def process_tag(self, scan_result: ScanResult, data: bytes) -> GenericFilament | return GenericFilament( source_processor=self.name, + tag_format=self.tag_format, unique_id=GenericFilament.generate_unique_id("QIDI", material_code, color_code, manufacturer_code), manufacturer="QIDI", type=type, @@ -66,4 +69,4 @@ def process_tag(self, scan_result: ScanResult, data: bytes) -> GenericFilament | drying_temp_c=0, drying_time_hours=0, manufacturing_date="0001-01-01" - ) \ No newline at end of file + ) diff --git a/src/tag/snapmaker/processor.py b/src/tag/snapmaker/processor.py index 6819cd4..ec4ba78 100644 --- a/src/tag/snapmaker/processor.py +++ b/src/tag/snapmaker/processor.py @@ -11,6 +11,8 @@ from cryptography.exceptions import InvalidSignature class SnapmakerTagProcessor(MifareClassicTagProcessor): + tag_format = "snapmaker" + def __init__(self, config : dict): super().__init__(config) @@ -126,6 +128,7 @@ def process_tag(self, scan_result : ScanResult, data : bytes) -> GenericFilament return GenericFilament( source_processor=self.name, + tag_format=self.tag_format, unique_id=GenericFilament.generate_unique_id("Snapmaker", vendor, manufacturer, main_type, sub_type, argb_color, weight_grams, sku, tray), manufacturer=vendor, type=main_type, @@ -209,4 +212,4 @@ def __convert_to_int(self, byte_data: bytes) -> int: result = 0 for byte in byte_data: result = (result << 8) | byte - return result \ No newline at end of file + return result diff --git a/src/tag/spoolease/processor.py b/src/tag/spoolease/processor.py index 59353e0..f1f3c42 100644 --- a/src/tag/spoolease/processor.py +++ b/src/tag/spoolease/processor.py @@ -7,6 +7,8 @@ class SpooleaseTagProcessor(NdefTagProcessor): + tag_format = "spoolease" + def __init__(self, config: dict): super().__init__(config) @@ -69,6 +71,7 @@ def __parse_spoolease_payload(self, payload: bytes) -> GenericFilament | None: return GenericFilament( source_processor=self.name, + tag_format=self.tag_format, unique_id=GenericFilament.generate_unique_id( "SpoolEase", brand, material, subtype, *colors, weight_grams ), diff --git a/src/tag/tag_processor.py b/src/tag/tag_processor.py index ce7e34a..2222604 100644 --- a/src/tag/tag_processor.py +++ b/src/tag/tag_processor.py @@ -3,9 +3,13 @@ from config import ConfigurableEntity, TYPE_TAG_PROCESSOR class TagProcessor(ConfigurableEntity): + # Stable payload-format identity. Concrete processors override this so the + # wire value does not depend on a user-renamed configuration section. + tag_format = "unknown" + def __init__(self, config : dict): super().__init__(config, TYPE_TAG_PROCESSOR) def process_tag(self, scan_result : ScanResult, data : bytes) -> GenericFilament | None: """Process a scanned tag and its data, returning a GenericFilament if recognized, else None.""" - raise NotImplementedError("Subclasses must implement this method") \ No newline at end of file + raise NotImplementedError("Subclasses must implement this method") diff --git a/src/tag/tigertag/constants.py b/src/tag/tigertag/constants.py index d2b7e13..c111a96 100644 --- a/src/tag/tigertag/constants.py +++ b/src/tag/tigertag/constants.py @@ -1,4 +1,7 @@ -TIGERTAG_VALID_DATA_IDS = {0x5BF59264, 0xBC0FCB97} +from tigertag import ID_TIGERTAG, ID_TIGERTAG_INIT, ID_TIGERTAG_PLUS + + +TIGERTAG_VALID_DATA_IDS = {ID_TIGERTAG, ID_TIGERTAG_PLUS, ID_TIGERTAG_INIT} TIGERTAG_EPOCH_OFFSET = 946684800 @@ -32,6 +35,6 @@ MESSAGE_LENGTH = 28 OFF_SIGNATURE = 80 -MIN_DATA_LENGTH = 96 +MIN_DATA_LENGTH = 80 USER_DATA_PAGE_OFFSET = 4 USER_DATA_BYTE_OFFSET = USER_DATA_PAGE_OFFSET * 4 diff --git a/src/tag/tigertag/encoder.py b/src/tag/tigertag/encoder.py index c8b2b8d..0703b79 100644 --- a/src/tag/tigertag/encoder.py +++ b/src/tag/tigertag/encoder.py @@ -1,281 +1,471 @@ -"""TigerTag 96-byte payload encoder. +"""Official-SDK adapter for unsigned TigerTag Maker payloads. -The encoder mirrors the byte layout consumed by :mod:`tag.tigertag.processor`. -Output is the 96-byte block written starting at NTAG215 user page 4 -(``Constants.USER_DATA_PAGE_OFFSET``), suitable for handing to -:meth:`reader.fm175xx.rfid.Fm175xx.write_ntag_pages`. - -The encoder loads label-to-id lookup tables from the bundled TigerTag JSON -database under ``tag/tigertag/database/`` (same directory used by -:mod:`tag.tigertag.registry`) so callers can pass either numeric ids or -human-readable labels for the registry-backed fields. +The public functions in this module intentionally retain the field names used +by OpenRFID's original encoder while delegating the protocol model and binary +serialization to the official tigertag package. New user-written tags are +always canonical 80-byte Maker payloads occupying NTAG pages 4 through 23. """ from __future__ import annotations -import json +import math import threading -import time -from datetime import datetime, timezone -from pathlib import Path +from copy import deepcopy +from datetime import date, datetime, timezone from typing import Any -from . import constants as Constants - - -_DB_DIRECTORY = Path(__file__).resolve().parent / "database" - -# label.lower() -> id, per registry kind -_LABEL_INDEX_CACHE: dict[str, dict[str, int]] | None = None -_LABEL_INDEX_LOCK = threading.Lock() - -_DB_FILES = { - "material": "id_material.json", - "brand": "id_brand.json", - "aspect": "id_aspect.json", - "diameter": "id_diameter.json", - "unit": "id_measure_unit.json", - "type": "id_type.json", +from tigertag import ( + ID_TIGERTAG, + MAKER_PRODUCT_ID, + TigerTag, + TigerTagDB, + __version__ as TIGERTAG_SDK_VERSION, +) + + +USER_DATA_START_PAGE = 4 +USER_DATA_END_PAGE = 23 +PAYLOAD_LENGTH_BYTES = 80 +PAYLOAD_PAGE_COUNT = PAYLOAD_LENGTH_BYTES // 4 + +TIGERTAG_TAG_ID = ID_TIGERTAG +TIGERTAG_MAKER_PRODUCT_ID = MAKER_PRODUCT_ID +TIGERTAG_SDK_COMMIT = "f3e2e2e8a1fdf88f91fb43ca4b5c5fbfb88f81af" + +_TIGERTAG_EPOCH = datetime(2000, 1, 1, tzinfo=timezone.utc) +_DB: TigerTagDB | None = None +_DB_LOCK = threading.Lock() + +_DB_TABLES = { + "material": "_materials", + "aspect": "_aspects", + "type": "_types", + "diameter": "_diameters", + "brand": "_brands", + "unit": "_units", +} +_ID_MAXIMUMS = { + "material": 0xFFFF, + "brand": 0xFFFF, + "aspect": 0xFF, + "type": 0xFF, + "diameter": 0xFF, + "unit": 0xFF, } -# Pages 4..N inclusive: TigerTag user payload starts at user-data page 4. -USER_DATA_START_PAGE = Constants.USER_DATA_PAGE_OFFSET -PAYLOAD_LENGTH_BYTES = Constants.MIN_DATA_LENGTH -# Tag IDs accepted by the parser; the encoder always emits the unsigned -# variant. Both values are accepted on read. -TIGERTAG_TAG_ID = 0xBC0FCB97 +def _database() -> TigerTagDB: + global _DB + with _DB_LOCK: + if _DB is None: + _DB = TigerTagDB() + return _DB -def _load_db_file(name: str) -> list[dict]: - path = _DB_DIRECTORY / name - try: - with path.open("r", encoding="utf-8") as handle: - payload = json.load(handle) - except (OSError, json.JSONDecodeError): - return [] - if isinstance(payload, list): - return [item for item in payload if isinstance(item, dict)] - if isinstance(payload, dict): - for key in ("data", "items", "results", "response", "content"): - value = payload.get(key) - if isinstance(value, list): - return [item for item in value if isinstance(item, dict)] - return [] - - -def _build_label_index(records: list[dict]) -> dict[str, int]: - out: dict[str, int] = {} +def _option_records(kind: str, metadata_keys: tuple[str, ...] = ()) -> list[dict[str, Any]]: + """Return a detached, deterministic, JSON-safe view of one SDK table.""" + records = getattr(_database(), _DB_TABLES[kind]) + options: list[dict[str, Any]] = [] for record in records: - rid = record.get("id") - if rid is None: - continue - try: - rid_int = int(rid) - except (TypeError, ValueError): + if not isinstance(record, dict) or record.get("id") is None: continue - for key in ("label", "name", "title", "description", "symbol", "value"): - label = record.get(key) - if isinstance(label, (int, float)): - label = str(label) - if isinstance(label, str) and label.strip(): - out[label.strip().lower()] = rid_int - break - return out - - -def _label_indexes() -> dict[str, dict[str, int]]: - global _LABEL_INDEX_CACHE - with _LABEL_INDEX_LOCK: - if _LABEL_INDEX_CACHE is None: - _LABEL_INDEX_CACHE = { - kind: _build_label_index(_load_db_file(filename)) - for kind, filename in _DB_FILES.items() - } - return _LABEL_INDEX_CACHE + option: dict[str, Any] = { + "id": int(record["id"]), + "label": str(TigerTagDB.label(record)), + } + for key in metadata_keys: + if key in record: + option[key] = deepcopy(record[key]) + options.append(option) + return sorted(options, key=lambda option: (option["label"].casefold(), option["id"])) + + +def get_options() -> dict[str, Any]: + """Return official SDK registry choices for a TigerTag authoring form. + + The result is safe to serialize directly as JSON and does not expose the + SDK's mutable internal database lists. Material recommendations are + database enrichment for form defaults only; they are not claimed as fields + currently present on a scanned tag. + """ + return { + "schema_version": 1, + "sdk": { + "name": "tigertag", + "version": TIGERTAG_SDK_VERSION, + "commit": TIGERTAG_SDK_COMMIT, + }, + "materials": _option_records( + "material", + ( + "material_type", + "filled_type", + "density", + "filled", + "product_type_id", + "recommended", + "metadata", + ), + ), + "brands": _option_records("brand"), + "aspects": _option_records("aspect", ("color_count",)), + "types": _option_records("type"), + "diameters": _option_records("diameter"), + "units": _option_records("unit", ("type",)), + } + + +def _first(spec: dict[str, Any], *keys: str, default: Any = None) -> Any: + for key in keys: + if key in spec and spec[key] is not None and spec[key] != "": + return spec[key] + return default -def _resolve_id(kind: str, value: Any, default: int = 0) -> int: +def _bounded_int( + name: str, + value: Any, + *, + minimum: int = 0, + maximum: int, + default: int = 0, +) -> int: if value is None or value == "": return default if isinstance(value, bool): - return default - if isinstance(value, int): - return value - if isinstance(value, float): - return int(value) - if isinstance(value, str): - stripped = value.strip() - if not stripped: - return default - if stripped.isdigit(): - return int(stripped) - return _label_indexes().get(kind, {}).get(stripped.lower(), default) - return default + raise ValueError(f"{name} must be an integer") + try: + numeric = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be an integer") from exc + if not math.isfinite(numeric) or not numeric.is_integer(): + raise ValueError(f"{name} must be an integer") + result = int(numeric) + if not minimum <= result <= maximum: + raise ValueError(f"{name} must be between {minimum} and {maximum}") + return result -def _utf8_message_bytes(text: Any, max_bytes: int = Constants.MESSAGE_LENGTH) -> bytes: - """Encode ``text`` to UTF-8, truncated to ``max_bytes`` without splitting a - codepoint, right-padded with zeros.""" - if not text: - return b"\x00" * max_bytes - enc = str(text).encode("utf-8") - if len(enc) > max_bytes: - end = max_bytes - while end > 0 and (enc[end] & 0xC0) == 0x80: - end -= 1 - enc = enc[:end] - return enc + b"\x00" * (max_bytes - len(enc)) - - -def _hex_to_rgba(value: Any) -> tuple[int, int, int, int]: - """Parse ``#RRGGBB`` / ``RRGGBB`` / ``RRGGBBAA`` / ``[r,g,b,a?]`` to - ``(R, G, B, A)``. Default is opaque black.""" - default = (0, 0, 0, 0xFF) - if isinstance(value, (list, tuple)) and len(value) >= 3: - r = int(value[0]) & 0xFF - g = int(value[1]) & 0xFF - b = int(value[2]) & 0xFF - a = int(value[3]) & 0xFF if len(value) >= 4 else 0xFF - return (r, g, b, a) +def _resolve_id(kind: str, value: Any, default: int = 0) -> int: + if value is None or value == "": + return default + if isinstance(value, bool): + raise ValueError(f"{kind} must be a label or numeric ID") + if isinstance(value, (int, float)): + return _bounded_int(kind, value, maximum=_ID_MAXIMUMS[kind]) if not isinstance(value, str): + raise ValueError(f"{kind} must be a label or numeric ID") + + text = value.strip() + if not text: return default - s = value.strip().lstrip("#") - if len(s) == 6: - try: - return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16), 0xFF) - except ValueError: - return default - if len(s) == 8: + try: + numeric_id = int(text, 0) + except ValueError: try: - return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16), int(s[6:8], 16)) + numeric_id = int(text, 10) except ValueError: - return default - return default + numeric_id = None + if numeric_id is not None: + return _bounded_int(kind, numeric_id, maximum=_ID_MAXIMUMS[kind]) + + records = getattr(_database(), _DB_TABLES[kind]) + wanted = text.casefold() + for record in records: + labels = ( + record.get("label"), + record.get("name"), + record.get("title"), + record.get("symbol"), + record.get("value"), + ) + if any(str(label).strip().casefold() == wanted for label in labels if label is not None): + return _bounded_int(kind, record.get("id"), maximum=_ID_MAXIMUMS[kind]) + raise ValueError(f"Unknown TigerTag {kind}: {value!r}") -def _resolve_mfg_timestamp(mfg_raw: Any) -> int: - """Convert a manufacturing-date spec into a TigerTag timestamp (seconds - since 2000-01-01). Falls back to ``now`` on parse failure.""" - if isinstance(mfg_raw, bool): - return int(time.time()) - Constants.TIGERTAG_EPOCH_OFFSET - if isinstance(mfg_raw, (int, float)): - return int(mfg_raw) - if isinstance(mfg_raw, str) and mfg_raw.strip(): - s = mfg_raw.strip() - for fmt in ( - "%Y-%m-%dT%H:%M:%S", - "%Y-%m-%dT%H:%M", - "%Y-%m-%d %H:%M:%S", - "%Y-%m-%d", + +def _parse_color(value: Any, *, primary: bool) -> tuple[int, int, int, int]: + default_alpha = 0xFF if primary else 0 + if value is None or value == "": + return (0, 0, 0, default_alpha) + + if isinstance(value, bool): + raise ValueError("color must be #RRGGBB, #RRGGBBAA, ARGB, or RGB(A) channels") + + if isinstance(value, int): + if not 0 <= value <= 0xFFFFFFFF: + raise ValueError("integer color must fit in 32 bits") + if value <= 0xFFFFFF: + return ((value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF, default_alpha) + # GenericFilament's integer representation is AARRGGBB. + return ((value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF, (value >> 24) & 0xFF) + + if isinstance(value, str): + text = value.strip().lstrip("#") + if len(text) not in (6, 8): + raise ValueError("hex color must contain 6 RGB or 8 RGBA digits") + try: + channels = tuple(int(text[offset:offset + 2], 16) for offset in range(0, len(text), 2)) + except ValueError as exc: + raise ValueError("hex color contains non-hexadecimal characters") from exc + if len(channels) == 3: + return (*channels, default_alpha) + return channels + + if isinstance(value, (list, tuple)) and len(value) in (3, 4): + channels = tuple( + _bounded_int("color channel", channel, maximum=0xFF) + for channel in value + ) + if len(channels) == 3: + return (*channels, default_alpha) + return channels + + raise ValueError("color must be #RRGGBB, #RRGGBBAA, ARGB, or RGB(A) channels") + + +def _color_sources(spec: dict[str, Any]) -> tuple[Any, Any, Any]: + sources: list[Any] = [] + colors = spec.get("colors") + if isinstance(colors, (list, tuple)) and colors: + if ( + len(colors) in (3, 4) + and all(isinstance(value, (int, float)) for value in colors) + and all(0 <= float(value) <= 0xFF for value in colors) ): - try: - # Parse as naive UTC (matches parser, which uses UTC for the inverse). - dt = datetime.strptime(s[: len(fmt) + 4], fmt).replace(tzinfo=timezone.utc) - return int(dt.timestamp()) - Constants.TIGERTAG_EPOCH_OFFSET - except (ValueError, OverflowError): - continue - return int(time.time()) - Constants.TIGERTAG_EPOCH_OFFSET - - -def encode(spec: dict) -> bytes: - """Encode a TigerTag spec dict into a 96-byte user-data block. - - All multi-byte fields are big-endian. The signature region (bytes 80..95) - is left zeroed; the parser does not enforce a signature. - - Spec fields (all optional unless noted):: - - material: str label or numeric id (default 0) - brand: str label or numeric id (default 0) - aspect_1: str label or numeric id (default 0) - aspect_2: str label or numeric id (default 0) - type: str label or numeric id (default 0) - diameter: str label or numeric id (e.g. "1.75") - product_id: int 0..0xFFFFFFFF (default 0) - color: "#RRGGBB" / "RRGGBBAA" / [r,g,b,a] (default opaque black) - weight_g: int 0..16777215 (3-byte BE) - unit: str label or numeric id (default 0) - temp_min_c: int 0..65535 - temp_max_c: int 0..65535 - dry_temp_c: int 0..255 - dry_time_h: int 0..255 - bed_temp_min_c: int 0..255 - bed_temp_max_c: int 0..255 - td_mm: float 0..6553.5 (encoded as round(mm * 10)) - td: int 0..65535 (legacy raw, ignored if td_mm given) - manufacturing_date: ISO date/datetime str or epoch number - message: str up to 28 UTF-8 bytes (written at OFF_MESSAGE) - """ - if not isinstance(spec, dict): - spec = {} + sources.append(colors) + else: + sources.extend(colors[:3]) + + while len(sources) < 3: + index = len(sources) + 1 + aliases = { + 1: ("color_1", "color1", "color"), + 2: ("color_2", "color2", "secondary_color"), + 3: ("color_3", "color3", "tertiary_color"), + }[index] + sources.append(_first(spec, *aliases)) + + return sources[0], sources[1], sources[2] + + +def _timestamp_from_spec(spec: dict[str, Any]) -> int | None: + raw_timestamp = _first(spec, "timestamp") + if raw_timestamp is not None: + return _bounded_int("timestamp", raw_timestamp, maximum=0xFFFFFFFF) + + value = _first(spec, "manufacturing_date", "mfg_date") + if value is None: + return None + if isinstance(value, bool): + raise ValueError("manufacturing_date must be an ISO date or TigerTag timestamp") + if isinstance(value, (int, float)): + return _bounded_int("manufacturing_date", value, maximum=0xFFFFFFFF) + + if isinstance(value, datetime): + parsed = value + elif isinstance(value, date): + parsed = datetime(value.year, value.month, value.day, tzinfo=timezone.utc) + elif isinstance(value, str): + text = value.strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError as exc: + raise ValueError("manufacturing_date must be an ISO-8601 date or datetime") from exc + else: + raise ValueError("manufacturing_date must be an ISO date or TigerTag timestamp") + + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + seconds = int((parsed.astimezone(timezone.utc) - _TIGERTAG_EPOCH).total_seconds()) + return _bounded_int("manufacturing_date", seconds, maximum=0xFFFFFFFF) + - buf = bytearray(PAYLOAD_LENGTH_BYTES) - - # Header / IDs - buf[Constants.OFF_TAG_ID:Constants.OFF_TAG_ID + 4] = TIGERTAG_TAG_ID.to_bytes(4, "big") - pid = int(spec.get("product_id", 0) or 0) & 0xFFFFFFFF - buf[Constants.OFF_PRODUCT_ID:Constants.OFF_PRODUCT_ID + 4] = pid.to_bytes(4, "big") - mid = _resolve_id("material", spec.get("material")) - buf[Constants.OFF_MATERIAL_ID:Constants.OFF_MATERIAL_ID + 2] = (mid & 0xFFFF).to_bytes(2, "big") - buf[Constants.OFF_ASPECT1_ID] = _resolve_id("aspect", spec.get("aspect_1")) & 0xFF - buf[Constants.OFF_ASPECT2_ID] = _resolve_id("aspect", spec.get("aspect_2")) & 0xFF - buf[Constants.OFF_TYPE_ID] = _resolve_id("type", spec.get("type")) & 0xFF - buf[Constants.OFF_DIAMETER_ID] = _resolve_id("diameter", spec.get("diameter")) & 0xFF - bid = _resolve_id("brand", spec.get("brand")) - buf[Constants.OFF_BRAND_ID:Constants.OFF_BRAND_ID + 2] = (bid & 0xFFFF).to_bytes(2, "big") - - # Color (RGBA, written R,G,B,A) - r, g, b, a = _hex_to_rgba(spec.get("color")) - buf[Constants.OFF_COLOR_RGBA + 0] = r - buf[Constants.OFF_COLOR_RGBA + 1] = g - buf[Constants.OFF_COLOR_RGBA + 2] = b - buf[Constants.OFF_COLOR_RGBA + 3] = a - - # Weight (3-byte BE) + unit - weight = max(0, min(int(spec.get("weight_g", 0) or 0), 0xFFFFFF)) - buf[Constants.OFF_WEIGHT + 0] = (weight >> 16) & 0xFF - buf[Constants.OFF_WEIGHT + 1] = (weight >> 8) & 0xFF - buf[Constants.OFF_WEIGHT + 2] = weight & 0xFF - buf[Constants.OFF_UNIT_ID] = _resolve_id("unit", spec.get("unit")) & 0xFF - - # Temps - tmin = max(0, min(int(spec.get("temp_min_c", 0) or 0), 0xFFFF)) - tmax = max(0, min(int(spec.get("temp_max_c", 0) or 0), 0xFFFF)) - buf[Constants.OFF_TEMP_MIN:Constants.OFF_TEMP_MIN + 2] = tmin.to_bytes(2, "big") - buf[Constants.OFF_TEMP_MAX:Constants.OFF_TEMP_MAX + 2] = tmax.to_bytes(2, "big") - buf[Constants.OFF_DRY_TEMP] = max(0, min(int(spec.get("dry_temp_c", 0) or 0), 0xFF)) - buf[Constants.OFF_DRY_TIME] = max(0, min(int(spec.get("dry_time_h", 0) or 0), 0xFF)) - buf[Constants.OFF_BED_TEMP_MIN] = max(0, min(int(spec.get("bed_temp_min_c", 0) or 0), 0xFF)) - buf[Constants.OFF_BED_TEMP_MAX] = max(0, min(int(spec.get("bed_temp_max_c", 0) or 0), 0xFF)) - - # Manufacturing timestamp (seconds since 2000-01-01) - ts = max(0, min(_resolve_mfg_timestamp(spec.get("manufacturing_date")), 0xFFFFFFFF)) - buf[Constants.OFF_TIMESTAMP:Constants.OFF_TIMESTAMP + 4] = ts.to_bytes(4, "big") - - # bytes 36..43 reserved — leave zero - - # TD (transmission distance), big-endian uint16, value = mm * 10 - if spec.get("td_mm") is not None: +def _uid_from_spec(spec: dict[str, Any]) -> bytes | None: + value = _first(spec, "uid", "expected_uid") + if value is None: + return None + if isinstance(value, str): + text = value.replace(":", "").replace("-", "").replace(" ", "") try: - td = int(round(float(spec.get("td_mm") or 0) * 10)) - except (TypeError, ValueError): - td = 0 + value = bytes.fromhex(text) + except ValueError as exc: + raise ValueError("uid must be a 7-byte hex string") from exc + if not isinstance(value, (bytes, bytearray)) or len(value) != 7: + raise ValueError("uid must contain exactly 7 bytes") + return bytes(value) + + +def _message_from_spec(spec: dict[str, Any]) -> str: + value = _first(spec, "message", "custom_message", default="") + message = str(value or "") + if len(message.encode("utf-8")) > 28: + raise ValueError("message must be at most 28 UTF-8 bytes") + return message + + +def _td_raw_from_spec(spec: dict[str, Any]) -> int: + td_mm = _first(spec, "td_mm", "td_value") + if td_mm is not None: + try: + numeric = float(td_mm) + except (TypeError, ValueError) as exc: + raise ValueError("td_mm must be a number") from exc + if not math.isfinite(numeric): + raise ValueError("td_mm must be a finite number") + raw = int(round(numeric * 10)) else: - td = int(spec.get("td", 0) or 0) - td = max(0, min(td, 0xFFFF)) - buf[Constants.OFF_TD:Constants.OFF_TD + 2] = td.to_bytes(2, "big") - - # bytes 46..47 reserved — leave zero - # OFF_MESSAGE = 48: 28-byte UTF-8 message + 4 reserved bytes (signature - # region starts at OFF_SIGNATURE = 80 and is left zeroed). - buf[Constants.OFF_MESSAGE:Constants.OFF_MESSAGE + Constants.MESSAGE_LENGTH] = _utf8_message_bytes( - spec.get("message"), max_bytes=Constants.MESSAGE_LENGTH + # Compatibility: the original encoder treated td as the raw u16. + raw = _bounded_int("td", _first(spec, "td"), maximum=0xFFFF) + + if raw != 0 and not 10 <= raw <= 1000: + raise ValueError("TigerTag TD must be 0 (undefined) or between 1.0 and 100.0 mm") + return raw + + +def build_maker_tag(spec: dict[str, Any] | None) -> TigerTag: + """Build and validate an unsigned TigerTag Maker model from an API spec.""" + if spec is None: + spec = {} + if not isinstance(spec, dict): + raise ValueError("TigerTag spec must be an object") + + requested_product = _first(spec, "product_id", "id_product") + if requested_product is not None: + product_id = _bounded_int("product_id", requested_product, maximum=0xFFFFFFFF) + # The old custom encoder used zero by default. Treat that legacy value + # as a Maker request, but never fabricate a TigerTag+ cloud identity. + if product_id not in (0, MAKER_PRODUCT_ID): + raise ValueError("OpenRFID can only encode unsigned TigerTag Maker tags") + + color1, color2, color3 = _color_sources(spec) + c1 = _parse_color(color1, primary=True) + c2 = _parse_color(color2, primary=False) + c3 = _parse_color(color3, primary=False) + + measure = _bounded_int( + "measure", + _first(spec, "measure", "weight_g"), + maximum=0xFFFFFF, + ) + available = _bounded_int( + "measure_available", + _first(spec, "measure_available", "available_quantity", default=measure), + maximum=0xFFFFFF, ) - return bytes(buf) + tag = TigerTag.create( + product_id=MAKER_PRODUCT_ID, + uid=_uid_from_spec(spec), + id_material=_resolve_id("material", _first(spec, "id_material", "material")), + id_aspect_1=_resolve_id("aspect", _first(spec, "id_aspect_1", "id_aspect1", "aspect_1")), + id_aspect_2=_resolve_id("aspect", _first(spec, "id_aspect_2", "id_aspect2", "aspect_2")), + id_type=_resolve_id("type", _first(spec, "id_type", "type")), + id_diameter=_resolve_id("diameter", _first(spec, "id_diameter", "diameter")), + id_brand=_resolve_id("brand", _first(spec, "id_brand", "brand")), + color1_r=c1[0], + color1_g=c1[1], + color1_b=c1[2], + color1_a=c1[3], + color2_r=c2[0], + color2_g=c2[1], + color2_b=c2[2], + color3_r=c3[0], + color3_g=c3[1], + color3_b=c3[2], + measure=measure, + id_unit=_resolve_id("unit", _first(spec, "id_unit", "unit")), + nozzle_temp_min=_bounded_int( + "temp_min_c", _first(spec, "nozzle_temp_min", "temp_min_c"), maximum=0xFFFF + ), + nozzle_temp_max=_bounded_int( + "temp_max_c", _first(spec, "nozzle_temp_max", "temp_max_c"), maximum=0xFFFF + ), + dry_temp=_bounded_int( + "dry_temp_c", _first(spec, "dry_temp", "dry_temp_c"), maximum=0xFF + ), + dry_time=_bounded_int( + "dry_time_h", _first(spec, "dry_time", "dry_time_h"), maximum=0xFF + ), + bed_temp_min=_bounded_int( + "bed_temp_min_c", _first(spec, "bed_temp_min", "bed_temp_min_c"), maximum=0xFF + ), + bed_temp_max=_bounded_int( + "bed_temp_max_c", _first(spec, "bed_temp_max", "bed_temp_max_c"), maximum=0xFF + ), + timestamp=_timestamp_from_spec(spec), + custom_message=_message_from_spec(spec), + td_raw=_td_raw_from_spec(spec), + ) + + if available != measure: + tag = tag.patch(measure_available=available) + + warnings = tag.validate() + if warnings: + raise ValueError("; ".join(warnings)) + return tag + + +def encode(spec: dict[str, Any] | None) -> bytes: + """Encode a canonical 80-byte unsigned TigerTag Maker payload.""" + payload = build_maker_tag(spec).to_bytes() + validate_maker_payload(payload) + return payload + + +def erase() -> bytes: + """Return exactly the TigerTag-owned 80 bytes cleared to zero.""" + payload = TigerTag.erase() + if len(payload) != PAYLOAD_LENGTH_BYTES: + raise RuntimeError("TigerTag SDK returned an unexpected erase payload length") + return payload def encode_blank() -> bytes: - """Return a 96-byte payload of zeros (used to erase NTAG215 user pages).""" - return b"\x00" * PAYLOAD_LENGTH_BYTES + """Backward-compatible alias for erase.""" + return erase() + + +def decode_payload(payload: bytes, uid: bytes | None = None) -> TigerTag: + """Decode an 80-byte Maker body or 144-byte signed TigerTag payload.""" + if not isinstance(payload, (bytes, bytearray)): + raise ValueError("TigerTag payload must be bytes") + data = bytes(payload) + if len(data) not in (PAYLOAD_LENGTH_BYTES, 144): + raise ValueError("TigerTag payload must be exactly 80 or 144 bytes") + if uid is None: + return TigerTag.from_dump(data) + if not isinstance(uid, (bytes, bytearray)) or len(uid) != 7: + raise ValueError("TigerTag UID must contain exactly 7 bytes") + return TigerTag.from_pages(bytes(uid), data) + + +def validate_maker_payload(payload: bytes, uid: bytes | None = None) -> TigerTag: + """Validate an exact unsigned Maker body and return its SDK model. + + This is suitable for controller-side request validation and read-back + verification. It deliberately rejects TigerTag+, Init, signatures, length + drift, and SDK field warnings. + """ + if not isinstance(payload, (bytes, bytearray)) or len(payload) != PAYLOAD_LENGTH_BYTES: + raise ValueError("TigerTag Maker payload must be exactly 80 bytes") + data = bytes(payload) + tag = decode_payload(data, uid=uid) + if tag.id_tigertag != TIGERTAG_TAG_ID or tag.id_product != MAKER_PRODUCT_ID: + raise ValueError("payload is not an unsigned TigerTag Maker tag") + # Parsing is intentionally tolerant (for example malformed UTF-8 is + # decoded with replacement), but bytes accepted for writing must be the + # one canonical representation emitted by the official SDK. This rejects + # non-zero reserved bytes and lossy text before they reach the tag. + if tag.to_bytes() != data: + raise ValueError( + "payload is not canonical TigerTag Maker data; use tigertag_encode" + ) + warnings = tag.validate() + if warnings: + raise ValueError("; ".join(warnings)) + return tag diff --git a/src/tag/tigertag/processor.py b/src/tag/tigertag/processor.py index bf67511..1c2d254 100644 --- a/src/tag/tigertag/processor.py +++ b/src/tag/tigertag/processor.py @@ -1,164 +1,308 @@ +from __future__ import annotations + +from typing import Any + from filament import GenericFilament from reader.scan_result import ScanResult from tag.mifare_ultralight_tag_processor import MifareUltralightTagProcessor from tag.tag_types import TagType +from tigertag import ID_TIGERTAG_PLUS, INIT_PRODUCT_ID, TigerTag, TigerTagDB + from . import constants as Constants -from .registry import get_tigertag_registry -import struct -from datetime import datetime, timezone +from .encoder import decode_payload + class TigerTagProcessor(MifareUltralightTagProcessor): + tag_format = "tigertag" + def __init__(self, config: dict): super().__init__(config) - self.registry = get_tigertag_registry() + self.db = TigerTagDB() def process_tag(self, scan_result: ScanResult, data: bytes) -> GenericFilament | None: if scan_result.tag_type != TagType.MifareUltralight: return None - if len(data) < Constants.USER_DATA_BYTE_OFFSET + Constants.OFF_TIMESTAMP + 4: + payload = self.__extract_payload(data) + if payload is None: return None - user_data = data[Constants.USER_DATA_BYTE_OFFSET:] + tag_id = int.from_bytes(payload[Constants.OFF_TAG_ID:Constants.OFF_TAG_ID + 4], "big") + if tag_id not in Constants.TIGERTAG_VALID_DATA_IDS: + return None - tag_id = struct.unpack_from('>I', user_data, Constants.OFF_TAG_ID)[0] + try: + uid = scan_result.uid if len(scan_result.uid) == 7 else None + tag = decode_payload(payload, uid=uid) + self.logger.debug( + "TigerTag: Detected format ID 0x%08X (%s)", + tag.id_tigertag, + TigerTagDB.label(self.db.version(tag.id_tigertag)), + ) + return self.__to_generic_filament( + tag, + has_signature_region=len(payload) == 144, + ) + except Exception as exc: + self.logger.exception("TigerTag: Failed to parse tag data: %s", exc) + return None - if tag_id not in Constants.TIGERTAG_VALID_DATA_IDS: + @staticmethod + def __extract_payload(data: bytes) -> bytes | None: + if not isinstance(data, (bytes, bytearray)): return None + raw = bytes(data) + + # Unit tests and integrations may already supply user pages only. + if len(raw) in (80, 144): + return raw + + # The FM175xx reader supplies complete chip memory. Pages 0-3 occupy + # the first 16 bytes; retain the signature pages when they are present. + user_data = raw[Constants.USER_DATA_BYTE_OFFSET:] + if len(user_data) >= 144: + return user_data[:144] + if len(user_data) >= Constants.MIN_DATA_LENGTH: + # This also keeps semantic compatibility with the former 96-byte + # encoder: its first 80 bytes are the standard TigerTag body and + # its trailing zero signature fragment is ignored. + return user_data[:Constants.MIN_DATA_LENGTH] + return None - self.logger.debug("TigerTag: Detected format ID 0x%08X (%s)", - tag_id, self.registry.version_ids.get(tag_id, "Unknown")) + def __to_generic_filament( + self, + tag: TigerTag, + has_signature_region: bool, + ) -> GenericFilament: + enriched = tag.to_dict(self.db) + raw = tag.to_raw_dict() - return self.__parse_tigertag(scan_result, user_data, tag_id) + material_entry = self.db.material(tag.id_material) or {} + material_label = TigerTagDB.label(material_entry) + material_type = material_entry.get("material_type") or material_label + filled_type = material_entry.get("filled_type") or "" + resolved_material_type = ( + f"{material_type}-{filled_type}" + if filled_type and not str(material_type).endswith(f"-{filled_type}") + else str(material_type) + ) - def __parse_tigertag(self, scan_result: ScanResult, user_data: bytes, tag_id: int) -> GenericFilament | None: + brand_name = TigerTagDB.label(self.db.brand(tag.id_brand)) + diameter_label = TigerTagDB.label(self.db.diameter(tag.id_diameter)) try: - product_id = struct.unpack_from('>I', user_data, Constants.OFF_PRODUCT_ID)[0] - material_id = struct.unpack_from('>H', user_data, Constants.OFF_MATERIAL_ID)[0] - aspect1_id = user_data[Constants.OFF_ASPECT1_ID] - aspect2_id = user_data[Constants.OFF_ASPECT2_ID] - type_id = user_data[Constants.OFF_TYPE_ID] - diameter_id = user_data[Constants.OFF_DIAMETER_ID] - brand_id = struct.unpack_from('>H', user_data, Constants.OFF_BRAND_ID)[0] - - r = user_data[Constants.OFF_COLOR_RGBA] - g = user_data[Constants.OFF_COLOR_RGBA + 1] - b = user_data[Constants.OFF_COLOR_RGBA + 2] - a = user_data[Constants.OFF_COLOR_RGBA + 3] - argb_color = (a << 24) | (r << 16) | (g << 8) | b - - weight_bytes = user_data[Constants.OFF_WEIGHT:Constants.OFF_WEIGHT + 3] - measure_value = (weight_bytes[0] << 16) | (weight_bytes[1] << 8) | weight_bytes[2] - unit_id = user_data[Constants.OFF_UNIT_ID] - - temp_min = struct.unpack_from('>H', user_data, Constants.OFF_TEMP_MIN)[0] - temp_max = struct.unpack_from('>H', user_data, Constants.OFF_TEMP_MAX)[0] - dry_temp = user_data[Constants.OFF_DRY_TEMP] - dry_time = user_data[Constants.OFF_DRY_TIME] - - bed_temp_min = user_data[Constants.OFF_BED_TEMP_MIN] - bed_temp_max = user_data[Constants.OFF_BED_TEMP_MAX] - - # TD (Transmission Distance) — 2-byte big-endian uint16, value/10 = mm - td_raw = 0 - td_mm = 0.0 - if len(user_data) > Constants.OFF_TD + 1: - td_raw = struct.unpack_from('>H', user_data, Constants.OFF_TD)[0] - td_mm = td_raw / 10.0 - - timestamp_raw = struct.unpack_from('>I', user_data, Constants.OFF_TIMESTAMP)[0] - - # Custom message: the 32-byte metadata block is exposed verbatim as - # a UTF-8 string. The TigerTag spec reserves the first few bytes - # for an optional emoji glyph, but the encoding is not well-defined - # across writers (and most writers omit it), so we hand the whole - # region to the application untouched. - message = "" - if len(user_data) >= Constants.OFF_MESSAGE + Constants.MESSAGE_LENGTH: - raw_msg = user_data[Constants.OFF_MESSAGE:Constants.OFF_MESSAGE + Constants.MESSAGE_LENGTH] - try: - message = raw_msg.rstrip(b'\x00').decode('utf-8', errors='replace').strip() - except Exception: - message = "" - - material_label = self.registry.material_ids.get(material_id, f"Unknown({material_id})") - material_type = self.registry.material_type_ids.get(material_id, material_label) - filled_type = self.registry.filled_type_ids.get(material_id, "") - resolved_material_type = material_type if not filled_type else f"{material_type}-{filled_type}" - brand_name = self.registry.brand_ids.get(brand_id, f"Unknown({brand_id})") - diameter_mm = self.registry.diameter_ids.get(diameter_id, 1.75) - aspect1_label = self.registry.aspect_ids.get(aspect1_id, "") - aspect2_label = self.registry.aspect_ids.get(aspect2_id, "") - unit_label = self.registry.unit_ids.get(unit_id, "g") - - weight_grams = self.__convert_to_grams(measure_value, unit_id) - manufacturing_date = self.__timestamp_to_date(timestamp_raw) - - modifiers = [] - for aspect_label in (aspect1_label, aspect2_label): - if aspect_label and aspect_label not in ("None", "", "-"): - modifiers.append(aspect_label) - - self.logger.debug("Found TigerTag filament:") - self.logger.debug(" Tag ID: 0x%08X (%s)", tag_id, self.registry.version_ids.get(tag_id, "Unknown")) - self.logger.debug(" Product ID: 0x%08X", product_id) - self.logger.debug(" Material: %s / %s (ID: %d)", resolved_material_type, material_label, material_id) - self.logger.debug(" Brand: %s (ID: %d)", brand_name, brand_id) - self.logger.debug(" Diameter: %.2f mm (ID: %d)", diameter_mm, diameter_id) - self.logger.debug(" Aspect1: %s, Aspect2: %s", aspect1_label, aspect2_label) - self.logger.debug(" Color (ARGB): 0x%08X", argb_color) - self.logger.debug(" Measure: %d %s (%.1f g)", measure_value, unit_label, weight_grams) - self.logger.debug(" Nozzle Temp: %d-%d °C", temp_min, temp_max) - self.logger.debug(" Dry: %d °C for %d hours", dry_temp, dry_time) - self.logger.debug(" Bed Temp: %d-%d °C", bed_temp_min, bed_temp_max) - self.logger.debug(" TD: raw=%d → %.1f mm", td_raw, td_mm) - self.logger.debug(" Message: %r", message) - self.logger.debug(" Timestamp: %d (%s)", timestamp_raw, manufacturing_date) - - return GenericFilament( - source_processor=self.name, - unique_id=GenericFilament.generate_unique_id( - "TigerTag", brand_name, resolved_material_type, argb_color, - product_id, timestamp_raw - ), - manufacturer=brand_name, - type=resolved_material_type, - modifiers=modifiers, - colors=[argb_color], - diameter_mm=diameter_mm, - weight_grams=weight_grams, - hotend_min_temp_c=float(temp_min), - hotend_max_temp_c=float(temp_max), - bed_temp_c=float(bed_temp_min), - bed_temp_max_c=float(bed_temp_max), - drying_temp_c=float(dry_temp), - drying_time_hours=float(dry_time), - manufacturing_date=manufacturing_date, - td=td_mm, - message=message, + diameter_mm = float(diameter_label) + except (TypeError, ValueError): + diameter_mm = 0.0 + + aspect_entries = [ + self.db.aspect(tag.id_aspect_1) or {}, + self.db.aspect(tag.id_aspect_2) or {}, + ] + aspect_labels = [ + TigerTagDB.label(entry) + for entry in aspect_entries + if entry + ] + modifiers = [ + label for label in aspect_labels + if label not in ("", "-", "None", "Unknown") + ] + + active_color_count = self.__active_color_count(aspect_entries) + colors = self.__active_colors(tag, active_color_count) + + unit_entry = self.db.unit(tag.id_unit) or {} + quantity_unit = TigerTagDB.label(unit_entry) + weight_grams = self.__to_grams(tag.measure, unit_entry) + + verification = tag.verify(self.db).to_dict() + # OpenRFID's retired 96-byte writer used a Plus/product-0 header and + # guaranteed zero bytes in pages 24..27 (the first 16 signature + # bytes). Do not classify arbitrary unsigned Plus-shaped records as + # migratable; the physical writer repeats this exact read-only check. + is_legacy_openrfid_v1 = ( + tag.id_tigertag == ID_TIGERTAG_PLUS + and tag.id_product == INIT_PRODUCT_ID + and has_signature_region + and tag.signature_r[:16] == bytes(16) + ) + legacy_stale_signature_tail = is_legacy_openrfid_v1 and bool( + any(tag.signature_r[16:]) or any(tag.signature_s) + ) + variant = ( + "legacy_openrfid_v1" + if is_legacy_openrfid_v1 + else "plus" + if tag.id_tigertag == ID_TIGERTAG_PLUS + else "init" + if tag.is_init + else "maker" + ) + authentication = { + "signed": False if is_legacy_openrfid_v1 else tag.is_signed, + "verification": ( + "legacy_openrfid_v1" + if is_legacy_openrfid_v1 + else verification.get("status", "unavailable") + ), + "sdk_verification": verification.get("status", "unavailable"), + "ok": False if is_legacy_openrfid_v1 else bool(verification.get("ok", False)), + "detail": ( + "Legacy OpenRFID TigerTag encoding: Plus format ID with product 0 " + "and no signature. Data is readable but not authenticated; rewrite " + "explicitly as a Maker tag to upgrade it." + if is_legacy_openrfid_v1 + else verification.get("detail", "") + ), + } + if is_legacy_openrfid_v1: + authentication["stale_signature_tail"] = legacy_stale_signature_tail + + present_fields = [ + "manufacturer", + "type", + "type_supported", + "material_name", + "modifiers", + "colors", + "colors_rgba", + "colors_rgba_hex", + "diameter_mm", + "available_quantity", + "quantity_unit", + "hotend_min_temp_c", + "hotend_max_temp_c", + "bed_temp_c", + "bed_temp_min_c", + "bed_temp_max_c", + "drying_temp_c", + "drying_time_hours", + "manufacturing_date", + ] + if weight_grams is not None: + present_fields.append("weight_grams") + if tag.custom_message: + present_fields.append("message") + if tag.td_raw: + present_fields.extend(("td", "td_mm")) + + validation_warnings = list(tag.validate()) + if legacy_stale_signature_tail: + validation_warnings.append( + "legacy OpenRFID v1 tag has non-zero stale bytes after its guaranteed zero signature prefix" ) - except Exception as e: - self.logger.exception("TigerTag: Failed to parse tag data: %s", e) - return None + format_data: dict[str, Any] = { + "sdk": enriched.get("sdk"), + "protocol": enriched.get("protocol"), + "variant": variant, + "version": enriched.get("version"), + "product": enriched.get("product"), + "product_type": enriched.get("type"), + "material": enriched.get("material"), + "aspects": [enriched.get("aspect_1"), enriched.get("aspect_2")], + "colors": enriched.get("colors"), + "measure": enriched.get("measure"), + "temperatures": enriched.get("temperatures"), + "twin_tag_pairing_id": enriched.get("twin_tag_pairing_id"), + # Keep raw on-chip values separate from SDK database enrichment. + "raw": raw, + "validation_warnings": validation_warnings, + "legacy": { + "signature_prefix_zero": is_legacy_openrfid_v1, + "stale_signature_tail": legacy_stale_signature_tail, + } if is_legacy_openrfid_v1 else None, + } - def __convert_to_grams(self, value: int, unit_id: int) -> float: - match unit_id: - case 1 | 21: - return float(value) - case 2 | 35: - return value * 1000.0 - case 10: - return value / 1000.0 - case _: - return float(value) - - def __timestamp_to_date(self, timestamp: int) -> str: - if timestamp == 0: - return "0001-01-01" - try: - unix_ts = timestamp + Constants.TIGERTAG_EPOCH_OFFSET - dt = datetime.fromtimestamp(unix_ts, tz=timezone.utc) - return dt.strftime("%Y-%m-%d") - except (OSError, OverflowError, ValueError): - return "0001-01-01" + primary_color = colors[0] if colors else 0 + self.logger.debug("Found TigerTag filament:") + self.logger.debug(" Product: %s", enriched.get("product")) + self.logger.debug(" Material: %s", resolved_material_type) + self.logger.debug(" Brand: %s", brand_name) + self.logger.debug(" Colors (ARGB): %s", [f"0x{color:08X}" for color in colors]) + self.logger.debug(" Available: %d %s", tag.measure_available, quantity_unit) + self.logger.debug(" Authentication: %s", authentication["verification"]) + + return GenericFilament( + source_processor=self.name, + tag_format=self.tag_format, + unique_id=GenericFilament.generate_unique_id( + "TigerTag", + brand_name, + resolved_material_type, + primary_color, + tag.id_product, + tag.timestamp, + ), + manufacturer=brand_name, + type=resolved_material_type, + material_name=material_label, + modifiers=modifiers, + colors=colors, + diameter_mm=diameter_mm, + weight_grams=weight_grams, + available_quantity=float(tag.measure_available), + quantity_unit=quantity_unit, + hotend_min_temp_c=float(tag.nozzle_temp_min), + hotend_max_temp_c=float(tag.nozzle_temp_max), + bed_temp_c=float(tag.bed_temp_min), + bed_temp_min_c=float(tag.bed_temp_min), + bed_temp_max_c=float(tag.bed_temp_max), + drying_temp_c=float(tag.dry_temp), + drying_time_hours=float(tag.dry_time), + manufacturing_date=tag.manufacturing_date.strftime("%Y-%m-%d"), + td=tag.td_value, + message=tag.custom_message, + present_fields=present_fields, + format_data=format_data, + authentication=authentication, + ) + + @staticmethod + def __active_color_count(aspect_entries: list[dict]) -> int: + first_count = int(aspect_entries[0].get("color_count", 0) or 0) + second_count = int(aspect_entries[1].get("color_count", 0) or 0) + # TigerTag defines multi-color mode on aspect 2 first, then aspect 1. + if second_count > 1: + return min(second_count, 3) + if first_count > 1: + return min(first_count, 3) + return 1 + + @staticmethod + def __active_colors(tag: TigerTag, count: int) -> list[int]: + primary = ( + (tag.color1_a << 24) + | (tag.color1_r << 16) + | (tag.color1_g << 8) + | tag.color1_b + ) + secondary = ( + (0xFF << 24) + | (tag.color2_r << 16) + | (tag.color2_g << 8) + | tag.color2_b + ) + tertiary = ( + (0xFF << 24) + | (tag.color3_r << 16) + | (tag.color3_g << 8) + | tag.color3_b + ) + return [primary, secondary, tertiary][:count] + + @staticmethod + def __to_grams(value: int, unit_entry: dict) -> float | None: + # TigerTag also defines volume, length, and area units. Keep those in + # available_quantity/quantity_unit without misrepresenting them as a + # filament weight. + if unit_entry.get("type") != "weight": + return None + unit_id = int(unit_entry.get("id", 0) or 0) + if unit_id == 21: # g + return float(value) + if unit_id == 35: # kg + return float(value) * 1000.0 + if unit_id == 10: # mg + return float(value) / 1000.0 + return None diff --git a/test/test_main_shutdown.py b/test/test_main_shutdown.py new file mode 100644 index 0000000..9a7caa3 --- /dev/null +++ b/test/test_main_shutdown.py @@ -0,0 +1,49 @@ +"""Entry-point tests for lock-free cooperative process shutdown.""" + +from __future__ import annotations + +import importlib +import signal +import sys +from pathlib import Path +from types import SimpleNamespace + + +ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = ROOT / "src" +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +# The entry point imports target-only GPIO/SPI modules, but this test does not +# instantiate hardware. Minimal module shims keep the import contract honest. +sys.modules.setdefault("spidev", SimpleNamespace(SpiDev=object)) +sys.modules.setdefault( + "gpiod", + SimpleNamespace(Chip=object, LINE_REQ_DIR_OUT=1), +) + + +def test_signal_handlers_only_request_runtime_shutdown(monkeypatch): + main_module = importlib.import_module("main") + installed = {} + + class _Runtime: + def __init__(self): + self.calls = 0 + + def request_shutdown(self): + self.calls += 1 + + runtime = _Runtime() + monkeypatch.setattr( + main_module.signal, + "signal", + lambda signum, handler: installed.setdefault(signum, handler), + ) + + main_module._install_shutdown_signal_handlers(runtime) + assert set(installed) == {signal.SIGTERM, signal.SIGINT} + + installed[signal.SIGTERM](signal.SIGTERM, None) + installed[signal.SIGINT](signal.SIGINT, None) + assert runtime.calls == 2 diff --git a/test/test_openrfid_api.py b/test/test_openrfid_api.py new file mode 100644 index 0000000..9670cc7 --- /dev/null +++ b/test/test_openrfid_api.py @@ -0,0 +1,481 @@ +"""Contract tests for the guarded Moonraker OpenRFID agent API.""" + +from __future__ import annotations + +import sys +import threading +import time +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = ROOT / "src" +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from controllers.openrfid_api import OpenrfidApiController, tigertag_encoder + + +MAKER_PAYLOAD = bytes.fromhex("5BF59264") + (b"\x00" * 76) + + +class _Reader: + name = "slot_0_reader" + slot = 0 + + +class _Runtime: + def __init__(self): + self.rfid_readers = [_Reader()] + self.last_scans = {} + self.started: list[int] = [] + self.write_calls: list[tuple] = [] + self.clear_calls: list[tuple] = [] + self.status_calls: list[str] = [] + self.shutting_down = False + + def is_shutting_down(self): + return self.shutting_down + + def start_reading_tag(self, slot: int): + self.started.append(slot) + + def get_tigertag_write_capabilities(self, slot: int) -> dict: + return { + "supported": True, + "format": "tigertag", + "variant": "maker", + "expected_uid_required": True, + "payload_bytes": 80, + "start_page": 4, + "end_page": 23, + "clear_supported": True, + "allow_unrecognized_supported": True, + "allow_legacy_migration_supported": True, + "busy": False, + } + + def submit_tigertag_write( + self, + slot, + expected_uid, + data, + timeout=10.0, + allow_unrecognized=False, + allow_legacy_migration=False, + safety_check=None, + ): + self.write_calls.append( + ( + slot, + expected_uid, + data, + timeout, + allow_unrecognized, + allow_legacy_migration, + ) + ) + return {"ok": True, "code": "written", "verified": True} + + def submit_tigertag_clear( + self, + slot, + expected_uid, + timeout=10.0, + allow_unrecognized=False, + allow_legacy_migration=False, + safety_check=None, + ): + self.clear_calls.append( + (slot, expected_uid, timeout, allow_unrecognized, allow_legacy_migration) + ) + return {"ok": True, "code": "cleared", "verified": True} + + def get_tigertag_operation_status(self, operation_id): + self.status_calls.append(operation_id) + return { + "ok": True, + "operation_id": operation_id, + "operation_state": "completed", + "completed": True, + "result": {"ok": True, "code": "written"}, + } + + +def _controller( + *, + enabled=True, + allow_unrecognized=False, + allow_legacy_migration=False, +): + controller = OpenrfidApiController({ + "__name": "openrfid_api", + "moonraker_socket_path": "/tmp/moonraker.sock", + "enable_write": str(enabled).lower(), + "allow_unrecognized_write": str(allow_unrecognized).lower(), + "allow_legacy_migration_write": str(allow_legacy_migration).lower(), + }) + controller.runtime = _Runtime() + return controller + + +@pytest.mark.parametrize( + ("state", "expected_code"), + [ + (None, "print_state_unknown"), + ("printing", "print_active"), + ("paused", "print_active"), + ("starting", "print_state_unsafe"), + ], +) +def test_write_gate_fails_closed(state, expected_code): + controller = _controller() + controller._set_print_state(state) + result = controller._handle_write_tag({}) + assert result["ok"] is False + assert result["code"] == expected_code + + +def test_write_gate_reports_disabled_before_printer_state(): + controller = _controller(enabled=False) + result = controller._handle_write_tag({}) + assert result["code"] == "writes_disabled" + + +def test_write_gate_reports_service_stopping_before_other_state(): + controller = _controller(enabled=False) + controller.runtime.shutting_down = True + result = controller._handle_write_tag({}) + assert result["code"] == "service_stopping" + + +def test_list_channels_reports_hardware_and_guard_capabilities(): + controller = _controller() + controller._set_print_state("standby") + result = controller._handle_list_channels({}) + + assert result["api_version"] == 2 + assert result["write_allowed"] is True + assert result["print_state"] == "standby" + assert result["capabilities"]["expected_format"] == "tigertag" + assert result["capabilities"]["operation_status"] is True + assert result["allow_legacy_migration_write"] is False + assert result["channels"][0]["capabilities"]["tigertag_write"]["payload_bytes"] == 80 + + +def test_extension_method_is_handled_without_klipper_remote_registration(): + controller = _controller() + controller._set_print_state("standby") + sent = [] + controller.send_message = sent.append + + # This is the direct request shape Moonraker relays for + # server.extensions.request; no connection.register_remote_method call is + # involved or required. + controller.on_message({ + "jsonrpc": "2.0", + "id": 17, + "method": "openrfid/list_channels", + "params": {}, + }) + assert sent[0]["id"] == 17 + assert sent[0]["result"]["api_version"] == 2 + + +def test_connect_does_not_expose_extension_methods_to_klipper_gcode(): + controller = _controller() + sent = [] + controller.send_message = sent.append + controller.on_connect() + methods = [message.get("method") for message in sent] + assert "server.connection.identify" in methods + assert "server.info" in methods + assert "connection.register_remote_method" not in methods + controller._stop_broadcast.set() + + +def test_write_uses_sdk_payload_and_safe_runtime(monkeypatch): + controller = _controller() + controller._set_print_state("standby") + monkeypatch.setattr(tigertag_encoder, "encode", lambda spec: MAKER_PAYLOAD) + monkeypatch.setattr( + tigertag_encoder, + "validate_maker_payload", + lambda payload, uid=None: object(), + ) + + result = controller._handle_write_tag({ + "slot": 0, + "expected_uid": "04:A1:B2:C3:D4:E5:F6", + "expected_format": "tigertag", + "spec": {"material": "PLA", "message": "work spool"}, + "timeout": 12, + }) + + assert result["ok"] is True + assert result["verified"] is True + assert result["tag_format"] == "tigertag" + assert result["payload_bytes"] == 80 + assert controller.runtime.write_calls == [ + (0, "04A1B2C3D4E5F6", MAKER_PAYLOAD, 12.0, False, False) + ] + + +def test_options_exposes_official_sdk_registries(monkeypatch): + monkeypatch.setattr(tigertag_encoder, "get_options", lambda: { + "schema_version": 1, + "sdk": {"name": "tigertag", "version": "1.2.1", "commit": "abc"}, + "materials": [{"id": 38219, "label": "PLA"}], + "brands": [], + "aspects": [], + "types": [], + "diameters": [], + "units": [], + }) + result = _controller()._handle_tigertag_options({}) + assert result["ok"] is True + assert result["schema_version"] == 1 + assert result["materials"] == [{"id": 38219, "label": "PLA"}] + + +def test_write_requires_expected_uid_and_format(): + controller = _controller() + controller._set_print_state("standby") + + missing_uid = controller._handle_write_tag({ + "slot": 0, + "expected_format": "tigertag", + "spec": {}, + }) + assert missing_uid["code"] == "invalid_request" + + wrong_format = controller._handle_write_tag({ + "slot": 0, + "expected_uid": "04A1B2C3D4E5F6", + "expected_format": "openspool", + "spec": {}, + }) + assert wrong_format["code"] == "invalid_request" + + +def test_unrecognized_override_requires_server_configuration(): + controller = _controller(allow_unrecognized=False) + controller._set_print_state("standby") + result = controller._handle_clear_tag({ + "slot": 0, + "expected_uid": "04A1B2C3D4E5F6", + "expected_format": "tigertag", + "allow_unrecognized": True, + }) + assert result["code"] == "invalid_request" + + +def test_clear_uses_fixed_safe_runtime_contract(): + controller = _controller(allow_unrecognized=True) + controller._set_print_state("complete") + result = controller._handle_clear_tag({ + "slot": 0, + "expected_uid": "04A1B2C3D4E5F6", + "expected_format": "tigertag", + "allow_unrecognized": True, + "timeout": 5, + }) + + assert result == {"ok": True, "code": "cleared", "verified": True} + assert controller.runtime.clear_calls == [ + (0, "04A1B2C3D4E5F6", 5.0, True, False) + ] + + +def test_legacy_migration_has_a_separate_server_and_request_opt_in(): + blocked = _controller(allow_unrecognized=True, allow_legacy_migration=False) + blocked._set_print_state("standby") + result = blocked._handle_clear_tag({ + "slot": 0, + "expected_uid": "04A1B2C3D4E5F6", + "expected_format": "tigertag", + "allow_legacy_migration": True, + }) + assert result["code"] == "invalid_request" + + allowed = _controller(allow_legacy_migration=True) + allowed._set_print_state("standby") + result = allowed._handle_clear_tag({ + "slot": 0, + "expected_uid": "04A1B2C3D4E5F6", + "expected_format": "tigertag", + "allow_legacy_migration": True, + }) + assert result["ok"] is True + assert allowed.runtime.clear_calls == [ + (0, "04A1B2C3D4E5F6", 10.0, False, True) + ] + + +def test_operation_status_recovers_terminal_result_without_write_gate(): + controller = _controller(enabled=False) + result = controller._handle_operation_status({"operation_id": "op-123"}) + assert result["completed"] is True + assert result["result"]["code"] == "written" + assert controller.runtime.status_calls == ["op-123"] + + invalid = controller._handle_operation_status({"operation_id": " "}) + assert invalid["code"] == "invalid_request" + + +def test_slot_validation_uses_logical_reader_slot_not_list_index(): + controller = _controller() + controller.runtime.rfid_readers[0].slot = 7 + result = controller._handle_scan_slot({"slot": 7}) + assert result == {"ok": True, "slot": 7} + assert controller.runtime.started == [7] + + +def test_status_subscription_updates_and_resets_authoritative_state(): + controller = _controller() + sent = [] + controller.send_message = sent.append + + assert controller._handle_print_state_message({ + "id": 0x6F1A0100, + "result": {"klippy_state": "ready"}, + }) is True + assert sent[-1]["method"] == "printer.objects.subscribe" + + controller._handle_print_state_message({ + "id": 0x6F1A0101, + "result": {"status": {"print_stats": {"state": "standby"}}}, + }) + assert controller._get_print_state()[0] == "standby" + + controller._handle_print_state_message({ + "method": "notify_status_update", + "params": [{"print_stats": {"state": "printing"}}, 123.0], + }) + assert controller._get_print_state()[0] == "printing" + + controller._handle_print_state_message({"method": "notify_klippy_disconnected"}) + assert controller._get_print_state()[0] is None + + +def test_extension_request_id_cannot_collide_with_internal_response_ids(): + controller = _controller() + sent = [] + controller.send_message = sent.append + + controller.on_message({ + "jsonrpc": "2.0", + "id": 0x6F1A0100, + "method": "openrfid/list_channels", + "params": {}, + }) + + assert sent[0]["id"] == 0x6F1A0100 + assert sent[0]["result"]["api_version"] == 2 + + +def test_pending_write_does_not_block_print_state_notifications(monkeypatch): + controller = _controller() + controller._set_print_state("standby") + entered = threading.Event() + release = threading.Event() + responses = [] + + def submit(_slot, _uid, _data, **kwargs): + entered.set() + assert release.wait(timeout=2.0) + blocked = kwargs["safety_check"]() + return blocked or {"ok": True, "code": "written"} + + controller.runtime.submit_tigertag_write = submit + controller.send_message = responses.append + monkeypatch.setattr(tigertag_encoder, "encode", lambda spec: MAKER_PAYLOAD) + monkeypatch.setattr( + tigertag_encoder, + "validate_maker_payload", + lambda payload, uid=None: object(), + ) + + controller.on_message({ + "jsonrpc": "2.0", + "id": 91, + "method": "openrfid/write_tag", + "params": { + "slot": 0, + "expected_uid": "04A1B2C3D4E5F6", + "expected_format": "tigertag", + "spec": {"material": "PLA"}, + }, + }) + assert entered.wait(timeout=1.0) + + # This is handled on the receive thread while the operation worker waits. + controller.on_message({ + "method": "notify_status_update", + "params": [{"print_stats": {"state": "printing"}}, time.time()], + }) + release.set() + + deadline = time.time() + 2.0 + while not responses and time.time() < deadline: + time.sleep(0.01) + assert responses + assert responses[0]["id"] == 91 + assert responses[0]["result"]["code"] == "print_active" + + +def test_disconnect_while_write_is_queued_fails_prewrite_and_discards_stale_response(monkeypatch): + controller = _controller() + controller._set_print_state("standby") + controller._connection_generation = 7 + controller._connection_connected = True + controller.socket = object() + entered = threading.Event() + release = threading.Event() + responses = [] + + def submit(_slot, _uid, _data, **kwargs): + entered.set() + assert release.wait(timeout=2.0) + return kwargs["safety_check"]() or {"ok": True, "code": "written"} + + def send(message, expected_generation=None): + if expected_generation is not None and expected_generation != controller._connection_generation: + return False + responses.append(message) + return True + + controller.runtime.submit_tigertag_write = submit + controller.send_message = send + monkeypatch.setattr(tigertag_encoder, "encode", lambda spec: MAKER_PAYLOAD) + monkeypatch.setattr( + tigertag_encoder, + "validate_maker_payload", + lambda payload, uid=None: object(), + ) + + controller.on_message({ + "jsonrpc": "2.0", + "id": 92, + "method": "openrfid/write_tag", + "params": { + "slot": 0, + "expected_uid": "04A1B2C3D4E5F6", + "expected_format": "tigertag", + "spec": {"material": "PLA"}, + }, + }) + assert entered.wait(timeout=1.0) + + controller.on_disconnect() + controller._connection_connected = False + # Simulate a subsequent safe reconnect before the old worker wakes. + controller._connection_generation = 8 + controller._connection_connected = True + controller._set_print_state("standby") + release.set() + + time.sleep(0.1) + assert responses == [] diff --git a/test/test_runtime_pending_write.py b/test/test_runtime_pending_write.py index ea36e7f..b390cdf 100644 --- a/test/test_runtime_pending_write.py +++ b/test/test_runtime_pending_write.py @@ -1,11 +1,4 @@ -"""Tests for the Runtime NTAG pending-write queue. - -The runtime serialises NTAG writes onto its read loop so the RFID bus is only -touched from one thread. ``submit_write`` blocks the caller on a -``threading.Event`` until the loop drains the request via -``_drain_pending_write``. These tests exercise that handshake without spinning -the full ``loop()`` (which is a ``while True``). -""" +"""Tests for Runtime's race-safe TigerTag operation queue.""" from __future__ import annotations @@ -21,31 +14,85 @@ if str(SRC_ROOT) not in sys.path: sys.path.insert(0, str(SRC_ROOT)) -from config import register_configurable_entity +import runtime as runtime_module +from config import TYPE_RFID_READER, register_configurable_entity from config.config_manager import LOADED_MODULES from config.configuration import default_configuration +from reader.mifare_ultralight_reader import TIGERTAG_MAKER_ID, TIGERTAG_OWNED_LENGTH from runtime import Runtime -class _FakeReader: - """Minimal stand-in for an ``RfidReader`` with NTAG write support.""" +UID = bytes.fromhex("04112233445566") +MAKER_PAYLOAD = TIGERTAG_MAKER_ID.to_bytes(4, "big") + bytes(range(TIGERTAG_OWNED_LENGTH - 4)) + - def __init__(self, slot: int = 0, write_status: int = 0): +class _FakeReader: + def __init__(self, slot: int = 0): self.name = f"fake_slot_{slot}" self.slot = slot + self.enabled = True self.last_read_uid = None - self._write_status = write_status - self.calls: list[tuple[int, bytes]] = [] - - def write_ntag_pages(self, start_page: int, data: bytes) -> int: - self.calls.append((start_page, bytes(data))) - return self._write_status + self.calls: list[tuple] = [] + self.sessions_started = 0 + self.sessions_ended = 0 + self.entered: threading.Event | None = None + self.release: threading.Event | None = None + + def start_session(self) -> None: + self.sessions_started += 1 + + def end_session(self) -> None: + self.sessions_ended += 1 + + def write_tigertag_maker( + self, + expected_uid: bytes, + data: bytes, + allow_unrecognized: bool = False, + allow_legacy_migration: bool = False, + safety_check=None, + ) -> dict: + self.calls.append( + ( + "write", + expected_uid, + data, + allow_unrecognized, + allow_legacy_migration, + ) + ) + if safety_check is not None: + blocked = safety_check() + if blocked is not None: + return blocked + if self.entered is not None: + self.entered.set() + if self.release is not None: + assert self.release.wait(timeout=2.0) + return {"ok": True, "code": "written", "bytes_written": len(data)} + + def clear_tigertag_maker( + self, + expected_uid: bytes, + allow_unrecognized: bool = False, + allow_legacy_migration: bool = False, + safety_check=None, + ) -> dict: + self.calls.append( + ("clear", expected_uid, allow_unrecognized, allow_legacy_migration) + ) + if safety_check is not None: + blocked = safety_check() + if blocked is not None: + return blocked + return {"ok": True, "code": "cleared", "bytes_written": TIGERTAG_OWNED_LENGTH} class _FakeReaderNoWrite: def __init__(self, slot: int = 0): self.name = f"fake_slot_{slot}" self.slot = slot + self.enabled = True self.last_read_uid = None @@ -57,104 +104,537 @@ def _reset_entities(): LOADED_MODULES.clear() -def _build_runtime_with(reader) -> Runtime: +def _build_runtime_with(reader_or_readers) -> Runtime: runtime = Runtime() - runtime.rfid_readers = [reader] - runtime.read_retries_left = [0] + runtime.rfid_readers = ( + list(reader_or_readers) + if isinstance(reader_or_readers, (list, tuple)) + else [reader_or_readers] + ) + runtime._rebuild_reader_slot_mapping() + runtime.read_retries_left = [0] * len(runtime.rfid_readers) return runtime -def test_submit_write_returns_ok_when_drained() -> None: - reader = _FakeReader(slot=0, write_status=0) - runtime = _build_runtime_with(reader) +def _wait_until_queued(runtime: Runtime, slot: int = 0) -> None: + deadline = time.time() + 1.0 + while time.time() < deadline: + with runtime._pending_write_lock: + if slot in runtime._pending_writes: + return + time.sleep(0.005) + raise AssertionError(f"tag operation was not queued for slot {slot}") - payload = b"\xDE\xAD\xBE\xEF" * 24 # 96 bytes (TigerTag-sized) +def _run_clear_operation(runtime: Runtime, slot: int = 0) -> dict: result_box: dict = {} + submitter = threading.Thread( + target=lambda: result_box.setdefault( + "result", + runtime.submit_tigertag_clear(slot, UID, timeout=2.0), + ), + daemon=True, + ) + submitter.start() + _wait_until_queued(runtime, slot) + assert runtime._drain_pending_write(slot) is True + submitter.join(timeout=2.0) + assert not submitter.is_alive() + return result_box["result"] - def _submit(): - result_box["result"] = runtime.submit_write(0, payload, start_page=4, timeout=2.0) - submitter = threading.Thread(target=_submit, daemon=True) - submitter.start() +def test_safe_write_returns_structured_result_when_drained() -> None: + reader = _FakeReader() + reader.last_read_uid = UID.hex() + runtime = _build_runtime_with(reader) + runtime.last_scans[0] = {"event": "tag_read", "uid": UID.hex().upper()} + wakeups: list[int] = [] + original_start_reading_tag = runtime.start_reading_tag - # Wait until the request is registered, then drain it from the "loop". - deadline = time.time() + 1.0 - while time.time() < deadline: - if 0 in runtime._pending_writes: - break - time.sleep(0.01) - assert 0 in runtime._pending_writes, "submit_write did not register pending write" + def _record_wakeup(slot: int) -> None: + wakeups.append(slot) + original_start_reading_tag(slot) - handled = runtime._drain_pending_write(0, reader) - assert handled is True + runtime.start_reading_tag = _record_wakeup + result_box: dict = {} + submitter = threading.Thread( + target=lambda: result_box.setdefault( + "result", + runtime.submit_tigertag_write( + 0, + UID.hex(":"), + MAKER_PAYLOAD, + timeout=2.0, + allow_legacy_migration=True, + ), + ), + daemon=True, + ) + submitter.start() + _wait_until_queued(runtime) + + assert runtime._drain_pending_write(0) is True submitter.join(timeout=2.0) - assert not submitter.is_alive(), "submitter did not unblock" result = result_box["result"] assert result["ok"] is True - assert result["status"] == 0 - assert result["start_page"] == 4 - assert result["bytes_written"] == 96 + assert result["code"] == "written" + assert result["action"] == "write" + assert result["slot"] == 0 + assert result["expected_uid"] == UID.hex().upper() + assert len(result["operation_id"]) == 32 + assert reader.calls == [("write", UID, MAKER_PAYLOAD, False, True)] + assert reader.sessions_started == reader.sessions_ended == 1 + assert reader.last_read_uid is None + assert 0 not in runtime.last_scans + assert wakeups == [0, 0] + assert runtime.read_retries_left[0] == runtime.config.read_retries + + +def test_safe_clear_uses_fixed_operation_and_allow_override() -> None: + reader = _FakeReader() + reader.last_read_uid = UID.hex() + runtime = _build_runtime_with(reader) + runtime.last_scans[0] = {"event": "tag_read", "uid": UID.hex().upper()} + wakeups: list[int] = [] + original_start_reading_tag = runtime.start_reading_tag + + def _record_wakeup(slot: int) -> None: + wakeups.append(slot) + original_start_reading_tag(slot) + + runtime.start_reading_tag = _record_wakeup + result_box: dict = {} + submitter = threading.Thread( + target=lambda: result_box.setdefault( + "result", + runtime.submit_tigertag_clear(0, UID, timeout=2.0, allow_unrecognized=True), + ), + daemon=True, + ) + submitter.start() + _wait_until_queued(runtime) + runtime._drain_pending_write(0) + submitter.join(timeout=2.0) - assert reader.calls == [(4, payload)] + assert result_box["result"]["code"] == "cleared" + assert reader.calls == [("clear", UID, True, False)] + assert reader.last_read_uid is None + assert 0 not in runtime.last_scans + assert wakeups == [0, 0] -def test_submit_write_rejects_invalid_slot() -> None: - reader = _FakeReader(slot=0) +def test_runtime_propagates_late_safety_block_to_reader() -> None: + reader = _FakeReader() runtime = _build_runtime_with(reader) - result = runtime.submit_write(5, b"\x00" * 4) + result_box: dict = {} + submitter = threading.Thread( + target=lambda: result_box.setdefault( + "result", + runtime.submit_tigertag_write( + 0, + UID, + MAKER_PAYLOAD, + timeout=2.0, + safety_check=lambda: { + "ok": False, + "code": "print_active", + "error": "print started", + }, + ), + ), + daemon=True, + ) + submitter.start() + _wait_until_queued(runtime) + runtime._drain_pending_write(0) + submitter.join(timeout=2.0) + + assert result_box["result"]["code"] == "print_active" + assert result_box["result"]["action"] == "write" + assert reader.sessions_started == reader.sessions_ended == 1 + + +@pytest.mark.parametrize( + ("call", "code"), + [ + (lambda runtime: runtime.submit_tigertag_write(5, UID, MAKER_PAYLOAD), "invalid_slot"), + (lambda runtime: runtime.submit_tigertag_write(0, "not-hex", MAKER_PAYLOAD), "invalid_uid"), + (lambda runtime: runtime.submit_tigertag_write(0, b"\x01\x02", MAKER_PAYLOAD), "invalid_uid"), + (lambda runtime: runtime.submit_tigertag_write(0, UID, b"\x00" * 79), "invalid_payload_length"), + (lambda runtime: runtime.submit_tigertag_write(0, UID, b"\x00" * 80), "invalid_payload_header"), + (lambda runtime: runtime.submit_tigertag_clear(0, UID, timeout=0), "invalid_timeout"), + ( + lambda runtime: runtime.submit_tigertag_clear(0, UID, allow_unrecognized="false"), + "invalid_allow_unrecognized", + ), + ( + lambda runtime: runtime.submit_tigertag_clear( + 0, + UID, + allow_legacy_migration="false", + ), + "invalid_allow_legacy_migration", + ), + ( + lambda runtime: runtime.submit_tigertag_clear(0, UID, safety_check="not-callable"), + "invalid_safety_check", + ), + ], +) +def test_safe_operation_validation(call, code: str) -> None: + runtime = _build_runtime_with(_FakeReader()) + result = call(runtime) assert result["ok"] is False - assert "invalid slot" in result["error"] + assert result["code"] == code + assert runtime._pending_writes == {} -def test_submit_write_rejects_misaligned_data() -> None: - reader = _FakeReader(slot=0) +def test_per_slot_busy_rejects_second_operation() -> None: + reader = _FakeReader() runtime = _build_runtime_with(reader) - assert runtime.submit_write(0, b"\x00" * 5)["ok"] is False - assert runtime.submit_write(0, b"")["ok"] is False + result_box: dict = {} + submitter = threading.Thread( + target=lambda: result_box.setdefault( + "result", runtime.submit_tigertag_write(0, UID, MAKER_PAYLOAD, timeout=2.0) + ), + daemon=True, + ) + submitter.start() + _wait_until_queued(runtime) + busy = runtime.submit_tigertag_clear(0, UID, timeout=1.0) + assert busy["code"] == "slot_busy" + assert len(busy["active_operation_id"]) == 32 + assert busy["active_state"] == "queued" -def test_submit_write_times_out_when_loop_idle() -> None: - reader = _FakeReader(slot=0) - runtime = _build_runtime_with(reader) + runtime._drain_pending_write(0) + submitter.join(timeout=2.0) + assert result_box["result"]["ok"] is True - result = runtime.submit_write(0, b"\x00" * 4, timeout=0.1) - assert result["ok"] is False - assert "timeout" in result["error"] - # Pending entry must be cleared on timeout so next submit can register. + +def test_queued_timeout_cancels_only_that_operation() -> None: + runtime = _build_runtime_with(_FakeReader()) + result = runtime.submit_tigertag_write(0, UID, MAKER_PAYLOAD, timeout=0.03) + assert result["code"] == "timeout" assert 0 not in runtime._pending_writes + assert runtime._drain_pending_write(0) is False + status = runtime.get_tigertag_operation_status(result["operation_id"]) + assert status["operation_state"] == "cancelled" + assert status["completed"] is True + assert status["result"]["code"] == "timeout" -def test_drain_returns_false_when_no_pending() -> None: - reader = _FakeReader(slot=0) - runtime = _build_runtime_with(reader) - assert runtime._drain_pending_write(0, reader) is False +def test_queue_wakeup_failure_removes_exact_pending_operation() -> None: + runtime = _build_runtime_with(_FakeReader()) + + def _fail_wakeup(_slot: int) -> None: + raise RuntimeError("wake failed") + runtime.start_reading_tag = _fail_wakeup + result = runtime.submit_tigertag_write(0, UID, MAKER_PAYLOAD) + assert result["code"] == "queue_wakeup_failed" + assert len(result["operation_id"]) == 32 + assert runtime._pending_writes == {} -def test_drain_handles_reader_without_write_support() -> None: - reader = _FakeReaderNoWrite(slot=0) + +def test_running_timeout_stays_registered_and_busy_until_completion() -> None: + reader = _FakeReader() + reader.entered = threading.Event() + reader.release = threading.Event() runtime = _build_runtime_with(reader) + result_box: dict = {} + submitter = threading.Thread( + target=lambda: result_box.setdefault( + "result", runtime.submit_tigertag_write(0, UID, MAKER_PAYLOAD, timeout=0.08) + ), + daemon=True, + ) + submitter.start() + _wait_until_queued(runtime) + drainer = threading.Thread(target=runtime._drain_pending_write, args=(0,), daemon=True) + drainer.start() + assert reader.entered.wait(timeout=1.0) + + submitter.join(timeout=1.0) + timed_out = result_box["result"] + assert timed_out["code"] == "timeout_in_progress" + active_status = runtime.get_tigertag_operation_status(timed_out["operation_id"]) + assert active_status["ok"] is True + assert active_status["operation_state"] == "running" + assert active_status["completed"] is False + assert active_status["slot"] == 0 + assert active_status["action"] == "write" + assert active_status["started_at"] is not None + with runtime._pending_write_lock: + running = runtime._pending_writes[0] + assert running.operation_id == timed_out["operation_id"] + assert running.state == "running" + + busy = runtime.submit_tigertag_clear(0, UID, timeout=0.1) + assert busy["code"] == "slot_busy" + assert busy["active_operation_id"] == timed_out["operation_id"] + + reader.release.set() + drainer.join(timeout=2.0) + assert not drainer.is_alive() + assert 0 not in runtime._pending_writes + assert reader.sessions_started == reader.sessions_ended == 1 + completed_status = runtime.get_tigertag_operation_status(timed_out["operation_id"]) + assert completed_status["ok"] is True + assert completed_status["operation_state"] == "completed" + assert completed_status["completed"] is True + assert completed_status["result"]["code"] == "written" + assert completed_status["completed_at"] >= completed_status["started_at"] + + +def test_operation_status_history_is_bounded_and_returns_copies() -> None: + runtime = _build_runtime_with(_FakeReader()) + runtime._tigertag_operation_history_limit = 2 + + results = [_run_clear_operation(runtime) for _ in range(3)] + + assert ( + runtime.get_tigertag_operation_status(results[0]["operation_id"])["code"] + == "operation_not_found" + ) + retained = runtime.get_tigertag_operation_status(results[1]["operation_id"]) + assert retained["operation_state"] == "completed" + assert retained["result"]["code"] == "cleared" + + retained["result"]["code"] = "mutated" + assert ( + runtime.get_tigertag_operation_status(results[1]["operation_id"])["result"]["code"] + == "cleared" + ) + assert runtime.get_tigertag_operation_status("")["code"] == "invalid_operation_id" + assert runtime.get_tigertag_operation_status("missing")["code"] == "operation_not_found" + + +def test_drain_handles_reader_without_safe_write_support() -> None: + reader = _FakeReaderNoWrite() + runtime = _build_runtime_with(reader) result_box: dict = {} + submitter = threading.Thread( + target=lambda: result_box.setdefault( + "result", runtime.submit_tigertag_write(0, UID, MAKER_PAYLOAD, timeout=2.0) + ), + daemon=True, + ) + submitter.start() + _wait_until_queued(runtime) + assert runtime._drain_pending_write(0) is True + submitter.join(timeout=2.0) + assert result_box["result"]["code"] == "reader_not_supported" + + +def test_capability_helper_reports_fixed_contract_and_busy_state() -> None: + runtime = _build_runtime_with(_FakeReader()) + capabilities = runtime.get_tigertag_write_capabilities(0) + assert capabilities == { + "supported": True, + "format": "tigertag", + "variant": "maker", + "expected_uid_required": True, + "payload_bytes": 80, + "start_page": 4, + "end_page": 23, + "clear_supported": True, + "allow_unrecognized_supported": True, + "allow_legacy_migration_supported": True, + "busy": False, + "active_operation_id": None, + "active_state": None, + "service_stopping": False, + } + assert runtime.get_tigertag_write_capabilities(3)["code"] == "invalid_slot" + + +def test_logical_slots_map_to_the_correct_reader_and_retry_index() -> None: + slot_three_reader = _FakeReader(slot=3) + slot_one_reader = _FakeReaderNoWrite(slot=1) + runtime = _build_runtime_with([slot_three_reader, slot_one_reader]) + + assert runtime.get_tigertag_write_capabilities(3)["supported"] is True + assert runtime.get_tigertag_write_capabilities(1)["supported"] is False + assert runtime.get_tigertag_write_capabilities(0)["code"] == "invalid_slot" + + runtime.start_reading_tag(1) + assert runtime.read_retries_left == [0, runtime.config.read_retries] + + result = _run_clear_operation(runtime, slot=3) + assert result["slot"] == 3 + assert slot_three_reader.calls == [("clear", UID, False, False)] + assert runtime.read_retries_left == [ + runtime.config.read_retries, + runtime.config.read_retries, + ] + + +def test_runtime_initialization_rejects_duplicate_logical_slots(monkeypatch) -> None: + readers = [_FakeReader(slot=2), _FakeReader(slot=2)] + original_get_entities = runtime_module.get_entities_by_type + + def _get_entities(entity_type: str): + if entity_type == TYPE_RFID_READER: + return readers + return original_get_entities(entity_type) + + monkeypatch.setattr(runtime_module, "get_entities_by_type", _get_entities) + with pytest.raises(ValueError, match="duplicate RFID reader slot 2"): + Runtime() + + +def test_shutdown_rejects_new_operations_before_reader_access() -> None: + reader = _FakeReader() + runtime = _build_runtime_with(reader) - def _submit(): - result_box["result"] = runtime.submit_write(0, b"\x00" * 4, timeout=2.0) + runtime.request_shutdown() + result = runtime.submit_tigertag_clear(0, UID) - submitter = threading.Thread(target=_submit, daemon=True) - submitter.start() + assert runtime.is_shutting_down() is True + assert result["code"] == "service_stopping" + assert reader.calls == [] + assert runtime._pending_writes == {} - deadline = time.time() + 1.0 - while time.time() < deadline: - if 0 in runtime._pending_writes: - break - time.sleep(0.01) - handled = runtime._drain_pending_write(0, reader) - assert handled is True +def test_shutdown_cancels_queued_operation_and_wakes_submitter() -> None: + reader = _FakeReader() + runtime = _build_runtime_with(reader) + result_box: dict = {} + submitter = threading.Thread( + target=lambda: result_box.setdefault( + "result", + runtime.submit_tigertag_clear(0, UID, timeout=2.0), + ), + daemon=True, + ) + submitter.start() + _wait_until_queued(runtime) - submitter.join(timeout=2.0) + runtime.request_shutdown() + runtime.loop() + submitter.join(timeout=1.0) + + assert not submitter.is_alive() result = result_box["result"] - assert result["ok"] is False - assert "does not support" in result["error"] + assert result["code"] == "service_stopping" + assert reader.calls == [] + status = runtime.get_tigertag_operation_status(result["operation_id"]) + assert status["operation_state"] == "cancelled" + assert status["result"]["code"] == "service_stopping" + + +def test_shutdown_after_mutation_starts_allows_operation_cleanup_to_finish() -> None: + reader = _FakeReader() + reader.entered = threading.Event() + reader.release = threading.Event() + runtime = _build_runtime_with(reader) + result_box: dict = {} + submitter = threading.Thread( + target=lambda: result_box.setdefault( + "result", + runtime.submit_tigertag_write(0, UID, MAKER_PAYLOAD, timeout=2.0), + ), + daemon=True, + ) + submitter.start() + _wait_until_queued(runtime) + drainer = threading.Thread( + target=runtime._drain_pending_write, + args=(0,), + daemon=True, + ) + drainer.start() + assert reader.entered.wait(timeout=1.0) + + runtime.request_shutdown() + assert drainer.is_alive() + reader.release.set() + drainer.join(timeout=1.0) + submitter.join(timeout=1.0) + + assert not drainer.is_alive() + assert not submitter.is_alive() + assert result_box["result"]["code"] == "written" + assert reader.sessions_started == reader.sessions_ended == 1 + assert runtime.read_retries_left == [0] + + +def test_shutdown_finishes_running_slot_and_cancels_other_queued_slot() -> None: + running_reader = _FakeReader(slot=3) + running_reader.entered = threading.Event() + running_reader.release = threading.Event() + queued_reader = _FakeReader(slot=7) + runtime = _build_runtime_with([running_reader, queued_reader]) + results: dict[int, dict] = {} + + def submit(slot: int) -> None: + results[slot] = runtime.submit_tigertag_write( + slot, + UID, + MAKER_PAYLOAD, + timeout=2.0, + ) + + submitters = [ + threading.Thread(target=submit, args=(slot,), daemon=True) + for slot in (3, 7) + ] + for thread in submitters: + thread.start() + _wait_until_queued(runtime, 3) + _wait_until_queued(runtime, 7) + + drainer = threading.Thread( + target=runtime._drain_pending_write, + args=(3,), + daemon=True, + ) + drainer.start() + assert running_reader.entered.wait(timeout=1.0) + runtime.request_shutdown() + assert runtime._drain_pending_write(7) is True + + running_reader.release.set() + drainer.join(timeout=1.0) + for thread in submitters: + thread.join(timeout=1.0) + + assert results[3]["code"] == "written" + assert results[7]["code"] == "service_stopping" + assert len(running_reader.calls) == 1 + assert queued_reader.calls == [] + + +def test_shutdown_during_preflight_fails_before_fake_mutation() -> None: + reader = _FakeReader() + runtime = _build_runtime_with(reader) + original_start_session = reader.start_session + + def start_and_stop() -> None: + original_start_session() + runtime.request_shutdown() + + reader.start_session = start_and_stop + result = _run_clear_operation(runtime) + + assert result["code"] == "service_stopping" + assert reader.sessions_started == reader.sessions_ended == 1 + + +def test_shutdown_interrupts_long_idle_wait_promptly() -> None: + runtime = _build_runtime_with(_FakeReader()) + runtime.config.read_interval_seconds = 30 + loop_thread = threading.Thread(target=runtime.loop, daemon=True) + loop_thread.start() + assert loop_thread.is_alive() + + started = time.monotonic() + runtime.request_shutdown() + loop_thread.join(timeout=0.5) + + assert not loop_thread.is_alive() + assert time.monotonic() - started < 0.5 diff --git a/test/test_safe_tigertag_writer.py b/test/test_safe_tigertag_writer.py new file mode 100644 index 0000000..350908c --- /dev/null +++ b/test/test_safe_tigertag_writer.py @@ -0,0 +1,438 @@ +"""Unit tests for the FM175XX guarded TigerTag Maker transaction.""" + +from __future__ import annotations + +import logging +import sys +import types +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = ROOT / "src" +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +# Reader modules import the Linux-only SPI/GPIO packages at module import +# time. These tests bypass hardware construction, so tiny import stubs are +# sufficient on development hosts that do not provide those packages. +sys.modules.setdefault("spidev", types.SimpleNamespace(SpiDev=object)) +sys.modules.setdefault( + "gpiod", + types.SimpleNamespace(Chip=object, LINE_REQ_DIR_OUT=0), +) + +from reader.fm175xx import constants as Constants +from reader.fm175xx.rfid import Fm175xx, Fm175xxReturnVal +from reader.gpio_enabled_rfid_reader import GpioEnabledRfidReader +from reader.mifare_ultralight_reader import ( + MifareUltralightReader, + TIGERTAG_INIT_ID, + TIGERTAG_MAKER_ID, + TIGERTAG_PLUS_ID, + TIGERTAG_OWNED_LENGTH, +) + + +UID = bytes.fromhex("04112233445566") +MAKER_PAYLOAD = TIGERTAG_MAKER_ID.to_bytes(4, "big") + bytes(range(TIGERTAG_OWNED_LENGTH - 4)) + + +class _FmHarness: + def __init__(self, existing_header: int = TIGERTAG_MAKER_ID, existing_product: int = 0): + self.memory = bytearray(231 * 4) + self.memory[4 * 4:4 * 4 + 4] = existing_header.to_bytes(4, "big") + self.memory[5 * 4:5 * 4 + 4] = existing_product.to_bytes(4, "big") + self.uid = UID + self.sak = b"\x04\x00" + self.activate_status = Constants.FM175XX_OK + self.read_fail_pages: set[int] = set() + self.write_fail_pages: set[int] = set() + self.ignore_write_pages: set[int] = set() + self.read_calls: list[int] = [] + self.write_calls: list[tuple[int, bytes]] = [] + self.version_storage = 0x11 + + # Default NTAG215 CFG0.AUTH0 disables password protection. + self.memory[(130 + 1) * 4 + 3] = 0xFF + + self.reader = object.__new__(Fm175xx) + self.reader.logger = logging.getLogger("test.safe_tigertag_writer") + self.reader._Fm175xx__reader_a_activate = self.activate + self.reader._Fm175xx__reader_a_ultralight_page_read = self.read_pages + self.reader._Fm175xx__ntag_page_write = self.write_page + self.reader._Fm175xx__ntag_get_version = self.get_version + + def get_version(self) -> Fm175xxReturnVal: + result = Fm175xxReturnVal() + result.err_code = Constants.FM175XX_OK + result.out_data = [0x00, 0x04, 0x04, 0x02, 0x01, 0x00, self.version_storage, 0x03] + return result + + def activate(self): + return self.activate_status, list(self.uid), [0x44, 0x00], [], list(self.sak) + + def read_pages(self, page: int) -> Fm175xxReturnVal: + self.read_calls.append(page) + result = Fm175xxReturnVal() + if page in self.read_fail_pages: + result.err_code = Constants.FM175XX_CARD_READ_ERR + return result + result.err_code = Constants.FM175XX_OK + offset = page * 4 + result.out_data = list(self.memory[offset:offset + 16]) + return result + + def write_page(self, page: int, data: list[int]) -> int: + raw = bytes(data) + self.write_calls.append((page, raw)) + if page in self.write_fail_pages: + return Constants.FM175XX_CARD_WRITE_ERR + if page not in self.ignore_write_pages: + offset = page * 4 + self.memory[offset:offset + 4] = raw + return Constants.FM175XX_OK + + def owned_region(self) -> bytes: + start = 4 * 4 + return bytes(self.memory[start:start + TIGERTAG_OWNED_LENGTH]) + + +def test_write_invalidates_header_writes_body_then_publishes_header() -> None: + harness = _FmHarness() + result = harness.reader.write_tigertag_maker(UID, MAKER_PAYLOAD) + + assert result == { + "ok": True, + "code": "written", + "uid": UID.hex().upper(), + "previous_format": "maker", + "start_page": 4, + "end_page": 23, + "bytes_written": 80, + "verified": True, + "tag_model": "NTAG215", + } + assert harness.owned_region() == MAKER_PAYLOAD + assert harness.write_calls[0] == (4, b"\x00" * 4) + assert [page for page, _data in harness.write_calls[1:20]] == list(range(5, 24)) + assert harness.write_calls[-1] == (4, MAKER_PAYLOAD[:4]) + assert harness.read_calls == [4, 8, 12, 16, 20, 2, 130] + [4, 8, 12, 16, 20] * 2 + + +def test_init_tag_is_an_allowed_existing_format() -> None: + harness = _FmHarness(TIGERTAG_INIT_ID) + result = harness.reader.write_tigertag_maker(UID, MAKER_PAYLOAD) + assert result["ok"] is True + assert result["previous_format"] == "init" + + +def test_unrecognized_tag_requires_explicit_override() -> None: + harness = _FmHarness(0) + rejected = harness.reader.write_tigertag_maker(UID, MAKER_PAYLOAD) + assert rejected["code"] == "unrecognized_tag" + assert harness.write_calls == [] + + allowed = harness.reader.write_tigertag_maker(UID, MAKER_PAYLOAD, allow_unrecognized=True) + assert allowed["ok"] is True + assert allowed["previous_format"] == "unrecognized" + + +def test_tigertag_plus_is_never_overwritten_even_with_override() -> None: + harness = _FmHarness(TIGERTAG_PLUS_ID, existing_product=1234) + result = harness.reader.write_tigertag_maker( + UID, + MAKER_PAYLOAD, + allow_unrecognized=True, + ) + assert result["code"] == "protected_tag_format" + assert harness.write_calls == [] + + +def test_legacy_plus_product_zero_requires_explicit_upgrade_override() -> None: + harness = _FmHarness(TIGERTAG_PLUS_ID, existing_product=0) + rejected = harness.reader.write_tigertag_maker(UID, MAKER_PAYLOAD) + assert rejected["code"] == "legacy_upgrade_required" + assert harness.write_calls == [] + + still_rejected = harness.reader.write_tigertag_maker( + UID, + MAKER_PAYLOAD, + allow_unrecognized=True, + ) + assert still_rejected["code"] == "legacy_upgrade_required" + assert harness.write_calls == [] + + upgraded = harness.reader.write_tigertag_maker( + UID, + MAKER_PAYLOAD, + allow_legacy_migration=True, + ) + assert upgraded["ok"] is True + assert upgraded["previous_format"] == "legacy_openrfid_v1" + assert harness.owned_region() == MAKER_PAYLOAD + + +def test_plus_product_zero_with_nonzero_legacy_signature_prefix_is_protected() -> None: + harness = _FmHarness(TIGERTAG_PLUS_ID, existing_product=0) + harness.memory[24 * 4] = 0x01 + result = harness.reader.write_tigertag_maker( + UID, + MAKER_PAYLOAD, + allow_legacy_migration=True, + ) + assert result["code"] == "protected_tag_format" + assert harness.write_calls == [] + + +@pytest.mark.parametrize( + ("mutation", "expected_code"), + [ + (lambda harness: setattr(harness, "activate_status", Constants.FM175XX_CARD_ACTIVATE_ERR), "tag_not_present"), + (lambda harness: setattr(harness, "sak", b"\x08"), "wrong_tag_type"), + (lambda harness: setattr(harness, "uid", bytes.fromhex("04AABBCCDDEEFF")), "uid_mismatch"), + ], +) +def test_activation_guards_run_before_any_memory_access(mutation, expected_code: str) -> None: + harness = _FmHarness() + mutation(harness) + result = harness.reader.write_tigertag_maker(UID, MAKER_PAYLOAD) + assert result["code"] == expected_code + assert harness.read_calls == [] + assert harness.write_calls == [] + + +def test_capacity_is_probed_through_page_23_before_header_invalidation() -> None: + harness = _FmHarness() + harness.read_fail_pages.add(20) + result = harness.reader.write_tigertag_maker(UID, MAKER_PAYLOAD) + assert result["code"] == "capacity_probe_failed" + assert result["page"] == 20 + assert harness.write_calls == [] + + +@pytest.mark.parametrize( + ("lock_byte_offset", "lock_mask", "expected_pages"), + [ + (2 * 4 + 2, 1 << 4, [4]), + (2 * 4 + 2, 1 << 7, [7]), + (2 * 4 + 3, 1 << 0, [8]), + (2 * 4 + 3, 1 << 7, [15]), + ], +) +def test_static_locks_are_rejected_before_header_invalidation( + lock_byte_offset, lock_mask, expected_pages +) -> None: + harness = _FmHarness() + harness.memory[lock_byte_offset] = lock_mask + result = harness.reader.write_tigertag_maker(UID, MAKER_PAYLOAD) + assert result["code"] == "tag_locked" + assert result["lock_source"] == "static" + assert result["locked_pages"] == expected_pages + assert harness.write_calls == [] + + +@pytest.mark.parametrize( + ("storage", "lock_page", "lock_mask", "expected_pages"), + [ + (0x0F, 40, 1 << 0, [16, 17]), + (0x0F, 40, 1 << 3, [22, 23]), + (0x11, 130, 1 << 0, list(range(16, 24))), + (0x13, 226, 1 << 0, list(range(16, 24))), + ], +) +def test_dynamic_locks_are_rejected_before_header_invalidation( + storage, lock_page, lock_mask, expected_pages +) -> None: + harness = _FmHarness() + harness.version_storage = storage + harness.memory[lock_page * 4] = lock_mask + harness.memory[(lock_page + 1) * 4 + 3] = 0xFF + result = harness.reader.write_tigertag_maker(UID, MAKER_PAYLOAD) + assert result["code"] == "tag_locked" + assert result["lock_source"] == "dynamic" + assert result["locked_pages"] == expected_pages + assert harness.write_calls == [] + + +def test_password_protected_owned_region_is_rejected_before_mutation() -> None: + harness = _FmHarness() + harness.memory[(130 + 1) * 4 + 3] = 12 + result = harness.reader.write_tigertag_maker(UID, MAKER_PAYLOAD) + assert result["code"] == "tag_password_protected" + assert result["auth0_page"] == 12 + assert harness.write_calls == [] + + +def test_unknown_ntag_version_is_rejected_before_mutation() -> None: + harness = _FmHarness() + harness.version_storage = 0x99 + result = harness.reader.write_tigertag_maker(UID, MAKER_PAYLOAD) + assert result["code"] == "unsupported_tag_model" + assert harness.write_calls == [] + + +def test_authoritative_safety_check_runs_after_probe_before_first_write() -> None: + harness = _FmHarness() + result = harness.reader.write_tigertag_maker( + UID, + MAKER_PAYLOAD, + safety_check=lambda: { + "ok": False, + "code": "print_active", + "error": "print started while the operation was pending", + "print_state": "printing", + }, + ) + + assert result["code"] == "print_active" + assert result["phase"] == "pre_write" + assert harness.read_calls == [4, 8, 12, 16, 20, 2, 130] + assert harness.write_calls == [] + + +def test_body_write_failure_leaves_header_invalidated() -> None: + harness = _FmHarness() + harness.write_fail_pages.add(9) + result = harness.reader.write_tigertag_maker(UID, MAKER_PAYLOAD) + assert result["code"] == "body_write_failed" + assert result["page"] == 9 + assert result["header_invalidated"] is True + assert harness.owned_region()[:4] == b"\x00" * 4 + assert harness.write_calls[-1][0] == 9 + + +def test_body_is_verified_before_header_is_published() -> None: + harness = _FmHarness() + harness.ignore_write_pages.add(6) + result = harness.reader.write_tigertag_maker(UID, MAKER_PAYLOAD) + assert result["code"] == "body_verify_failed" + assert result["header_invalidated"] is True + assert harness.owned_region()[:4] == b"\x00" * 4 + assert harness.write_calls[-1][0] == 23 + + +def test_full_readback_detects_final_header_mismatch() -> None: + harness = _FmHarness(TIGERTAG_INIT_ID) + harness.ignore_write_pages.add(4) + result = harness.reader.write_tigertag_maker(UID, MAKER_PAYLOAD) + assert result["code"] == "verify_failed" + assert harness.owned_region()[:4] == TIGERTAG_INIT_ID.to_bytes(4, "big") + + +def test_clear_zeros_exactly_the_owned_80_bytes_and_verifies() -> None: + harness = _FmHarness() + start = 4 * 4 + harness.memory[start:start + 80] = MAKER_PAYLOAD + sentinel_before = bytes(harness.memory[:start]) + sentinel_after = bytes(harness.memory[start + 80:]) + + result = harness.reader.clear_tigertag_maker(UID) + + assert result["code"] == "cleared" + assert result["bytes_written"] == 80 + assert harness.owned_region() == b"\x00" * 80 + assert bytes(harness.memory[:start]) == sentinel_before + assert bytes(harness.memory[start + 80:]) == sentinel_after + assert harness.write_calls[0] == (4, b"\x00" * 4) + assert harness.write_calls[-1] == (4, b"\x00" * 4) + + +def test_general_page_writer_is_not_public() -> None: + assert not hasattr(Fm175xx, "write_ntag_pages") + assert not hasattr(GpioEnabledRfidReader, "write_ntag_pages") + + +class _InnerSafeReader: + def __init__(self): + self.calls: list[tuple] = [] + + def write_tigertag_maker( + self, + expected_uid, + data, + allow_unrecognized=False, + allow_legacy_migration=False, + ): + self.calls.append( + ("write", expected_uid, data, allow_unrecognized, allow_legacy_migration) + ) + return {"ok": True, "code": "written"} + + def clear_tigertag_maker( + self, + expected_uid, + allow_unrecognized=False, + allow_legacy_migration=False, + ): + self.calls.append( + ("clear", expected_uid, allow_unrecognized, allow_legacy_migration) + ) + return {"ok": True, "code": "cleared"} + + +def test_gpio_wrapper_requires_an_ultralight_inner_reader() -> None: + wrapper = object.__new__(GpioEnabledRfidReader) + wrapper.rfid_reader = _InnerSafeReader() + result = wrapper.write_tigertag_maker(UID, MAKER_PAYLOAD) + assert result["code"] == "reader_not_supported" + + +class _InnerUltralightReader(MifareUltralightReader): + def start_session(self): + raise AssertionError("GPIO wrapper must not open a second inner session") + + def end_session(self): + raise AssertionError("GPIO wrapper must not close a second inner session") + + def scan(self): + return None + + def read_mifare_ultralight(self, scan_result): + return None + + def write_tigertag_maker( + self, + expected_uid, + data, + allow_unrecognized=False, + allow_legacy_migration=False, + ): + self.calls.append( + ("write", expected_uid, data, allow_unrecognized, allow_legacy_migration) + ) + return {"ok": True, "code": "written"} + + def clear_tigertag_maker( + self, + expected_uid, + allow_unrecognized=False, + allow_legacy_migration=False, + ): + self.calls.append( + ("clear", expected_uid, allow_unrecognized, allow_legacy_migration) + ) + return {"ok": True, "code": "cleared"} + + +def test_gpio_wrapper_delegates_without_opening_a_second_session() -> None: + inner = object.__new__(_InnerUltralightReader) + inner.calls = [] + wrapper = object.__new__(GpioEnabledRfidReader) + wrapper.rfid_reader = inner + + written = wrapper.write_tigertag_maker( + UID, + MAKER_PAYLOAD, + allow_unrecognized=True, + allow_legacy_migration=True, + ) + cleared = wrapper.clear_tigertag_maker(UID) + + assert written["code"] == "written" + assert cleared["code"] == "cleared" + assert inner.calls == [ + ("write", UID, MAKER_PAYLOAD, True, True), + ("clear", UID, False, False), + ] diff --git a/test/test_tags.py b/test/test_tags.py index 9d6f475..d205515 100644 --- a/test/test_tags.py +++ b/test/test_tags.py @@ -180,6 +180,18 @@ def _build_tigertag_processor() -> TagProcessor: }, } +EXPECTED_TAG_FORMATS = { + "Anycubic": "anycubic", + "Bambu": "bambu", + "Creality": "creality", + "Elegoo": "elegoo", + "OpenSpool": "openspool", + "Qidi": "qidi", + "SpoolEase": "spoolease", + "Snapmaker": "snapmaker", + "Tigertag": "tigertag", +} + def _get_processor_fixture(folder_name: str) -> dict: if folder_name not in PROCESSOR_FIXTURES: @@ -245,3 +257,22 @@ def test_tag_processor_fixture_matches_expected_output(fixture_path: Path) -> No actual = filament.to_dict() _assert_expected_matches_actual(expected, actual) + assert actual["tag_format"] == EXPECTED_TAG_FORMATS[folder_name] + assert isinstance(actual["present_fields"], list) + assert isinstance(actual["format_data"], dict) + + +def test_all_concrete_processors_define_stable_tag_formats() -> None: + processor_types = { + AnycubicTagProcessor: "anycubic", + BambuTagProcessor: "bambu", + CrealityTagProcessor: "creality", + ElegooTagProcessor: "elegoo", + OpenspoolTagProcessor: "openspool", + QidiTagProcessor: "qidi", + SnapmakerTagProcessor: "snapmaker", + SpooleaseTagProcessor: "spoolease", + TigerTagProcessor: "tigertag", + } + for processor_type, tag_format in processor_types.items(): + assert processor_type.tag_format == tag_format diff --git a/test/test_tigertag_encoder.py b/test/test_tigertag_encoder.py index 17ab39c..9c058a9 100644 --- a/test/test_tigertag_encoder.py +++ b/test/test_tigertag_encoder.py @@ -1,14 +1,11 @@ -"""Round-trip tests for the TigerTag encoder. - -Verifies that ``encode(spec)`` produces a 96-byte payload that the existing -:class:`TigerTagProcessor` can parse back into a :class:`GenericFilament` whose -fields match the spec. -""" +"""Official TigerTag SDK adapter and OpenRFID model tests.""" +import json import sys from pathlib import Path import pytest +from tigertag import ID_TIGERTAG, ID_TIGERTAG_PLUS, MAKER_PRODUCT_ID, TigerTag ROOT = Path(__file__).resolve().parents[1] SRC_ROOT = ROOT / "src" @@ -21,24 +18,31 @@ from tag.tigertag import constants as Constants from tag.tigertag.encoder import ( PAYLOAD_LENGTH_BYTES, + PAYLOAD_PAGE_COUNT, + TIGERTAG_MAKER_PRODUCT_ID, TIGERTAG_TAG_ID, + USER_DATA_END_PAGE, USER_DATA_START_PAGE, + decode_payload, encode, encode_blank, + erase, + get_options, + validate_maker_payload, ) from tag.tigertag.processor import TigerTagProcessor -def _build_processor() -> TigerTagProcessor: - processor = TigerTagProcessor({"__name": "TigerTagProcessor"}) +def _build_processor(name: str = "TigerTagProcessor") -> TigerTagProcessor: + processor = TigerTagProcessor({"__name": name}) processor.enabled = True return processor -def _build_scan_result() -> ScanResult: +def _build_scan_result(uid: bytes = bytes.fromhex("04A1B2C3D4E5F6")) -> ScanResult: return ScanResult( tag_type=TagType.MifareUltralight, - uid=b"\x01\x02\x03\x04", + uid=uid, atqa=b"\x00\x44", bcc=b"\x00", sak=b"\x00", @@ -46,28 +50,15 @@ def _build_scan_result() -> ScanResult: def _wrap_user_payload(payload: bytes) -> bytes: - """Prepend ``USER_DATA_BYTE_OFFSET`` zero bytes so the parser sees the - payload at user-data page 4 (the parser slices past the first - ``USER_DATA_BYTE_OFFSET`` bytes).""" + """Prepend pages 0-3 as returned by the FM175xx full-memory reader.""" return b"\x00" * Constants.USER_DATA_BYTE_OFFSET + payload -def test_encoder_payload_length_and_start_page() -> None: - assert PAYLOAD_LENGTH_BYTES == 96 - assert USER_DATA_START_PAGE == 4 - assert len(encode({})) == PAYLOAD_LENGTH_BYTES - assert encode_blank() == b"\x00" * PAYLOAD_LENGTH_BYTES - - -def test_encoder_emits_tag_id_header() -> None: - payload = encode({}) - assert int.from_bytes(payload[0:4], "big") == TIGERTAG_TAG_ID - - -def test_encoder_round_trip_minimal_pla_spec() -> None: - spec = { +def _minimal_pla_spec() -> dict: + return { "material": "PLA", "brand": "ELEGOO", + "type": "Filament", "diameter": "1.75", "color": "#BCBCBC", "weight_g": 1000, @@ -83,42 +74,325 @@ def test_encoder_round_trip_minimal_pla_spec() -> None: "message": "Hello, world!", } - payload = encode(spec) - assert len(payload) == PAYLOAD_LENGTH_BYTES +def test_encoder_uses_exact_maker_owned_pages() -> None: + assert PAYLOAD_LENGTH_BYTES == 80 + assert PAYLOAD_PAGE_COUNT == 20 + assert USER_DATA_START_PAGE == 4 + assert USER_DATA_END_PAGE == 23 + assert len(encode({})) == PAYLOAD_LENGTH_BYTES + assert encode_blank() == erase() == b"\x00" * PAYLOAD_LENGTH_BYTES + + +def test_encoder_emits_official_maker_identity() -> None: + payload = encode({}) + tag = validate_maker_payload(payload) + + assert TIGERTAG_TAG_ID == ID_TIGERTAG + assert TIGERTAG_MAKER_PRODUCT_ID == MAKER_PRODUCT_ID + assert tag.id_tigertag == ID_TIGERTAG + assert tag.id_product == MAKER_PRODUCT_ID + assert tag.is_maker + + +def test_options_are_json_safe_official_sdk_registry_records() -> None: + options = get_options() + serialized = json.dumps(options, sort_keys=True) + assert serialized + assert options["schema_version"] == 1 + assert options["sdk"] == { + "name": "tigertag", + "version": "1.2.1", + "commit": "f3e2e2e8a1fdf88f91fb43ca4b5c5fbfb88f81af", + } + + for key in ("materials", "brands", "aspects", "types", "diameters", "units"): + assert options[key] + assert all( + isinstance(record["id"], int) and isinstance(record["label"], str) + for record in options[key] + ) + + pla = next(record for record in options["materials"] if record["label"] == "PLA") + assert pla["material_type"] == "PLA" + assert pla["product_type_id"] == 142 + assert "recommended" in pla + + tricolor = next(record for record in options["aspects"] if record["label"] == "Tricolor") + bicolor = next(record for record in options["aspects"] if record["label"] == "Bicolor") + assert tricolor["color_count"] == 3 + assert bicolor["color_count"] == 2 + + # Callers cannot mutate the cached SDK database through the returned view. + options["aspects"][0]["label"] = "mutated" + assert get_options()["aspects"][0]["label"] != "mutated" + + +def test_encoder_round_trip_preserves_legacy_spec_fields() -> None: + payload = encode(_minimal_pla_spec()) filament = _build_processor().process_tag(_build_scan_result(), _wrap_user_payload(payload)) assert filament is not None + assert filament.tag_format == "tigertag" assert filament.diameter_mm == pytest.approx(1.75) assert filament.weight_grams == pytest.approx(1000.0) assert filament.hotend_min_temp_c == pytest.approx(190.0) assert filament.hotend_max_temp_c == pytest.approx(240.0) assert filament.bed_temp_c == pytest.approx(45.0) + assert filament.bed_temp_min_c == pytest.approx(45.0) assert filament.bed_temp_max_c == pytest.approx(55.0) assert filament.drying_temp_c == pytest.approx(50.0) assert filament.drying_time_hours == pytest.approx(8.0) assert filament.manufacturing_date == "2026-04-03" assert filament.message == "Hello, world!" + assert filament.colors == [0xFFBCBCBC] + assert filament.authentication is not None + assert filament.authentication["verification"] == "unsigned" + + +def test_message_limit_is_28_utf8_bytes_without_partial_codepoints() -> None: + ascii_payload = encode({"message": "x" * 28}) + assert ascii_payload[48:76] == b"x" * 28 + + emoji_payload = encode({"message": "\U0001F600" * 7}) + assert emoji_payload[48:76].decode("utf-8") == "\U0001F600" * 7 + + with pytest.raises(ValueError, match="28 UTF-8 bytes"): + encode({"message": "x" * 29}) + with pytest.raises(ValueError, match="28 UTF-8 bytes"): + encode({"message": "\U0001F600" * 8}) + + +def test_three_colors_quantity_ranges_message_and_td_are_additive() -> None: + spec = { + **_minimal_pla_spec(), + "aspect_1": "Silk", + "aspect_2": "Tricolor", + "colors": ["#112233", "#445566", "#778899"], + "measure": 1000, + "measure_available": 640, + "td_mm": 12.5, + "message": "Galaxy Black", + } + + payload = encode(spec) + sdk_tag = validate_maker_payload(payload, uid=_build_scan_result().uid) + assert sdk_tag.measure == 1000 + assert sdk_tag.measure_available == 640 + assert sdk_tag.td_value == pytest.approx(12.5) + assert sdk_tag.color2_hex == "#445566" + assert sdk_tag.color3_hex == "#778899" + + filament = _build_processor("renamed_tigertag_section").process_tag( + _build_scan_result(), + _wrap_user_payload(payload), + ) + assert filament is not None + assert filament.source_processor == "renamed_tigertag_section" + assert filament.tag_format == "tigertag" + assert filament.colors == [0xFF112233, 0xFF445566, 0xFF778899] + assert filament.available_quantity == pytest.approx(640.0) + assert filament.quantity_unit == "g" + assert filament.bed_temp_min_c == pytest.approx(45.0) + assert filament.bed_temp_max_c == pytest.approx(55.0) + assert filament.message == "Galaxy Black" + assert filament.td == pytest.approx(12.5) + assert {"message", "td", "td_mm", "available_quantity", "bed_temp_max_c"} <= set( + filament.present_fields + ) + assert filament.format_data["product"]["mode"] == "maker" + assert filament.format_data["product_type"]["label"] == "Filament" + assert filament.format_data["raw"]["measure_available"] == 640 + assert filament.authentication["verification"] == "unsigned" + + serialized = filament.to_dict() + assert serialized["tag_format"] == "tigertag" + assert serialized["bed_temp_min_c"] == pytest.approx(45.0) + assert serialized["td_mm"] == pytest.approx(12.5) + assert serialized["colors_rgba_hex"] == ["112233FF", "445566FF", "778899FF"] + + +def test_sdk_adapter_decodes_init_plus_and_signed_lengths() -> None: + uid = _build_scan_result().uid + + init = TigerTag.as_init(uid=uid) + decoded_init = decode_payload(init.to_bytes(), uid=uid) + assert decoded_init.is_init + + plus = TigerTag.create(product_id=1234, uid=uid, id_material=38219) + decoded_plus = decode_payload(plus.to_bytes(), uid=uid) + assert not decoded_plus.is_maker + assert not decoded_plus.is_init + assert decoded_plus.id_product == 1234 + + signed_length = plus.to_bytes(include_signature=True) + assert len(signed_length) == 144 + assert decode_payload(signed_length, uid=uid).id_product == 1234 + + +def test_real_signed_plus_fixture_reports_valid_and_wrong_uid_invalid() -> None: + fixture_path = ( + Path(__file__).resolve().parent + / "tags" + / "Tigertag" + / "eSun PLA Basic Refill Cold White TigerTag+.bin" + ) + dump = fixture_path.read_bytes() + uid = dump[0:3] + dump[4:8] + + verified = _build_processor().process_tag(_build_scan_result(uid), dump) + assert verified is not None + assert verified.format_data["variant"] == "plus" + assert verified.authentication == { + "signed": True, + "verification": "valid", + "sdk_verification": "valid", + "ok": True, + "detail": "", + } + + wrong_uid = uid[:-1] + bytes([uid[-1] ^ 0x01]) + invalid = _build_processor().process_tag(_build_scan_result(wrong_uid), dump) + assert invalid is not None + assert invalid.authentication["signed"] is True + assert invalid.authentication["verification"] == "invalid" + assert invalid.authentication["ok"] is False + + +def test_legacy_96_byte_plus_product_zero_is_explicitly_classified() -> None: + canonical = encode(_minimal_pla_spec()) + # The old feature writer emitted a 96-byte body with the Plus format ID, + # product 0, and a zero signature fragment. A full FM read supplies at + # least 144 user bytes, all zero after that legacy body. + legacy_body = bytearray(144) + legacy_body[:80] = canonical + legacy_body[0:4] = ID_TIGERTAG_PLUS.to_bytes(4, "big") + legacy_body[4:8] = bytes(4) + filament = _build_processor().process_tag( + _build_scan_result(), + _wrap_user_payload(bytes(legacy_body)), + ) + assert filament is not None + assert filament.manufacturer == "ELEGOO" + assert filament.type == "PLA" + assert filament.message == "Hello, world!" + assert filament.format_data["variant"] == "legacy_openrfid_v1" + assert filament.format_data["raw"]["id_tigertag"] == ID_TIGERTAG_PLUS + assert filament.format_data["raw"]["id_product"] == 0 + assert filament.authentication["signed"] is False + assert filament.authentication["verification"] == "legacy_openrfid_v1" + assert filament.authentication["sdk_verification"] == "unsigned" + assert "rewrite explicitly as a Maker tag" in filament.authentication["detail"] + + +def test_legacy_detection_uses_zero_signature_prefix_not_unrelated_tail() -> None: + legacy_body = bytearray(144) + legacy_body[:80] = encode(_minimal_pla_spec()) + legacy_body[0:4] = ID_TIGERTAG_PLUS.to_bytes(4, "big") + legacy_body[4:8] = bytes(4) + # Pages 24..27 remain the guaranteed zero prefix from the old 96-byte + # writer, while later untouched memory contains stale non-zero bytes. + legacy_body[96] = 0xA5 + + filament = _build_processor().process_tag( + _build_scan_result(), + _wrap_user_payload(bytes(legacy_body)), + ) + assert filament is not None + assert filament.format_data["variant"] == "legacy_openrfid_v1" + assert filament.authentication["signed"] is False + assert filament.authentication["stale_signature_tail"] is True + assert any("stale bytes" in warning for warning in filament.format_data["validation_warnings"]) + + +@pytest.mark.parametrize( + ("unit", "measure", "expected_weight"), + [ + ("kg", 2, 2000.0), + ("mg", 2500, 2.5), + ("m", 1000, None), + ("ml", 750, None), + ], +) +def test_quantity_units_only_project_weight_units_to_grams(unit, measure, expected_weight) -> None: + payload = encode({**_minimal_pla_spec(), "unit": unit, "measure": measure}) + filament = _build_processor().process_tag( + _build_scan_result(), + _wrap_user_payload(payload), + ) + assert filament is not None + assert filament.available_quantity == pytest.approx(float(measure)) + assert filament.quantity_unit == unit + assert filament.weight_grams == expected_weight + assert ("weight_grams" in filament.present_fields) is (expected_weight is not None) + + +@pytest.mark.parametrize("offset", [39, 43, 46, 47, 79]) +def test_maker_validation_rejects_noncanonical_reserved_bytes(offset) -> None: + payload = bytearray(encode(_minimal_pla_spec())) + payload[offset] = 0xA5 + with pytest.raises(ValueError, match="not canonical"): + validate_maker_payload(bytes(payload)) + + +def test_maker_validation_rejects_lossy_invalid_utf8() -> None: + payload = bytearray(encode(_minimal_pla_spec())) + payload[48:76] = b"\xFF" + bytes(27) + with pytest.raises(ValueError, match="not canonical"): + validate_maker_payload(bytes(payload)) + + +@pytest.mark.parametrize( + ("payload", "variant"), + [ + (TigerTag.as_init().to_bytes(), "init"), + (encode({}), "maker"), + ], +) +def test_init_and_empty_maker_remain_identifiable_without_printer_material(payload, variant) -> None: + filament = _build_processor().process_tag( + _build_scan_result(), + _wrap_user_payload(payload), + ) + assert filament is not None + assert filament.tag_format == "tigertag" + assert filament.format_data["variant"] == variant + assert filament.type_supported is False + assert filament.to_dict()["type_supported"] is False + - # Color: encoder takes "#RRGGBB" (opaque). Parser stores ARGB, alpha=0xFF. - assert filament.colors == [(0xFF << 24) | (0xBC << 16) | (0xBC << 8) | 0xBC] +def test_every_official_material_option_round_trips_through_processor() -> None: + processor = _build_processor() + failures = [] + for material in get_options()["materials"]: + try: + payload = encode({ + "material": material["id"], + "type": material.get("product_type_id") or 142, + "diameter": 56, + "unit": 21, + "color": "#112233", + "measure": 1000, + }) + filament = processor.process_tag( + _build_scan_result(), + _wrap_user_payload(payload), + ) + if filament is None: + failures.append(material["label"]) + except Exception as exc: # collect all registry failures in one assertion + failures.append(f"{material['label']}: {exc}") + assert failures == [] -def test_encoder_message_truncation_and_padding() -> None: - long_msg = "x" * 100 - payload = encode({"message": long_msg}) - msg_region = payload[Constants.OFF_MESSAGE:Constants.OFF_MESSAGE + Constants.MESSAGE_LENGTH] - assert msg_region == b"x" * Constants.MESSAGE_LENGTH - short_payload = encode({"message": "ab"}) - short_region = short_payload[Constants.OFF_MESSAGE:Constants.OFF_MESSAGE + Constants.MESSAGE_LENGTH] - assert short_region == b"ab" + b"\x00" * (Constants.MESSAGE_LENGTH - 2) +def test_maker_validation_rejects_wrong_length_and_non_maker_models() -> None: + with pytest.raises(ValueError, match="exactly 80"): + validate_maker_payload(bytes(96)) + plus = TigerTag.create(product_id=1234).to_bytes() + with pytest.raises(ValueError, match="not an unsigned TigerTag Maker"): + validate_maker_payload(plus) -def test_encoder_message_does_not_split_utf8_codepoint() -> None: - # 4-byte UTF-8 emoji repeated; should truncate at a codepoint boundary. - payload = encode({"message": "\U0001F600" * 10}) # 😀 × 10 = 40 bytes - msg_region = payload[Constants.OFF_MESSAGE:Constants.OFF_MESSAGE + Constants.MESSAGE_LENGTH] - # Decoding the truncated region must succeed without errors. - decoded = msg_region.rstrip(b"\x00").decode("utf-8") - assert decoded == "\U0001F600" * 7 # 28 bytes / 4 bytes per emoji = 7 + with pytest.raises(ValueError, match="only encode unsigned TigerTag Maker"): + encode({"product_id": 1234}) From af5ff9d027039820c9f8c7e75d3fdc50b8678b82 Mon Sep 17 00:00:00 2001 From: Physics G Date: Sun, 11 Oct 2026 18:15:56 +0200 Subject: [PATCH 8/8] Report opentag3d tag_format and align tests with unsupported-material handling --- src/tag/opentag3d/processor.py | 3 +++ test/test_opentag3d.py | 9 +++++++-- test/test_tags.py | 1 + 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/tag/opentag3d/processor.py b/src/tag/opentag3d/processor.py index 57f2610..a71cbc1 100644 --- a/src/tag/opentag3d/processor.py +++ b/src/tag/opentag3d/processor.py @@ -8,6 +8,8 @@ # See "Reader Implementation Guidelines" for record selection and version handling. class OpenTag3DTagProcessor(NdefTagProcessor): + tag_format = "opentag3d" + def __init__(self, config: dict): """Use the shared NDEF reader setup so this format works with existing configuration.""" super().__init__(config) @@ -105,4 +107,5 @@ def __to_filament(self, scan_result: ScanResult, data: dict) -> GenericFilament: # Reuse the other processors' unknown-date sentinel for absent dates. manufacturing_date=data["mfg_date"] or "0001-01-01", td=data["td"], + tag_format=self.tag_format, ) diff --git a/test/test_opentag3d.py b/test/test_opentag3d.py index 95565f6..60b1aa4 100644 --- a/test/test_opentag3d.py +++ b/test/test_opentag3d.py @@ -124,9 +124,14 @@ def test_invalid_text_date_time_or_temperature(processor, scan, payload, offset, assert processor.process_tag(scan, tag(record(payload))) is None -def test_unknown_material_fails_cleanly(processor, scan, payload): +def test_unknown_material_is_kept_but_unsupported(processor, scan, payload): + # GenericFilament keeps decoded materials outside the profile whitelist + # visible and flags them instead of rejecting the whole tag. payload[2:7] = b"XXXXX" - assert processor.process_tag(scan, tag(record(payload))) is None + result = processor.process_tag(scan, tag(record(payload))) + assert result is not None + assert result.type == "XXXXX" + assert result.type_supported is False def test_unrelated_mime(processor, scan, payload): diff --git a/test/test_tags.py b/test/test_tags.py index edad3cd..db2d399 100644 --- a/test/test_tags.py +++ b/test/test_tags.py @@ -191,6 +191,7 @@ def _build_tigertag_processor() -> TagProcessor: "Creality": "creality", "Elegoo": "elegoo", "OpenSpool": "openspool", + "OpenTag3D": "opentag3d", "Qidi": "qidi", "SpoolEase": "spoolease", "Snapmaker": "snapmaker",