Files
ha-reticulum/custom_components/reticulum/reticulum_client.py
claude a80f65c442 Embed RNS cleanly: fix signal-thread crash, tame atexit/stdout, persist on stop
Root cause of setup failure: Reticulum.__init__ calls signal.signal() with no
main-thread guard, but we (correctly) initialise off the event loop in an
executor thread, where signal.signal() raises ValueError. It failed after
setting the __instance singleton, which both broke setup and caused the
"Attempt to reinitialise Reticulum" error on every retry.

- Temporarily neutralise signal.signal during RNS init so init completes in the
  executor, and so RNS does not hijack HA's SIGINT/SIGTERM (needed for clean
  shutdown under Kubernetes).
- Unregister RNS's and LXMF's atexit exit handlers, which otherwise persist
  state with blocking file I/O on the event-loop thread and detach HA's
  stdout/stderr at interpreter exit (the loop-blocking warnings seen in logs).
- Persist RNS/LXMF state ourselves off-loop on the homeassistant_stop event.

Documents the remaining non-daemon RNS worker threads as a known shutdown note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:46:06 +03:00

756 lines
28 KiB
Python

"""Reticulum / LXMF client manager for Home Assistant.
This module isolates all interaction with the (blocking, thread-based)
Reticulum Network Stack (``RNS``) and the LXMF messaging layer, and bridges
their callbacks (which fire on RNS-owned threads) back onto the Home Assistant
event loop.
The neighbouring machine runs a full Reticulum instance exposing a
``TCPServerInterface``. We connect out to it with a ``TCPClientInterface`` and
run a standalone (non-shared) RNS instance inside the Home Assistant process.
"""
from __future__ import annotations
import asyncio
import atexit
import logging
import os
import signal
import threading
import time
from collections import deque
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Callable
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import (
ATTACHMENTS_SUBDIR,
CONFIG_FILENAME,
DEST_HASH_LEN,
IDENTITY_FILENAME,
SIGNAL_STATE_UPDATED,
)
if TYPE_CHECKING:
import LXMF
import RNS
_LOGGER = logging.getLogger(__name__)
# A process may only ever hold a single RNS.Reticulum instance, and RNS/LXMF
# cannot be cleanly torn down inside a running process. Home Assistant is a
# single process, so we cache the running stack objects module-side and reuse
# them across config-entry reloads instead of rebuilding (which would raise
# "Attempt to reinitialise Reticulum" and/or leak duplicate LXMF routers).
_RNS_INSTANCE: Any = None
_LXM_ROUTER: Any = None
_LOCAL_DESTINATION: Any = None
_IDENTITY: Any = None
_ANNOUNCE_HANDLER: Any = None
# How long (seconds) to wait for a path/identity to resolve before giving up on
# an outbound message.
PATH_RESOLVE_TIMEOUT = 15.0
# Ring buffer size for recently seen peers / messages surfaced as attributes.
MAX_TRACKED_PEERS = 200
MAX_RECENT_MESSAGES = 25
@dataclass(slots=True)
class Peer:
"""A Reticulum LXMF peer we have heard announce."""
destination_hash: str
display_name: str | None
stamp_cost: int | None
last_heard: float
hops: int | None = None
@dataclass(slots=True)
class ManagerState:
"""Mutable state surfaced to entities and diagnostics."""
started: bool = False
interface_online: bool = False
lxmf_address: str | None = None
display_name: str | None = None
messages_received: int = 0
messages_sent: int = 0
messages_failed: int = 0
# Interface telemetry (populated by refresh_interface_status).
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
last_message: str | None = None
last_message_source: str | None = None
last_message_title: str | None = None
last_message_time: float | None = None
peers: dict[str, Peer] = field(default_factory=dict)
recent_messages: deque = field(default_factory=lambda: deque(maxlen=MAX_RECENT_MESSAGES))
class ReticulumError(Exception):
"""Raised for recoverable Reticulum operations (surfaced to the UI)."""
class ReticulumManager:
"""Owns the RNS instance, the LXMF router and all bridging logic."""
def __init__(
self,
hass: HomeAssistant,
storage_dir: str,
*,
target_host: str,
target_port: int,
interface_name: str,
display_name: str,
loglevel: int,
) -> None:
self.hass = hass
self.storage_dir = storage_dir
self.target_host = target_host
self.target_port = target_port
self.interface_name = interface_name
self.display_name = display_name
self.loglevel = loglevel
self.state = ManagerState(display_name=display_name)
# Populated in _start (executor).
self._rns: Any = None
self._router: Any = None
self._identity: Any = None
self._local_destination: Any = None
self._announce_handler: Any = None
# Incoming-message handler installed by __init__.py once options are
# known (assist bridge, allow-list, …).
self.incoming_handler: Callable[[dict[str, Any]], Any] | None = None
self._lock = asyncio.Lock()
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def async_start(self) -> None:
"""Initialise RNS + LXMF (runs the blocking parts in an executor)."""
async with self._lock:
await self.hass.async_add_executor_job(self._start)
self._push_state()
def _start(self) -> None:
"""Blocking init. Runs in the executor thread."""
global _RNS_INSTANCE, _LXM_ROUTER, _LOCAL_DESTINATION # noqa: PLW0603
global _IDENTITY, _ANNOUNCE_HANDLER # noqa: PLW0603
import LXMF # noqa: PLC0415
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()
# RNS is a hard process-wide singleton: calling RNS.Reticulum() twice
# raises "Attempt to reinitialise Reticulum, when it was already
# running". Our module-level cache can get out of sync with RNS's own
# internal singleton (e.g. the integration module is re-imported after
# an update, or a partial-setup retry), so treat RNS itself as the
# source of truth via get_instance().
existing = None
try:
existing = RNS.Reticulum.get_instance()
except Exception: # noqa: BLE001 - older RNS may lack get_instance
existing = _RNS_INSTANCE
if existing is not None:
_LOGGER.debug("Reusing already-running Reticulum instance")
_RNS_INSTANCE = existing
else:
_LOGGER.debug("Initialising Reticulum instance at %s", self.storage_dir)
_RNS_INSTANCE = self._create_rns_instance(RNS)
self._rns = _RNS_INSTANCE
self._tame_exit_handler(RNS.Reticulum)
# Stable identity so our LXMF address survives restarts.
if _IDENTITY is None:
identity_path = os.path.join(self.storage_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)
_IDENTITY = identity
self._identity = _IDENTITY
# Reuse the LXMF router + delivery destination across reloads; only the
# delivery callback (which is bound to this manager) is (re)registered.
if _LXM_ROUTER is None:
_LXM_ROUTER = LXMF.LXMRouter(
storagepath=os.path.join(self.storage_dir, "lxmf"),
)
_LOCAL_DESTINATION = _LXM_ROUTER.register_delivery_identity(
self._identity, display_name=self.display_name
)
self._router = _LXM_ROUTER
self._local_destination = _LOCAL_DESTINATION
self._router.register_delivery_callback(self._delivery_callback)
self._tame_exit_handler(self._router)
# Discover peers via their LXMF delivery announces. Deregister any
# previous handler (bound to a stale manager) before installing ours.
if _ANNOUNCE_HANDLER is not None:
try:
RNS.Transport.deregister_announce_handler(_ANNOUNCE_HANDLER)
except Exception: # noqa: BLE001 - best effort / older RNS
_LOGGER.debug("Could not deregister old announce handler", exc_info=True)
self._announce_handler = _AnnounceHandler(self)
_ANNOUNCE_HANDLER = self._announce_handler
RNS.Transport.register_announce_handler(self._announce_handler)
self.state.lxmf_address = RNS.hexrep(
self._local_destination.hash, delimit=False
)
self.state.started = True
_LOGGER.info(
"Reticulum ready. LXMF address: %s (%s)",
self.state.lxmf_address,
self.display_name,
)
def _write_config_file(self) -> None:
"""Write a minimal RNS config with only our TCP client interface."""
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)
def _create_rns_instance(self, rns: Any) -> Any:
"""Create the RNS instance from an executor thread.
``Reticulum.__init__`` calls ``signal.signal()`` with no main-thread
guard, but signal handlers can only be installed from the main thread.
Since we (correctly) initialise off the event loop, we temporarily
neutralise ``signal.signal`` so init completes — and as a bonus this
stops RNS from hijacking Home Assistant's own SIGINT/SIGTERM handling,
which HA needs for clean shutdown (important under Kubernetes).
"""
saved_signal = signal.signal
if threading.current_thread() is not threading.main_thread():
signal.signal = lambda *args, **kwargs: None # type: ignore[assignment]
try:
return rns.Reticulum(
configdir=self.storage_dir, loglevel=self.loglevel
)
except OSError as err:
# Divergent state / race: adopt whatever is already running.
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 after %s", err)
return adopted
finally:
signal.signal = saved_signal # type: ignore[assignment]
@staticmethod
def _tame_exit_handler(obj: Any) -> None:
"""Unregister an RNS/LXMF ``atexit`` exit handler.
RNS and LXMF register ``atexit`` handlers that persist state with
blocking file I/O on the main (event-loop) thread and detach
stdout/stderr — both of which trip HA's loop-protection and can suppress
HA's own shutdown logging. We persist state ourselves off-loop at HA
stop instead (see :meth:`async_persist`).
"""
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)
async def async_persist(self) -> None:
"""Persist RNS/LXMF state off the event loop (call at HA stop)."""
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 - best effort
_LOGGER.debug("Failed to persist RNS %s data", label, exc_info=True)
async def async_stop(self) -> None:
"""Detach our callbacks. The RNS instance itself lives for the process.
RNS/LXMF do not support a clean per-entry teardown inside a running
process, so a full stack restart requires restarting Home Assistant.
We at least stop delivering into a torn-down entry.
"""
self.incoming_handler = None
if self._router is not None:
try:
self._router.register_delivery_callback(lambda _msg: None)
except Exception: # noqa: BLE001 - best effort
_LOGGER.debug("Could not reset delivery callback", exc_info=True)
if self._announce_handler is not None:
try:
import RNS # noqa: PLC0415
RNS.Transport.deregister_announce_handler(self._announce_handler)
except Exception: # noqa: BLE001 - best effort / older RNS
_LOGGER.debug("Could not deregister announce handler", 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:
"""Send an LXMF message. Returns the message hash (hex)."""
dest_hash = self._normalise_hash(destination)
return await self.hass.async_add_executor_job(
self._send, dest_hash, content, title, method, fields
)
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)
_LOGGER.debug(
"Queued LXMF message to %s via %s", RNS.prettyhexrep(dest_hash), method
)
return RNS.hexrep(lxm.hash, delimit=False)
def _resolve_identity(self, dest_hash: bytes) -> Any:
"""Recall (and if needed, request a path for) a destination identity."""
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:
"""Announce our LXMF delivery destination on the network."""
if self._router is None or self._local_destination is None:
raise ReticulumError("Reticulum is not started")
await self.hass.async_add_executor_job(
self._router.announce, self._local_destination.hash
)
_LOGGER.debug("Announced LXMF destination %s", self.state.lxmf_address)
async def async_request_path(self, destination: str) -> None:
"""Request a network path to a destination."""
dest_hash = self._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:
"""Set the outbound LXMF propagation (store-and-forward) node."""
dest_hash = self._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:
"""Pull any queued messages from the configured propagation node."""
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)
# ------------------------------------------------------------------
# Inbound (RNS thread -> HA loop bridging)
# ------------------------------------------------------------------
def _delivery_callback(self, message: Any) -> None:
"""LXMF delivery callback. Runs on an RNS-owned thread."""
import RNS # noqa: PLC0415
try:
source_hash = RNS.hexrep(message.source_hash, delimit=False)
payload: dict[str, Any] = {
"content": message.content_as_string() or "",
"title": message.title_as_string() or "",
"source": source_hash,
"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 {}),
}
except Exception: # noqa: BLE001 - never let a bad message kill the thread
_LOGGER.exception("Failed to parse inbound LXMF message")
return
# Hop back onto the event loop thread.
self.hass.loop.call_soon_threadsafe(self._dispatch_incoming, payload)
@callback
def _dispatch_incoming(self, payload: dict[str, Any]) -> None:
"""Runs on the event loop: update state, hand off to handler."""
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 (RNS thread)
# ------------------------------------------------------------------
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
self.state.messages_sent += 1
self._push_state()
_fire_event(
self.hass,
"delivered",
{
"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_event(
self.hass,
"failed",
{
"message_hash": RNS.hexrep(message.hash, delimit=False),
"destination": RNS.hexrep(message.destination_hash, delimit=False),
},
)
# ------------------------------------------------------------------
# Peer tracking (called from announce handler on 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:
peer = Peer(
destination_hash=destination_hash,
display_name=display_name,
stamp_cost=stamp_cost,
last_heard=time.time(),
hops=hops,
)
self.state.peers[destination_hash] = peer
# Keep the peer table bounded.
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_event(
self.hass,
"announce",
{
"destination": destination_hash,
"display_name": display_name,
"stamp_cost": stamp_cost,
"hops": hops,
},
)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def refresh_interface_status(self) -> None:
"""Recompute interface online status and collect telemetry.
Called on the event loop from a periodic timer. RX/TX byte counters and
bitrate are available for the TCP client interface; RSSI/SNR/quality are
only populated when the underlying interface is a physical one (e.g. an
RNode/LoRa interface) and stay ``None`` for a plain TCP link.
"""
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()
@callback
def _push_state(self) -> None:
async_dispatcher_send(self.hass, SIGNAL_STATE_UPDATED)
@staticmethod
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} "
f"hex characters (a 16-byte destination hash)"
)
try:
return bytes.fromhex(cleaned)
except ValueError as err:
raise ReticulumError(f"Invalid Reticulum address '{value}'") from err
class _AnnounceHandler:
"""RNS announce handler filtered to the LXMF delivery aspect."""
aspect_filter = "lxmf.delivery"
def __init__(self, manager: ReticulumManager) -> None:
self._manager = manager
# LXMF requests receipt of the full announce data path.
self.receive_path_responses = False
def received_announce(
self,
destination_hash: bytes,
announced_identity: Any,
app_data: Any,
) -> None:
# NOTE: RNS dispatches this callback by *exact* parameter count and
# passes arguments by keyword. The 3-parameter form (destination_hash,
# announced_identity, app_data) is supported by every RNS version; the
# optional announce_packet_hash / is_path_response are unused here.
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 - app_data helpers vary by version
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._manager._register_peer(
RNS.hexrep(destination_hash, delimit=False),
display_name,
stamp_cost,
hops,
)
def _fire_event(hass: HomeAssistant, kind: str, data: dict[str, Any]) -> None:
"""Fire one of the reticulum_* bus events (imported lazily to avoid cycle)."""
from .const import ( # noqa: PLC0415
EVENT_ANNOUNCE_RECEIVED,
EVENT_MESSAGE_DELIVERED,
EVENT_MESSAGE_FAILED,
)
mapping = {
"delivered": EVENT_MESSAGE_DELIVERED,
"failed": EVENT_MESSAGE_FAILED,
"announce": EVENT_ANNOUNCE_RECEIVED,
}
hass.bus.async_fire(mapping[kind], data)
async def _maybe_await(func: Callable[[dict[str, Any]], Any], payload: dict[str, Any]) -> None:
"""Call ``func`` supporting both coroutine and plain callables."""
result = func(payload)
if asyncio.iscoroutine(result):
await result