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>
466 lines
15 KiB
Python
466 lines
15 KiB
Python
"""The Reticulum integration.
|
|
|
|
One config entry = the shared Reticulum stack (RNS + TCP interface).
|
|
Each config subentry (type ``identity``) = an independent LXMF identity.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import timedelta
|
|
from typing import Any
|
|
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
|
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
|
|
from homeassistant.core import (
|
|
HomeAssistant,
|
|
ServiceCall,
|
|
ServiceResponse,
|
|
SupportsResponse,
|
|
)
|
|
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
|
from homeassistant.helpers import config_validation as cv
|
|
from homeassistant.helpers.event import async_track_time_interval
|
|
|
|
from .const import (
|
|
ATTR_CONTENT,
|
|
ATTR_DESTINATION,
|
|
ATTR_FIELDS,
|
|
ATTR_IDENTITY,
|
|
ATTR_MAX_MESSAGES,
|
|
ATTR_METHOD,
|
|
ATTR_TITLE,
|
|
CONF_ALLOW_ALL,
|
|
CONF_ALLOWED_IDENTITIES,
|
|
CONF_ANNOUNCE_INTERVAL,
|
|
CONF_ASSIST_AGENT,
|
|
CONF_ASSIST_LANGUAGE,
|
|
CONF_DISPLAY_NAME,
|
|
CONF_ENABLE_ASSIST,
|
|
CONF_GREETING,
|
|
CONF_INTERFACE_NAME,
|
|
CONF_LOGLEVEL,
|
|
CONF_PROPAGATION_NODE,
|
|
CONF_SYNC_INTERVAL,
|
|
CONF_TARGET_HOST,
|
|
CONF_TARGET_PORT,
|
|
DEFAULT_ANNOUNCE_INTERVAL,
|
|
DEFAULT_DISPLAY_NAME,
|
|
DEFAULT_INTERFACE_NAME,
|
|
DEFAULT_LOGLEVEL,
|
|
DEFAULT_SYNC_INTERVAL,
|
|
DELIVERY_METHODS,
|
|
DOMAIN,
|
|
EVENT_MESSAGE_RECEIVED,
|
|
SERVICE_ANNOUNCE,
|
|
SERVICE_REQUEST_PATH,
|
|
SERVICE_SEND_MESSAGE,
|
|
SERVICE_SET_PROPAGATION_NODE,
|
|
SERVICE_SYNC_PROPAGATION,
|
|
STORAGE_SUBDIR,
|
|
SUBENTRY_TYPE_IDENTITY,
|
|
)
|
|
from .reticulum_client import (
|
|
IdentityManager,
|
|
ReticulumError,
|
|
ReticulumStack,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
PLATFORMS: list[Platform] = [
|
|
Platform.BINARY_SENSOR,
|
|
Platform.BUTTON,
|
|
Platform.NOTIFY,
|
|
Platform.SENSOR,
|
|
]
|
|
|
|
INTERFACE_POLL = timedelta(seconds=15)
|
|
|
|
|
|
class RuntimeData:
|
|
"""Runtime objects for the hub entry."""
|
|
|
|
def __init__(self, stack: ReticulumStack) -> None:
|
|
self.stack = stack
|
|
self.identities: dict[str, IdentityManager] = {}
|
|
self.unsubs: list[Any] = []
|
|
# f"{subentry_id}:{source}" -> HA conversation_id
|
|
self.conversation_ids: dict[str, str] = {}
|
|
|
|
|
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
"""Set up the Reticulum stack and its identities."""
|
|
storage_dir = hass.config.path(STORAGE_SUBDIR)
|
|
stack = ReticulumStack(
|
|
hass,
|
|
storage_dir,
|
|
entry.entry_id,
|
|
target_host=entry.data[CONF_TARGET_HOST],
|
|
target_port=entry.data[CONF_TARGET_PORT],
|
|
interface_name=entry.data.get(CONF_INTERFACE_NAME, DEFAULT_INTERFACE_NAME),
|
|
loglevel=int(entry.options.get(CONF_LOGLEVEL, DEFAULT_LOGLEVEL)),
|
|
)
|
|
try:
|
|
await stack.async_start()
|
|
except Exception as err: # noqa: BLE001
|
|
raise ConfigEntryNotReady(f"Could not start Reticulum: {err}") from err
|
|
|
|
runtime = RuntimeData(stack)
|
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime
|
|
|
|
# Start each identity subentry.
|
|
for subentry in entry.subentries.values():
|
|
if subentry.subentry_type != SUBENTRY_TYPE_IDENTITY:
|
|
continue
|
|
display_name = subentry.data.get(CONF_DISPLAY_NAME) or subentry.title
|
|
manager = IdentityManager(
|
|
hass, stack, subentry.subentry_id, display_name=display_name
|
|
)
|
|
try:
|
|
await manager.async_start()
|
|
except Exception as err: # noqa: BLE001
|
|
raise ConfigEntryNotReady(
|
|
f"Could not start identity '{display_name}': {err}"
|
|
) from err
|
|
manager.incoming_handler = _make_incoming_handler(
|
|
hass, entry, subentry, runtime, manager
|
|
)
|
|
runtime.identities[subentry.subentry_id] = manager
|
|
|
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
|
|
|
# Per-identity announce + periodic schedules.
|
|
for subentry in entry.subentries.values():
|
|
if subentry.subentry_type != SUBENTRY_TYPE_IDENTITY:
|
|
continue
|
|
manager = runtime.identities.get(subentry.subentry_id)
|
|
if manager is None:
|
|
continue
|
|
await _safe_announce(manager)
|
|
_schedule_identity(hass, runtime, manager, subentry)
|
|
|
|
# Hub interface-status poll.
|
|
runtime.unsubs.append(
|
|
async_track_time_interval(
|
|
hass, stack.refresh_interface_status, INTERFACE_POLL
|
|
)
|
|
)
|
|
|
|
async def _on_ha_stop(_event) -> None:
|
|
await stack.async_persist()
|
|
|
|
runtime.unsubs.append(
|
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_ha_stop)
|
|
)
|
|
entry.async_on_unload(entry.add_update_listener(_async_entry_updated))
|
|
_async_register_services(hass)
|
|
return True
|
|
|
|
|
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
"""Unload the hub entry."""
|
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
|
runtime: RuntimeData | None = hass.data.get(DOMAIN, {}).get(entry.entry_id)
|
|
if runtime is not None:
|
|
for unsub in runtime.unsubs:
|
|
unsub()
|
|
for manager in runtime.identities.values():
|
|
await manager.async_stop()
|
|
await runtime.stack.async_stop()
|
|
if unload_ok:
|
|
hass.data[DOMAIN].pop(entry.entry_id, None)
|
|
if not hass.data[DOMAIN]:
|
|
_async_unregister_services(hass)
|
|
return unload_ok
|
|
|
|
|
|
async def _async_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
"""Reload when options or subentries change."""
|
|
await hass.config_entries.async_reload(entry.entry_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Periodic scheduling
|
|
# ---------------------------------------------------------------------------
|
|
def _schedule_identity(
|
|
hass: HomeAssistant,
|
|
runtime: RuntimeData,
|
|
manager: IdentityManager,
|
|
subentry: ConfigSubentry,
|
|
) -> None:
|
|
data = subentry.data
|
|
announce_interval = data.get(CONF_ANNOUNCE_INTERVAL, DEFAULT_ANNOUNCE_INTERVAL)
|
|
if announce_interval and announce_interval > 0:
|
|
|
|
async def _do_announce(_now, _mgr=manager) -> None:
|
|
await _safe_announce(_mgr)
|
|
|
|
runtime.unsubs.append(
|
|
async_track_time_interval(
|
|
hass, _do_announce, timedelta(seconds=announce_interval)
|
|
)
|
|
)
|
|
|
|
propagation_node = data.get(CONF_PROPAGATION_NODE)
|
|
if propagation_node:
|
|
hass.async_create_task(_set_propagation(manager, propagation_node))
|
|
sync_interval = data.get(CONF_SYNC_INTERVAL, DEFAULT_SYNC_INTERVAL)
|
|
if sync_interval and sync_interval > 0:
|
|
|
|
async def _do_sync(_now, _mgr=manager) -> None:
|
|
try:
|
|
await _mgr.async_sync_propagation()
|
|
except ReticulumError as err:
|
|
_LOGGER.warning("Propagation sync failed: %s", err)
|
|
|
|
runtime.unsubs.append(
|
|
async_track_time_interval(
|
|
hass, _do_sync, timedelta(seconds=sync_interval)
|
|
)
|
|
)
|
|
|
|
|
|
async def _set_propagation(manager: IdentityManager, node: str) -> None:
|
|
try:
|
|
await manager.async_set_propagation_node(node)
|
|
except ReticulumError as err:
|
|
_LOGGER.warning("Could not set propagation node: %s", err)
|
|
|
|
|
|
async def _safe_announce(manager: IdentityManager) -> None:
|
|
try:
|
|
await manager.async_announce()
|
|
except ReticulumError as err:
|
|
_LOGGER.debug("Announce skipped: %s", err)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Incoming message pipeline (per identity)
|
|
# ---------------------------------------------------------------------------
|
|
def _make_incoming_handler(
|
|
hass: HomeAssistant,
|
|
entry: ConfigEntry,
|
|
subentry: ConfigSubentry,
|
|
runtime: RuntimeData,
|
|
manager: IdentityManager,
|
|
):
|
|
async def _handle(payload: dict[str, Any]) -> None:
|
|
source = payload["source"]
|
|
hass.bus.async_fire(EVENT_MESSAGE_RECEIVED, payload)
|
|
|
|
data = subentry.data
|
|
allow_all = data.get(CONF_ALLOW_ALL, True)
|
|
allowed = {
|
|
a.strip().lower()
|
|
for a in data.get(CONF_ALLOWED_IDENTITIES, [])
|
|
if a and a.strip()
|
|
}
|
|
if not allow_all and source.lower() not in allowed:
|
|
_LOGGER.debug("Ignoring message from non-allowed sender %s", source)
|
|
return
|
|
|
|
# When not routing to Assist, optionally auto-reply with the greeting.
|
|
if not data.get(CONF_ENABLE_ASSIST, True):
|
|
greeting = (data.get(CONF_GREETING) or "").strip()
|
|
if greeting:
|
|
await _reply(manager, source, greeting, "Home Assistant")
|
|
return
|
|
|
|
text = (payload.get("content") or "").strip()
|
|
if not text:
|
|
return
|
|
await _run_assist(hass, subentry, runtime, manager, source, text)
|
|
|
|
return _handle
|
|
|
|
|
|
async def _run_assist(
|
|
hass: HomeAssistant,
|
|
subentry: ConfigSubentry,
|
|
runtime: RuntimeData,
|
|
manager: IdentityManager,
|
|
source: str,
|
|
text: str,
|
|
) -> None:
|
|
from homeassistant.components import conversation # noqa: PLC0415
|
|
from homeassistant.core import Context # noqa: PLC0415
|
|
|
|
data = subentry.data
|
|
agent_id = data.get(CONF_ASSIST_AGENT) or None
|
|
language = data.get(CONF_ASSIST_LANGUAGE) or hass.config.language
|
|
conv_key = f"{subentry.subentry_id}:{source}"
|
|
|
|
try:
|
|
result = await conversation.async_converse(
|
|
hass=hass,
|
|
text=text,
|
|
conversation_id=runtime.conversation_ids.get(conv_key),
|
|
context=Context(),
|
|
language=language,
|
|
agent_id=agent_id,
|
|
)
|
|
except Exception as err: # noqa: BLE001
|
|
_LOGGER.exception("Assist conversation failed")
|
|
await _reply(manager, source, f"⚠️ Assist error: {err}", "Error")
|
|
return
|
|
|
|
conv_id = getattr(result, "conversation_id", None)
|
|
if conv_id:
|
|
runtime.conversation_ids[conv_key] = conv_id
|
|
|
|
reply = _extract_speech(result)
|
|
if reply:
|
|
await _reply(manager, source, reply, "Assist")
|
|
|
|
|
|
def _extract_speech(result: Any) -> str | None:
|
|
try:
|
|
speech = result.response.speech
|
|
if isinstance(speech, dict):
|
|
return speech.get("plain", {}).get("speech")
|
|
except AttributeError:
|
|
return None
|
|
return None
|
|
|
|
|
|
async def _reply(
|
|
manager: IdentityManager, destination: str, content: str, title: str
|
|
) -> None:
|
|
try:
|
|
await manager.async_send_message(destination, content, title=title)
|
|
except ReticulumError as err:
|
|
_LOGGER.warning("Could not send reply to %s: %s", destination, err)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Services
|
|
# ---------------------------------------------------------------------------
|
|
SEND_MESSAGE_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required(ATTR_DESTINATION): cv.string,
|
|
vol.Required(ATTR_CONTENT): cv.string,
|
|
vol.Optional(ATTR_TITLE, default=""): cv.string,
|
|
vol.Optional(ATTR_METHOD, default="direct"): vol.In(DELIVERY_METHODS),
|
|
vol.Optional(ATTR_FIELDS): dict,
|
|
vol.Optional(ATTR_IDENTITY): cv.string,
|
|
}
|
|
)
|
|
REQUEST_PATH_SCHEMA = vol.Schema(
|
|
{vol.Required(ATTR_DESTINATION): cv.string, vol.Optional(ATTR_IDENTITY): cv.string}
|
|
)
|
|
SET_PROPAGATION_SCHEMA = vol.Schema(
|
|
{vol.Required(ATTR_DESTINATION): cv.string, vol.Optional(ATTR_IDENTITY): cv.string}
|
|
)
|
|
SYNC_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Optional(ATTR_MAX_MESSAGES): vol.All(vol.Coerce(int), vol.Range(min=1)),
|
|
vol.Optional(ATTR_IDENTITY): cv.string,
|
|
}
|
|
)
|
|
ANNOUNCE_SCHEMA = vol.Schema({vol.Optional(ATTR_IDENTITY): cv.string})
|
|
|
|
|
|
def _resolve_manager(hass: HomeAssistant, identity: str | None) -> IdentityManager:
|
|
"""Pick an identity manager by name/address, or the first one."""
|
|
data: dict[str, RuntimeData] = hass.data.get(DOMAIN, {})
|
|
managers: list[IdentityManager] = []
|
|
for runtime in data.values():
|
|
managers.extend(runtime.identities.values())
|
|
if not managers:
|
|
raise HomeAssistantError("No Reticulum identity is configured")
|
|
if identity:
|
|
key = identity.strip().lower()
|
|
for manager in managers:
|
|
if (
|
|
manager.display_name.lower() == key
|
|
or (manager.state.lxmf_address or "").lower() == key
|
|
):
|
|
return manager
|
|
raise HomeAssistantError(f"No Reticulum identity matches '{identity}'")
|
|
return managers[0]
|
|
|
|
|
|
def _async_register_services(hass: HomeAssistant) -> None:
|
|
if hass.services.has_service(DOMAIN, SERVICE_SEND_MESSAGE):
|
|
return
|
|
|
|
async def _send(call: ServiceCall) -> ServiceResponse:
|
|
manager = _resolve_manager(hass, call.data.get(ATTR_IDENTITY))
|
|
try:
|
|
message_hash = await manager.async_send_message(
|
|
call.data[ATTR_DESTINATION],
|
|
call.data[ATTR_CONTENT],
|
|
title=call.data.get(ATTR_TITLE, ""),
|
|
method=call.data.get(ATTR_METHOD, "direct"),
|
|
fields=call.data.get(ATTR_FIELDS),
|
|
)
|
|
except ReticulumError as err:
|
|
raise HomeAssistantError(str(err)) from err
|
|
return {"message_hash": message_hash, "identity": manager.display_name}
|
|
|
|
async def _announce(call: ServiceCall) -> None:
|
|
manager = _resolve_manager(hass, call.data.get(ATTR_IDENTITY))
|
|
try:
|
|
await manager.async_announce()
|
|
except ReticulumError as err:
|
|
raise HomeAssistantError(str(err)) from err
|
|
|
|
async def _request_path(call: ServiceCall) -> None:
|
|
manager = _resolve_manager(hass, call.data.get(ATTR_IDENTITY))
|
|
try:
|
|
await manager.async_request_path(call.data[ATTR_DESTINATION])
|
|
except ReticulumError as err:
|
|
raise HomeAssistantError(str(err)) from err
|
|
|
|
async def _set_propagation(call: ServiceCall) -> None:
|
|
manager = _resolve_manager(hass, call.data.get(ATTR_IDENTITY))
|
|
try:
|
|
await manager.async_set_propagation_node(call.data[ATTR_DESTINATION])
|
|
except ReticulumError as err:
|
|
raise HomeAssistantError(str(err)) from err
|
|
|
|
async def _sync(call: ServiceCall) -> None:
|
|
manager = _resolve_manager(hass, call.data.get(ATTR_IDENTITY))
|
|
try:
|
|
await manager.async_sync_propagation(call.data.get(ATTR_MAX_MESSAGES))
|
|
except ReticulumError as err:
|
|
raise HomeAssistantError(str(err)) from err
|
|
|
|
hass.services.async_register(
|
|
DOMAIN,
|
|
SERVICE_SEND_MESSAGE,
|
|
_send,
|
|
schema=SEND_MESSAGE_SCHEMA,
|
|
supports_response=SupportsResponse.OPTIONAL,
|
|
)
|
|
hass.services.async_register(
|
|
DOMAIN, SERVICE_ANNOUNCE, _announce, schema=ANNOUNCE_SCHEMA
|
|
)
|
|
hass.services.async_register(
|
|
DOMAIN, SERVICE_REQUEST_PATH, _request_path, schema=REQUEST_PATH_SCHEMA
|
|
)
|
|
hass.services.async_register(
|
|
DOMAIN,
|
|
SERVICE_SET_PROPAGATION_NODE,
|
|
_set_propagation,
|
|
schema=SET_PROPAGATION_SCHEMA,
|
|
)
|
|
hass.services.async_register(
|
|
DOMAIN, SERVICE_SYNC_PROPAGATION, _sync, schema=SYNC_SCHEMA
|
|
)
|
|
|
|
|
|
def _async_unregister_services(hass: HomeAssistant) -> None:
|
|
for service in (
|
|
SERVICE_SEND_MESSAGE,
|
|
SERVICE_ANNOUNCE,
|
|
SERVICE_REQUEST_PATH,
|
|
SERVICE_SET_PROPAGATION_NODE,
|
|
SERVICE_SYNC_PROPAGATION,
|
|
):
|
|
if hass.services.has_service(DOMAIN, service):
|
|
hass.services.async_remove(DOMAIN, service)
|