Files
ha-reticulum/custom_components/reticulum/reticulum_client.py
claude 027ff685b7 Multiple identities via config subentries + regenerate + editable name
Refactor from one-entry-one-identity to a hub config entry (shared RNS stack)
plus config subentries of type "identity", each an independent LXMF identity
with its own LXMRouter, address, device and entities. This is required because
LXMRouter allows only one delivery identity per instance.

- ReticulumStack: owns the single process-wide RNS instance, peer discovery and
  interface telemetry (hub device).
- IdentityManager: one per subentry, its own LXMRouter/identity/destination and
  delivery callback (identity device). Signal handlers suppressed and atexit
  handlers tamed for each router too.
- Config subentry flow (add / reconfigure identity); a first identity is seeded
  on stack creation. Editable announce display name = subentry title.
- Per-identity "Regenerate identity" button: new address + re-announce
  (clears the router's single delivery destination first).
- Services and events gain an "identity" field to target/distinguish identities.
- Per-identity assist bridge, greeting-when-assist-off, allow-list, notify,
  announce/sync buttons, message counters; hub-level connectivity/telemetry/peers.

Verified against current HA config_entries/entity_platform/selector and RNS/LXMF
sources. Docs and en/ru translations updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 20:29:05 +03:00

803 lines
27 KiB
Python

"""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