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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions examples/u1.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
# libgpiod 1.6.4
5 changes: 5 additions & 0 deletions src/SAFE_TIGERTAG_WRITER_V2
Original file line number Diff line number Diff line change
@@ -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.
118 changes: 93 additions & 25 deletions src/controllers/moonraker_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
from typing import Any
import os
import threading
import time

class MoonrakerController(Controller):
Expand All @@ -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:
Expand All @@ -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")
raise NotImplementedError("Subclasses must implement this method")
Loading
Loading