"""Reticulum / LXMF client for Home Assistant. Architecture ------------ Reticulum (``RNS``) is a process-wide singleton, so there is exactly one :class:`ReticulumStack` (the config entry / hub) that owns the RNS instance, the outbound TCP interface, peer discovery and interface telemetry. LXMF's ``LXMRouter`` supports only a single delivery identity per instance, so each LXMF identity (a config subentry) gets its own :class:`IdentityManager` with its own ``LXMRouter``, delivery destination and delivery callback. All of them share the single RNS instance. Both ``RNS.Reticulum()`` and ``LXMF.LXMRouter()`` call ``signal.signal()`` in their constructors (main-thread only) and register blocking ``atexit`` handlers, so construction happens in an executor thread with signals neutralised and the atexit handlers untamed; state is persisted off-loop on ``homeassistant_stop``. """ from __future__ import annotations import atexit import logging import os import signal import threading import time from collections import deque from contextlib import contextmanager from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Callable, Iterator from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( ATTACHMENTS_SUBDIR, CONFIG_FILENAME, DEST_HASH_LEN, IDENTITIES_SUBDIR, IDENTITY_FILENAME, identity_signal, stack_signal, ) if TYPE_CHECKING: import LXMF import RNS _LOGGER = logging.getLogger(__name__) # Process-wide singletons / caches (survive config-entry reloads). _RNS_INSTANCE: Any = None _ANNOUNCE_HANDLER: Any = None # Per-identity caches keyed by subentry_id, so a reload reuses the running # LXMRouter/identity instead of spawning duplicates. _ROUTERS: dict[str, Any] = {} _IDENTITIES: dict[str, Any] = {} _DESTINATIONS: dict[str, Any] = {} PATH_RESOLVE_TIMEOUT = 15.0 MAX_TRACKED_PEERS = 200 MAX_RECENT_MESSAGES = 25 def _noop_signal(*_args: Any, **_kwargs: Any) -> None: """Drop-in for ``signal.signal`` used while initialising off the main thread.""" return None @contextmanager def _suppressed_signal_handlers() -> Iterator[None]: """Neutralise ``signal.signal`` when not on the main thread. RNS and LXMF register SIGINT/SIGTERM handlers in their constructors, which only works on the main thread (we init in an executor). Suppressing this also keeps Home Assistant in control of its own signals. """ patched = threading.current_thread() is not threading.main_thread() saved = signal.signal if patched: signal.signal = _noop_signal # type: ignore[assignment] try: yield finally: if patched: signal.signal = saved # type: ignore[assignment] def _tame_exit_handler(obj: Any) -> None: """Unregister an RNS/LXMF ``atexit`` handler (blocking I/O / stdout detach).""" handler = getattr(obj, "exit_handler", None) if handler is None: return try: atexit.unregister(handler) except Exception: # noqa: BLE001 - best effort _LOGGER.debug("Could not unregister exit handler for %s", obj) class ReticulumError(Exception): """Recoverable Reticulum error surfaced to the UI.""" # --------------------------------------------------------------------------- # State containers # --------------------------------------------------------------------------- @dataclass(slots=True) class Peer: """An LXMF peer heard announcing.""" destination_hash: str display_name: str | None stamp_cost: int | None last_heard: float hops: int | None = None @dataclass(slots=True) class StackState: """Hub-level state (shared RNS instance).""" started: bool = False interface_online: bool = False tel_rxb: int | None = None tel_txb: int | None = None tel_bitrate: float | None = None tel_rssi: float | None = None tel_snr: float | None = None tel_quality: float | None = None peers: dict[str, Peer] = field(default_factory=dict) @dataclass(slots=True) class IdentityState: """Per-identity state.""" started: bool = False lxmf_address: str | None = None display_name: str | None = None messages_received: int = 0 messages_sent: int = 0 messages_failed: int = 0 last_message: str | None = None last_message_source: str | None = None last_message_title: str | None = None last_message_time: float | None = None recent_messages: deque = field(default_factory=lambda: deque(maxlen=MAX_RECENT_MESSAGES)) # --------------------------------------------------------------------------- # Stack (hub) — one shared RNS instance # --------------------------------------------------------------------------- class ReticulumStack: """Owns the process-wide RNS instance, peer discovery and telemetry.""" def __init__( self, hass: HomeAssistant, storage_dir: str, entry_id: str, *, target_host: str, target_port: int, interface_name: str, loglevel: int, ) -> None: self.hass = hass self.storage_dir = storage_dir self.entry_id = entry_id self.target_host = target_host self.target_port = target_port self.interface_name = interface_name self.loglevel = loglevel self.state = StackState() self._rns: Any = None async def async_start(self) -> None: await self.hass.async_add_executor_job(self._start) self._push_state() def _start(self) -> None: global _RNS_INSTANCE, _ANNOUNCE_HANDLER # noqa: PLW0603 import RNS # noqa: PLC0415 os.makedirs(self.storage_dir, exist_ok=True) os.makedirs(os.path.join(self.storage_dir, ATTACHMENTS_SUBDIR), exist_ok=True) self._write_config_file() existing = None try: existing = RNS.Reticulum.get_instance() except Exception: # noqa: BLE001 existing = _RNS_INSTANCE if existing is not None: _RNS_INSTANCE = existing else: with _suppressed_signal_handlers(): try: _RNS_INSTANCE = RNS.Reticulum( configdir=self.storage_dir, loglevel=self.loglevel ) except OSError as err: adopted = None try: adopted = RNS.Reticulum.get_instance() except Exception: # noqa: BLE001 adopted = None if adopted is None: raise _LOGGER.debug("Adopted existing Reticulum instance: %s", err) _RNS_INSTANCE = adopted self._rns = _RNS_INSTANCE _tame_exit_handler(RNS.Reticulum) # (Re)install the peer-discovery announce handler bound to this stack. if _ANNOUNCE_HANDLER is not None: try: RNS.Transport.deregister_announce_handler(_ANNOUNCE_HANDLER) except Exception: # noqa: BLE001 _LOGGER.debug("Could not deregister old announce handler", exc_info=True) _ANNOUNCE_HANDLER = _AnnounceHandler(self) RNS.Transport.register_announce_handler(_ANNOUNCE_HANDLER) self.state.started = True _LOGGER.info( "Reticulum stack ready (%s:%s via %s)", self.target_host, self.target_port, self.interface_name, ) def _write_config_file(self) -> None: config_path = os.path.join(self.storage_dir, CONFIG_FILENAME) contents = ( "[reticulum]\n" " enable_transport = False\n" " share_instance = No\n" " panic_on_interface_error = No\n\n" "[logging]\n" f" loglevel = {self.loglevel}\n\n" "[interfaces]\n" f" [[{self.interface_name}]]\n" " type = TCPClientInterface\n" " enabled = yes\n" f" target_host = {self.target_host}\n" f" target_port = {self.target_port}\n" ) with open(config_path, "w", encoding="utf-8") as handle: handle.write(contents) async def async_stop(self) -> None: self.state.started = False async def async_persist(self) -> None: if self._rns is None: return await self.hass.async_add_executor_job(self._persist) def _persist(self) -> None: import RNS # noqa: PLC0415 for label, fn in ( ("transport", getattr(RNS.Transport, "persist_data", None)), ("identity", getattr(RNS.Identity, "persist_data", None)), ): if fn is None: continue try: fn() except Exception: # noqa: BLE001 _LOGGER.debug("Failed to persist RNS %s data", label, exc_info=True) @callback def refresh_interface_status(self, now: Any = None) -> None: """Recompute interface online status + telemetry (on the event loop).""" if self._rns is None: return online = False rxb = txb = bitrate = rssi = snr = quality = None try: import RNS # noqa: PLC0415 target = None interfaces = list(RNS.Transport.interfaces) for iface in interfaces: name = getattr(iface, "name", "") or str(iface) if self.interface_name in name: target = iface break if target is None and interfaces: target = interfaces[0] if target is not None: online = bool(getattr(target, "online", False)) rxb = getattr(target, "rxb", None) txb = getattr(target, "txb", None) bitrate = getattr(target, "bitrate", None) rssi = getattr(target, "rssi", None) snr = getattr(target, "snr", None) quality = getattr(target, "q", None) except Exception: # noqa: BLE001 online = False snapshot = (online, rxb, txb, bitrate, rssi, snr, quality) current = ( self.state.interface_online, self.state.tel_rxb, self.state.tel_txb, self.state.tel_bitrate, self.state.tel_rssi, self.state.tel_snr, self.state.tel_quality, ) if snapshot == current: return ( self.state.interface_online, self.state.tel_rxb, self.state.tel_txb, self.state.tel_bitrate, self.state.tel_rssi, self.state.tel_snr, self.state.tel_quality, ) = snapshot self._push_state() # Peer tracking (announce handler runs on an RNS thread) -------------- def register_peer( self, destination_hash: str, display_name: str | None, stamp_cost: int | None, hops: int | None, ) -> None: self.hass.loop.call_soon_threadsafe( self._store_peer, destination_hash, display_name, stamp_cost, hops ) @callback def _store_peer( self, destination_hash: str, display_name: str | None, stamp_cost: int | None, hops: int | None, ) -> None: self.state.peers[destination_hash] = Peer( destination_hash=destination_hash, display_name=display_name, stamp_cost=stamp_cost, last_heard=time.time(), hops=hops, ) if len(self.state.peers) > MAX_TRACKED_PEERS: oldest = min(self.state.peers.values(), key=lambda p: p.last_heard) self.state.peers.pop(oldest.destination_hash, None) self._push_state() _fire_announce_event( self.hass, destination_hash, display_name, stamp_cost, hops ) @callback def _push_state(self) -> None: async_dispatcher_send(self.hass, stack_signal(self.entry_id)) # --------------------------------------------------------------------------- # Identity — one LXMRouter per identity # --------------------------------------------------------------------------- class IdentityManager: """Owns one LXMF identity, its router and delivery destination.""" def __init__( self, hass: HomeAssistant, stack: ReticulumStack, subentry_id: str, *, display_name: str, ) -> None: self.hass = hass self.stack = stack self.subentry_id = subentry_id self.display_name = display_name self.state = IdentityState(display_name=display_name) self._router: Any = None self._identity: Any = None self._local_destination: Any = None self.incoming_handler: Callable[[dict[str, Any]], Any] | None = None @property def _dir(self) -> str: return os.path.join( self.stack.storage_dir, IDENTITIES_SUBDIR, self.subentry_id ) async def async_start(self) -> None: await self.hass.async_add_executor_job(self._start) self._push_state() def _start(self) -> None: import LXMF # noqa: PLC0415 import RNS # noqa: PLC0415 os.makedirs(self._dir, exist_ok=True) sid = self.subentry_id if sid not in _ROUTERS: identity_path = os.path.join(self._dir, IDENTITY_FILENAME) identity = None if os.path.isfile(identity_path): identity = RNS.Identity.from_file(identity_path) if identity is None: identity = RNS.Identity() identity.to_file(identity_path) with _suppressed_signal_handlers(): router = LXMF.LXMRouter(storagepath=os.path.join(self._dir, "lxmf")) _tame_exit_handler(router) destination = router.register_delivery_identity( identity, display_name=self.display_name ) _ROUTERS[sid] = router _IDENTITIES[sid] = identity _DESTINATIONS[sid] = destination else: router = _ROUTERS[sid] identity = _IDENTITIES[sid] destination = _DESTINATIONS[sid] # Apply a possibly-changed announce display name. try: destination.display_name = self.display_name except Exception: # noqa: BLE001 pass router.register_delivery_callback(self._delivery_callback) self._router = router self._identity = identity self._local_destination = destination self.state.lxmf_address = RNS.hexrep(destination.hash, delimit=False) self.state.display_name = self.display_name self.state.started = True _LOGGER.info( "Reticulum identity '%s' ready: %s", self.display_name, self.state.lxmf_address, ) async def async_stop(self) -> None: self.incoming_handler = None if self._router is not None: try: self._router.register_delivery_callback(lambda _msg: None) except Exception: # noqa: BLE001 _LOGGER.debug("Could not reset delivery callback", exc_info=True) self.state.started = False # Outbound ---------------------------------------------------------- async def async_send_message( self, destination: str, content: str, title: str = "", method: str = "direct", fields: dict | None = None, ) -> str: dest_hash = _normalise_hash(destination) message_hash = await self.hass.async_add_executor_job( self._send, dest_hash, content, title, method, fields ) self.state.messages_sent += 1 self._push_state() return message_hash def _send( self, dest_hash: bytes, content: str, title: str, method: str, fields: dict | None, ) -> str: import LXMF # noqa: PLC0415 import RNS # noqa: PLC0415 recipient_identity = self._resolve_identity(dest_hash) if recipient_identity is None: raise ReticulumError( f"Could not resolve a path/identity for " f"{RNS.prettyhexrep(dest_hash)} within {PATH_RESOLVE_TIMEOUT:.0f}s" ) dest = RNS.Destination( recipient_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery", ) desired = { "direct": LXMF.LXMessage.DIRECT, "opportunistic": LXMF.LXMessage.OPPORTUNISTIC, "propagated": LXMF.LXMessage.PROPAGATED, }.get(method, LXMF.LXMessage.DIRECT) lxm = LXMF.LXMessage( dest, self._local_destination, content, title, desired_method=desired ) if fields: lxm.fields = fields lxm.register_delivery_callback(self._outbound_delivered) lxm.register_failed_callback(self._outbound_failed) self._router.handle_outbound(lxm) return RNS.hexrep(lxm.hash, delimit=False) def _resolve_identity(self, dest_hash: bytes) -> Any: import RNS # noqa: PLC0415 identity = RNS.Identity.recall(dest_hash) if identity is not None: return identity if not RNS.Transport.has_path(dest_hash): RNS.Transport.request_path(dest_hash) deadline = time.monotonic() + PATH_RESOLVE_TIMEOUT while time.monotonic() < deadline: identity = RNS.Identity.recall(dest_hash) if identity is not None: return identity time.sleep(0.25) return RNS.Identity.recall(dest_hash) async def async_announce(self) -> None: if self._router is None or self._local_destination is None: raise ReticulumError("Identity is not started") await self.hass.async_add_executor_job( self._router.announce, self._local_destination.hash ) async def async_request_path(self, destination: str) -> None: dest_hash = _normalise_hash(destination) def _request() -> None: import RNS # noqa: PLC0415 RNS.Transport.request_path(dest_hash) await self.hass.async_add_executor_job(_request) async def async_set_propagation_node(self, destination: str) -> None: dest_hash = _normalise_hash(destination) await self.hass.async_add_executor_job( self._router.set_outbound_propagation_node, dest_hash ) async def async_sync_propagation(self, max_messages: int | None = None) -> None: def _sync() -> None: if max_messages is None: self._router.request_messages_from_propagation_node(self._identity) else: self._router.request_messages_from_propagation_node( self._identity, max_messages ) await self.hass.async_add_executor_job(_sync) async def async_regenerate_identity(self) -> str: """Create a brand-new identity/address for this manager.""" address = await self.hass.async_add_executor_job(self._regenerate) self._push_state() return address def _regenerate(self) -> str: import RNS # noqa: PLC0415 os.makedirs(self._dir, exist_ok=True) new_identity = RNS.Identity() new_identity.to_file(os.path.join(self._dir, IDENTITY_FILENAME)) router = self._router # LXMRouter allows only one delivery identity; clear the old one first. try: router.delivery_destinations.clear() except Exception: # noqa: BLE001 _LOGGER.debug("Could not clear old delivery destination", exc_info=True) destination = router.register_delivery_identity( new_identity, display_name=self.display_name ) _IDENTITIES[self.subentry_id] = new_identity _DESTINATIONS[self.subentry_id] = destination self._identity = new_identity self._local_destination = destination self.state.lxmf_address = RNS.hexrep(destination.hash, delimit=False) try: router.announce(destination.hash) except Exception: # noqa: BLE001 _LOGGER.debug("Announce after regenerate failed", exc_info=True) _LOGGER.info( "Regenerated identity '%s': new address %s", self.display_name, self.state.lxmf_address, ) return self.state.lxmf_address # Inbound ----------------------------------------------------------- def _delivery_callback(self, message: Any) -> None: """LXMF delivery callback (runs on an RNS thread).""" import RNS # noqa: PLC0415 try: payload: dict[str, Any] = { "content": message.content_as_string() or "", "title": message.title_as_string() or "", "source": RNS.hexrep(message.source_hash, delimit=False), "destination": RNS.hexrep(message.destination_hash, delimit=False), "timestamp": getattr(message, "timestamp", None), "signature_validated": getattr(message, "signature_validated", None), "fields": dict(getattr(message, "fields", {}) or {}), "identity": self.subentry_id, "identity_name": self.display_name, "local_address": self.state.lxmf_address, } except Exception: # noqa: BLE001 _LOGGER.exception("Failed to parse inbound LXMF message") return self.hass.loop.call_soon_threadsafe(self._dispatch_incoming, payload) @callback def _dispatch_incoming(self, payload: dict[str, Any]) -> None: self.state.messages_received += 1 self.state.last_message = payload["content"] self.state.last_message_source = payload["source"] self.state.last_message_title = payload["title"] self.state.last_message_time = payload.get("timestamp") or time.time() self.state.recent_messages.appendleft( { "source": payload["source"], "title": payload["title"], "content": payload["content"], "time": self.state.last_message_time, } ) self._push_state() if self.incoming_handler is not None: self.hass.async_create_task( _maybe_await(self.incoming_handler, payload), name="reticulum_incoming", ) # Outbound delivery status ----------------------------------------- def _outbound_delivered(self, message: Any) -> None: self.hass.loop.call_soon_threadsafe(self._on_outbound_delivered, message) def _outbound_failed(self, message: Any) -> None: self.hass.loop.call_soon_threadsafe(self._on_outbound_failed, message) @callback def _on_outbound_delivered(self, message: Any) -> None: import RNS # noqa: PLC0415 _fire_message_event( self.hass, "delivered", self, { "message_hash": RNS.hexrep(message.hash, delimit=False), "destination": RNS.hexrep(message.destination_hash, delimit=False), }, ) @callback def _on_outbound_failed(self, message: Any) -> None: import RNS # noqa: PLC0415 self.state.messages_failed += 1 self._push_state() _fire_message_event( self.hass, "failed", self, { "message_hash": RNS.hexrep(message.hash, delimit=False), "destination": RNS.hexrep(message.destination_hash, delimit=False), }, ) @callback def _push_state(self) -> None: async_dispatcher_send(self.hass, identity_signal(self.subentry_id)) # --------------------------------------------------------------------------- # Announce handler (peer discovery) # --------------------------------------------------------------------------- class _AnnounceHandler: """RNS announce handler filtered to the LXMF delivery aspect.""" aspect_filter = "lxmf.delivery" def __init__(self, stack: ReticulumStack) -> None: self._stack = stack self.receive_path_responses = False def received_announce( self, destination_hash: bytes, announced_identity: Any, app_data: Any, ) -> None: # RNS dispatches by exact parameter count with keyword args; the # 3-parameter form is supported by every RNS version. import RNS # noqa: PLC0415 display_name: str | None = None stamp_cost: int | None = None try: import LXMF # noqa: PLC0415 if app_data is not None: display_name = LXMF.display_name_from_app_data(app_data) stamp_cost = LXMF.stamp_cost_from_app_data(app_data) except Exception: # noqa: BLE001 if isinstance(app_data, (bytes, bytearray)): try: display_name = app_data.decode("utf-8", errors="replace") except Exception: # noqa: BLE001 display_name = None hops = None try: hops = RNS.Transport.hops_to(destination_hash) except Exception: # noqa: BLE001 hops = None self._stack.register_peer( RNS.hexrep(destination_hash, delimit=False), display_name, stamp_cost, hops, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _normalise_hash(value: str) -> bytes: cleaned = value.strip().lower().replace(":", "").replace(" ", "") if cleaned.startswith("0x"): cleaned = cleaned[2:] if len(cleaned) != DEST_HASH_LEN: raise ReticulumError( f"Invalid Reticulum address '{value}': expected {DEST_HASH_LEN} hex " f"characters (a 16-byte destination hash)" ) try: return bytes.fromhex(cleaned) except ValueError as err: raise ReticulumError(f"Invalid Reticulum address '{value}'") from err def _fire_message_event( hass: HomeAssistant, kind: str, manager: IdentityManager, data: dict[str, Any] ) -> None: from .const import EVENT_MESSAGE_DELIVERED, EVENT_MESSAGE_FAILED # noqa: PLC0415 event = {"delivered": EVENT_MESSAGE_DELIVERED, "failed": EVENT_MESSAGE_FAILED}[kind] hass.bus.async_fire( event, { **data, "identity": manager.subentry_id, "identity_name": manager.display_name, "local_address": manager.state.lxmf_address, }, ) def _fire_announce_event( hass: HomeAssistant, destination_hash: str, display_name: str | None, stamp_cost: int | None, hops: int | None, ) -> None: from .const import EVENT_ANNOUNCE_RECEIVED # noqa: PLC0415 hass.bus.async_fire( EVENT_ANNOUNCE_RECEIVED, { "destination": destination_hash, "display_name": display_name, "stamp_cost": stamp_cost, "hops": hops, }, ) async def _maybe_await( func: Callable[[dict[str, Any]], Any], payload: dict[str, Any] ) -> None: import asyncio # noqa: PLC0415 result = func(payload) if asyncio.iscoroutine(result): await result