diff --git a/examples/u1.cfg b/examples/u1.cfg index 98de690..726363b 100644 --- a/examples/u1.cfg +++ b/examples/u1.cfg @@ -124,6 +124,32 @@ 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. 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). +#events = tag_read,tag_parse_error,tag_not_present +#max_queued = 64 + [configuration] auto_read_mode = false retries = 20 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 new file mode 100644 index 0000000..21286fc --- /dev/null +++ b/src/controllers/openrfid_api.py @@ -0,0 +1,632 @@ +"""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``, ``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`. + +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 math +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_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): + """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.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, + 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, + REMOTE_METHOD_TIGERTAG_OPTIONS: self._handle_tigertag_options, + REMOTE_METHOD_OPERATION_STATUS: self._handle_operation_status, + } + + # ------------------------------------------------------------------ + # 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._set_print_state(None) + self._klippy_ready = False + self._send_register_agent() + + # 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, + 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: + 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) + response = {"jsonrpc": "2.0", "error": {"code": -32000, "message": str(exc)}} + + if msg_id is not None: + response["id"] = msg_id + try: + 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) + + # ------------------------------------------------------------------ + # Outgoing messages + # ------------------------------------------------------------------ + def _send_register_agent(self): + self.send_message({ + "jsonrpc": "2.0", + "method": "server.connection.identify", + "params": { + "client_name": AGENT_NAME, + "version": "0.2.0", + "type": "agent", + "url": "https://github.com/physicsG/OpenRFID", + }, + "id": _MSG_ID_REGISTER_AGENT, + }) + + 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": "printer.objects.subscribe", + "params": {"objects": {"print_stats": ["state"]}}, + "id": _MSG_ID_PRINT_STATE_SUBSCRIBE, + }) + + 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") + return + + while not self._stop_broadcast.is_set(): + event = exporter.pop_event(timeout=0.5) + 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 + 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 + + # ------------------------------------------------------------------ + # Handlers + # ------------------------------------------------------------------ + 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": slot, + "name": reader.name, + "last_scan": self.runtime.last_scans.get(slot), + "capabilities": { + "scan": True, + "tigertag_write": write_capabilities, + }, + }) + 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: + 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, + 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) + allow_unrecognized = self._allow_unrecognized(params) + allow_legacy_migration = self._allow_legacy_migration(params) + timeout = self._timeout(params) + except ValueError as exc: + return self._error("invalid_request", str(exc)) + + 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 {} + 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, + "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 _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") + 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/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..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,14 +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 + 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 @@ -38,11 +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.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" @@ -52,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) @@ -84,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, @@ -99,14 +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": 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 88a98be..fb60277 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 @@ -15,6 +18,7 @@ import os import json import threading +import signal import configparser from tag.anycubic import AnycubicTagProcessor @@ -27,7 +31,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(): @@ -74,15 +77,30 @@ 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]}") 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") @@ -112,6 +130,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 1d2d362..d36ff25 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,6 +98,514 @@ def read_mifare_ultralight(self, scan_result: ScanResult) -> bytes | None: return bytes(data.out_data) + def _write_ntag_pages_unchecked(self, start_page: int, data: bytes) -> int: + """Legacy general NTAG writer retained for low-level diagnostics. + + 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 + 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 + + @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() + 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 + + 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 diff --git a/src/reader/gpio_enabled_rfid_reader.py b/src/reader/gpio_enabled_rfid_reader.py index 105b5a2..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 @@ -54,4 +55,56 @@ 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_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) + + 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 48f1fc8..3f3f8da 100644 --- a/src/runtime.py +++ b/src/runtime.py @@ -6,16 +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 +from typing import cast, Any, Callable +from collections import OrderedDict +import threading import time import logging +import math +import re +import uuid + + +class _PendingWrite: + """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)) @@ -27,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] @@ -42,22 +100,590 @@ def __init__(self): self.read_retries_left = [0] * len(self.rfid_readers) + # 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: + 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 safe TigerTag operations (used by the OpenRFID agent API). + # ------------------------------------------------------------------ + @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", + ) + + 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 not isinstance(data, (bytes, bytearray)): + 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. + 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) + 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 + + with self._pending_write_lock: + 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 + 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: + 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("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(slot): + continue + if not self.config.auto_read_mode and self.read_retries_left[i] <= 0: continue @@ -92,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() @@ -180,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/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/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 75cdd52..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 @@ -22,8 +25,16 @@ 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 +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 new file mode 100644 index 0000000..0703b79 --- /dev/null +++ b/src/tag/tigertag/encoder.py @@ -0,0 +1,471 @@ +"""Official-SDK adapter for unsigned TigerTag Maker payloads. + +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 math +import threading +from copy import deepcopy +from datetime import date, datetime, timezone +from typing import Any + +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, +} + + +def _database() -> TigerTagDB: + global _DB + with _DB_LOCK: + if _DB is None: + _DB = TigerTagDB() + return _DB + + +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: + if not isinstance(record, dict) or record.get("id") is None: + continue + 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 _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): + 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 _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 + try: + numeric_id = int(text, 0) + except ValueError: + try: + numeric_id = int(text, 10) + except ValueError: + 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 _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) + ): + 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) + + +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: + 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: + # 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, + ) + + 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: + """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 975670b..1c2d254 100644 --- a/src/tag/tigertag/processor.py +++ b/src/tag/tigertag/processor.py @@ -1,148 +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] - - 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(" 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=0.0, - drying_temp_c=float(dry_temp), - drying_time_hours=float(dry_time), - manufacturing_date=manufacturing_date, - td=td_mm, + 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/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_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_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_runtime_pending_write.py b/test/test_runtime_pending_write.py new file mode 100644 index 0000000..b390cdf --- /dev/null +++ b/test/test_runtime_pending_write.py @@ -0,0 +1,640 @@ +"""Tests for Runtime's race-safe TigerTag operation queue.""" + +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)) + +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 + + +UID = bytes.fromhex("04112233445566") +MAKER_PAYLOAD = TIGERTAG_MAKER_ID.to_bytes(4, "big") + bytes(range(TIGERTAG_OWNED_LENGTH - 4)) + + +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.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 + + +@pytest.fixture(autouse=True) +def _reset_entities(): + LOADED_MODULES.clear() + register_configurable_entity(default_configuration()) + yield + LOADED_MODULES.clear() + + +def _build_runtime_with(reader_or_readers) -> Runtime: + runtime = Runtime() + 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 _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}") + + +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 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 + + 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_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) + + result = result_box["result"] + assert result["ok"] is True + 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 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_runtime_propagates_late_safety_block_to_reader() -> None: + reader = _FakeReader() + 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, + 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 result["code"] == code + assert runtime._pending_writes == {} + + +def test_per_slot_busy_rejects_second_operation() -> None: + reader = _FakeReader() + 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) + + 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" + + runtime._drain_pending_write(0) + submitter.join(timeout=2.0) + assert result_box["result"]["ok"] is True + + +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_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_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) + + runtime.request_shutdown() + result = runtime.submit_tigertag_clear(0, UID) + + assert runtime.is_shutting_down() is True + assert result["code"] == "service_stopping" + assert reader.calls == [] + assert runtime._pending_writes == {} + + +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) + + runtime.request_shutdown() + runtime.loop() + submitter.join(timeout=1.0) + + assert not submitter.is_alive() + result = result_box["result"] + 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 5bf8e4a..db2d399 100644 --- a/test/test_tags.py +++ b/test/test_tags.py @@ -185,6 +185,19 @@ def _build_tigertag_processor() -> TagProcessor: }, } +EXPECTED_TAG_FORMATS = { + "Anycubic": "anycubic", + "Bambu": "bambu", + "Creality": "creality", + "Elegoo": "elegoo", + "OpenSpool": "openspool", + "OpenTag3D": "opentag3d", + "Qidi": "qidi", + "SpoolEase": "spoolease", + "Snapmaker": "snapmaker", + "Tigertag": "tigertag", +} + def _get_processor_fixture(folder_name: str) -> dict: if folder_name not in PROCESSOR_FIXTURES: @@ -250,3 +263,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 new file mode 100644 index 0000000..1277caa --- /dev/null +++ b/test/test_tigertag_encoder.py @@ -0,0 +1,422 @@ +"""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" + +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, + 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(name: str = "TigerTagProcessor") -> TigerTagProcessor: + processor = TigerTagProcessor({"__name": name}) + processor.enabled = True + return processor + + +def _build_scan_result(uid: bytes = bytes.fromhex("04A1B2C3D4E5F6")) -> ScanResult: + return ScanResult( + tag_type=TagType.MifareUltralight, + uid=uid, + atqa=b"\x00\x44", + bcc=b"\x00", + sak=b"\x00", + ) + + +def _wrap_user_payload(payload: bytes) -> bytes: + """Prepend pages 0-3 as returned by the FM175xx full-memory reader.""" + return b"\x00" * Constants.USER_DATA_BYTE_OFFSET + payload + + +def _minimal_pla_spec() -> dict: + return { + "material": "PLA", + "brand": "ELEGOO", + "type": "Filament", + "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!", + } + + +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_iso8601_offset_and_z_timestamps_normalize_to_the_same_instant() -> None: + offset_tag = validate_maker_payload( + encode({"manufacturing_date": "2026-04-03T02:30:00+02:30"}) + ) + utc_tag = validate_maker_payload( + encode({"manufacturing_date": "2026-04-03T00:00:00Z"}) + ) + + assert offset_tag.timestamp == utc_tag.timestamp + + +def test_message_whitespace_is_preserved_verbatim() -> None: + message = " padded " + payload = encode({"message": message}) + + assert validate_maker_payload(payload).custom_message == message + filament = _build_processor().process_tag( + _build_scan_result(), + _wrap_user_payload(payload), + ) + assert filament is not None + assert filament.message == message + + +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 + + +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_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) + + with pytest.raises(ValueError, match="only encode unsigned TigerTag Maker"): + encode({"product_id": 1234})