From 027ff685b7689d43854ddf9850270b0bac14cd00 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 22 Jul 2026 20:29:05 +0300 Subject: [PATCH] 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 --- README.md | 129 +- custom_components/reticulum/__init__.py | 244 ++-- custom_components/reticulum/binary_sensor.py | 21 +- custom_components/reticulum/button.py | 78 +- custom_components/reticulum/config_flow.py | 273 +++-- custom_components/reticulum/const.py | 25 +- custom_components/reticulum/diagnostics.py | 47 +- custom_components/reticulum/entity.py | 70 +- custom_components/reticulum/manifest.json | 2 +- custom_components/reticulum/notify.py | 56 +- .../reticulum/reticulum_client.py | 1052 +++++++++-------- custom_components/reticulum/sensor.py | 199 ++-- custom_components/reticulum/services.yaml | 25 +- custom_components/reticulum/strings.json | 140 ++- .../reticulum/translations/en.json | 140 ++- .../reticulum/translations/ru.json | 140 ++- 16 files changed, 1572 insertions(+), 1069 deletions(-) diff --git a/README.md b/README.md index a79f358..09253ec 100644 --- a/README.md +++ b/README.md @@ -75,69 +75,80 @@ directory and restart. **Settings → Devices & Services → Add Integration → Reticulum.** -| Field | Meaning | +You configure the shared **stack** once (the RNS connection), and a first LXMF +**identity** is created automatically. Additional identities are added as +**sub-entries** from the integration page. + +| Stack field | Meaning | |-------|---------| | Target host | Host/IP of the neighbouring Reticulum TCP server | | Target port | Its `listen_port` (default `4242`) | -| Display name | Name announced to other peers (e.g. `Home Assistant`) | | Interface name | Label for the connection in the generated RNS config | +| First identity name | Announce name of the first identity | -On first start the integration generates a stable identity and announces it. -Find your **LXMF address** on the *LXMF address* sensor, or in the log: +### Identities (sub-entries) -``` -Reticulum ready. LXMF address: a1b2c3… (Home Assistant) -``` +Each identity is an independent LXMF address with its own settings, its own +device, and its own entities. Add one via the integration's **⋮ → Add identity** +(or reconfigure an existing one). Typical uses: -Add that address as a contact in Sideband/MeshChat and send a message — Assist -will answer. +- an **assistant** identity (Assist enabled) that users chat with; +- a **receive-only** identity (Assist disabled) whose messages are handled by + automations via the `reticulum_message_received` event. -### Options (⚙ → Configure) +Per-identity settings: +- **Name** – contact name announced to peers (also the identity's HA name). + Change it any time by reconfiguring the identity; future announces use it. - **Route incoming messages to Assist** – turn the chat bridge on/off. - **Assist agent / language** – pick the conversation agent from the dropdown. > **Want free-form chat?** The default *Home Assistant* agent only recognises - > device-control commands and will answer anything else with "Sorry, I - > couldn't understand that". To actually chat, install an LLM conversation - > integration (OpenAI, Google Generative AI, Anthropic, Ollama, …), then - > select that agent here. -- **Reply to any sender** / **Allowed sender addresses** – restrict who Assist - will answer. -- **Default recipient** – address the `notify.reticulum_send_message` entity - sends to (see Entities below). -- **Greeting / auto-reply** – message sent automatically in reply to incoming - messages **only when "Route incoming messages to Assist" is off** (so senders - get an acknowledgement instead of silence). Blank = disabled. -- **Default delivery method** – `direct`, `opportunistic`, or `propagated`. -- **Announce interval** – periodic re-announce (seconds; `0` disables). -- **Propagation node** + **sync interval** – optional store-and-forward node so - messages queue while HA is offline. -- **Reticulum log level** – `0`–`7`. + > device-control commands. To chat, install an LLM conversation integration + > (OpenAI, Google Generative AI, Anthropic, Ollama, …) and select it here. +- **Reply to any sender** / **Allowed sender addresses** – restrict who is answered. +- **Default recipient** – address this identity's notify entity sends to. +- **Auto-reply (when Assist is off)** – message sent automatically in reply to + incoming messages only when Assist routing is off. Blank = disabled. +- **Default delivery method**, **announce interval**, **propagation node** + + **sync interval**. + +Stack-level options (**⚙ → Configure**): **Reticulum log level** (`0`–`7`). + +Find each identity's **LXMF address** on its *LXMF address* sensor, or in the +log (`Reticulum identity '…' ready: a1b2c3…`). Add it as a contact in +Sideband/MeshChat and message it. --- ## Entities +**Hub (stack) device — `Reticulum`:** + | Entity | Description | |--------|-------------| -| `sensor.reticulum_lxmf_address` | This HA's LXMF address (share this with users) | -| `sensor.reticulum_messages_received` / `_sent` / `_failed` | Counters | -| `sensor.reticulum_known_peers` | Count + `peers` attribute (address, name, hops) | -| `sensor.reticulum_last_message` | Last inbound text + source/title attributes | -| `sensor.reticulum_last_message_time` | Timestamp of last inbound message | -| `sensor.reticulum_received` / `_transmitted` | Interface RX/TX byte counters | -| `sensor.reticulum_bitrate` | Interface bitrate | -| `sensor.reticulum_rssi` / `_snr` / `_link_quality` | Physical-link telemetry (disabled by default) | -| `binary_sensor.reticulum_connected` | Whether the TCP interface is online | -| `button.reticulum_announce` | Announce now | -| `button.reticulum_sync_propagation_node` | Pull queued messages now | -| `notify.reticulum_send_message` | `notify` entity that sends an LXMF message to the **default recipient** set in options — use it in automations/scripts (`action: notify.send_message`) or the Developer Tools; for arbitrary addresses use the `reticulum.send_message` service | +| `binary_sensor` Connected | Whether the TCP interface is online | +| `sensor` Known peers | Count + `peers` attribute (address, name, hops) | +| `sensor` Received / Transmitted | Interface RX/TX byte counters | +| `sensor` Bitrate | Interface bitrate | +| `sensor` RSSI / SNR / Link quality | Physical-link telemetry (disabled by default) | -The telemetry sensors read from the outbound interface. RX/TX bytes and bitrate -work for the TCP link. **RSSI, SNR and link quality only carry data when the -underlying interface is a physical one (RNode/LoRa)** — they stay unavailable on -a plain TCP link, so they are disabled by default. Enable them from the device -page if you run a physical interface. +**Per identity device (one per identity):** + +| Entity | Description | +|--------|-------------| +| `sensor` LXMF address | This identity's address (share it with users) | +| `sensor` Messages received / sent / failed | Counters | +| `sensor` Last message | Last inbound text + source/title attributes | +| `button` Announce | Announce this identity now | +| `button` Sync propagation node | Pull queued messages now | +| `button` Regenerate identity | Generate a brand-new address for this identity | +| `notify` Send message | Sends an LXMF message from this identity to its **default recipient** (`action: notify.send_message`); for arbitrary addresses use the `reticulum.send_message` service | + +RSSI, SNR and link quality only carry data on a physical interface (RNode/LoRa) +— they stay unavailable on a plain TCP link, so they are disabled by default. + +> **Regenerate identity** replaces this identity's address with a fresh one and +> re-announces. The old address stops working — contacts must re-add the new one. --- @@ -153,20 +164,26 @@ data: content: "The garage door is still open." title: "Home Assistant" method: direct # direct | opportunistic | propagated + identity: "Home Assistant" # optional: which identity to send from (name or + # LXMF address); defaults to the first identity ``` Other services: `reticulum.announce`, `reticulum.request_path`, -`reticulum.set_propagation_node`, `reticulum.sync_propagation`. +`reticulum.set_propagation_node`, `reticulum.sync_propagation`. Each accepts an +optional `identity` to pick which identity to act through. --- ## Events -- `reticulum_message_received` — `{content, title, source, destination, timestamp, signature_validated, fields}` -- `reticulum_message_delivered` — `{message_hash, destination}` -- `reticulum_message_failed` — `{message_hash, destination}` +- `reticulum_message_received` — `{content, title, source, destination, timestamp, signature_validated, fields, identity, identity_name, local_address}` +- `reticulum_message_delivered` — `{message_hash, destination, identity, identity_name, local_address}` +- `reticulum_message_failed` — `{message_hash, destination, identity, identity_name, local_address}` - `reticulum_announce_received` — `{destination, display_name, stamp_cost, hops}` +`identity` is the subentry id and `identity_name` the identity's name, so +automations can tell which identity received/sent a message. + ### Example automation — forward every inbound message to a mobile push ```yaml @@ -213,15 +230,15 @@ action: ## Notes & limitations -- Reticulum uses a **single process-wide instance**. Only one Reticulum config - entry is allowed, and because RNS/LXMF cannot be cleanly torn down inside a - running process, the running stack is reused across reloads. Changing the - host/port (or recovering from a failed first start) therefore requires a - **full Home Assistant restart**, not just an integration reload — a reload - reuses the already-running stack. Option changes that don't touch the stack - apply on reload. -- The identity is stored in `config/reticulum/identity` — back it up to keep the - same address. +- Reticulum uses a **single process-wide instance**, so only one Reticulum + config entry (the stack) is allowed; add multiple **identities** as sub-entries + under it. Because RNS/LXMF cannot be cleanly torn down inside a running + process, the running stack and each identity's router are reused across + reloads. Changing the host/port (or recovering from a failed first start) + therefore requires a **full Home Assistant restart**, not just a reload. +- Each identity is stored in `config/reticulum/identities//identity` + — back it up to keep the same address. **Regenerating** an identity overwrites + it with a new address. - RNS and LXMF are standalone-daemon-style libraries — both install SIGINT/SIGTERM handlers and `atexit` persistence in their constructors. To embed them cleanly the integration suppresses those signal handlers during diff --git a/custom_components/reticulum/__init__.py b/custom_components/reticulum/__init__.py index bf6219a..17297b0 100644 --- a/custom_components/reticulum/__init__.py +++ b/custom_components/reticulum/__init__.py @@ -1,4 +1,8 @@ -"""The Reticulum integration.""" +"""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 @@ -8,7 +12,7 @@ from typing import Any import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import ( HomeAssistant, @@ -24,6 +28,7 @@ from .const import ( ATTR_CONTENT, ATTR_DESTINATION, ATTR_FIELDS, + ATTR_IDENTITY, ATTR_MAX_MESSAGES, ATTR_METHOD, ATTR_TITLE, @@ -55,8 +60,13 @@ from .const import ( SERVICE_SET_PROPAGATION_NODE, SERVICE_SYNC_PROPAGATION, STORAGE_SUBDIR, + SUBENTRY_TYPE_IDENTITY, +) +from .reticulum_client import ( + IdentityManager, + ReticulumError, + ReticulumStack, ) -from .reticulum_client import ReticulumError, ReticulumManager _LOGGER = logging.getLogger(__name__) @@ -67,86 +77,99 @@ PLATFORMS: list[Platform] = [ Platform.SENSOR, ] -# Interface-status poll: RNS has no push for link up/down, so poll cheaply. INTERFACE_POLL = timedelta(seconds=15) class RuntimeData: - """Container stored on the config entry.""" + """Runtime objects for the hub entry.""" - def __init__(self, manager: ReticulumManager) -> None: - self.manager = manager + def __init__(self, stack: ReticulumStack) -> None: + self.stack = stack + self.identities: dict[str, IdentityManager] = {} self.unsubs: list[Any] = [] - # Maps a peer address -> the HA conversation_id we opened for it, so - # each remote user keeps its own Assist conversation context. + # 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 Reticulum from a config entry.""" - options = entry.options + """Set up the Reticulum stack and its identities.""" storage_dir = hass.config.path(STORAGE_SUBDIR) - - manager = ReticulumManager( + 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), - display_name=entry.data.get(CONF_DISPLAY_NAME, DEFAULT_DISPLAY_NAME), - loglevel=int(options.get(CONF_LOGLEVEL, DEFAULT_LOGLEVEL)), + loglevel=int(entry.options.get(CONF_LOGLEVEL, DEFAULT_LOGLEVEL)), ) - try: - await manager.async_start() + await stack.async_start() except Exception as err: # noqa: BLE001 raise ConfigEntryNotReady(f"Could not start Reticulum: {err}") from err - runtime = RuntimeData(manager) + runtime = RuntimeData(stack) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime - # Wire the incoming-message pipeline (assist bridge + event). - manager.incoming_handler = _make_incoming_handler(hass, entry, 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) - # Announce on startup so peers learn our address, then periodically. - await _safe_announce(manager) - _schedule_periodic(hass, entry, runtime) + # 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) - # Poll interface status. refresh_interface_status is a @callback, so pass it - # directly (a lambda would be a plain sync job and get run in an executor - # thread, where its async_dispatcher_send call is not thread-safe). + # Hub interface-status poll. runtime.unsubs.append( async_track_time_interval( - hass, manager.refresh_interface_status, INTERFACE_POLL + hass, stack.refresh_interface_status, INTERFACE_POLL ) ) - # Persist RNS/LXMF state off-loop when Home Assistant stops (we unregister - # RNS's own atexit handlers, which would otherwise do this with blocking I/O - # on the event loop and detach HA's stdout). async def _on_ha_stop(_event) -> None: - await manager.async_persist() + 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_options_updated)) + 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 a config entry.""" + """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() - await runtime.manager.async_stop() + 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]: @@ -154,25 +177,26 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload the entry so option changes take effect.""" +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 tasks +# Periodic scheduling # --------------------------------------------------------------------------- -def _schedule_periodic( - hass: HomeAssistant, entry: ConfigEntry, runtime: RuntimeData +def _schedule_identity( + hass: HomeAssistant, + runtime: RuntimeData, + manager: IdentityManager, + subentry: ConfigSubentry, ) -> None: - manager = runtime.manager - announce_interval = entry.options.get( - CONF_ANNOUNCE_INTERVAL, DEFAULT_ANNOUNCE_INTERVAL - ) + 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) -> None: - await _safe_announce(manager) + async def _do_announce(_now, _mgr=manager) -> None: + await _safe_announce(_mgr) runtime.unsubs.append( async_track_time_interval( @@ -180,15 +204,15 @@ def _schedule_periodic( ) ) - sync_interval = entry.options.get(CONF_SYNC_INTERVAL, DEFAULT_SYNC_INTERVAL) - propagation_node = entry.options.get(CONF_PROPAGATION_NODE) + 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) -> None: + async def _do_sync(_now, _mgr=manager) -> None: try: - await manager.async_sync_propagation() + await _mgr.async_sync_propagation() except ReticulumError as err: _LOGGER.warning("Propagation sync failed: %s", err) @@ -199,14 +223,14 @@ def _schedule_periodic( ) -async def _set_propagation(manager: ReticulumManager, node: str) -> None: +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: ReticulumManager) -> None: +async def _safe_announce(manager: IdentityManager) -> None: try: await manager.async_announce() except ReticulumError as err: @@ -214,82 +238,82 @@ async def _safe_announce(manager: ReticulumManager) -> None: # --------------------------------------------------------------------------- -# Incoming message pipeline +# Incoming message pipeline (per identity) # --------------------------------------------------------------------------- def _make_incoming_handler( - hass: HomeAssistant, entry: ConfigEntry, runtime: RuntimeData + hass: HomeAssistant, + entry: ConfigEntry, + subentry: ConfigSubentry, + runtime: RuntimeData, + manager: IdentityManager, ): async def _handle(payload: dict[str, Any]) -> None: source = payload["source"] - - # Always surface the message as an event for automations. hass.bus.async_fire(EVENT_MESSAGE_RECEIVED, payload) - options = entry.options - allow_all = options.get(CONF_ALLOW_ALL, True) + data = subentry.data + allow_all = data.get(CONF_ALLOW_ALL, True) allowed = { a.strip().lower() - for a in options.get(CONF_ALLOWED_IDENTITIES, []) + 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 messages are NOT routed to Assist, optionally auto-reply with the - # greeting so senders get an acknowledgement instead of silence. - if not options.get(CONF_ENABLE_ASSIST, True): - greeting = (options.get(CONF_GREETING) or "").strip() + # 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(runtime.manager, source, greeting, "Home Assistant") + await _reply(manager, source, greeting, "Home Assistant") return text = (payload.get("content") or "").strip() if not text: return - - await _run_assist(hass, entry, runtime, source, text) + await _run_assist(hass, subentry, runtime, manager, source, text) return _handle async def _run_assist( hass: HomeAssistant, - entry: ConfigEntry, + subentry: ConfigSubentry, runtime: RuntimeData, + manager: IdentityManager, source: str, text: str, ) -> None: - """Route a message through the Assist conversation pipeline and reply.""" from homeassistant.components import conversation # noqa: PLC0415 from homeassistant.core import Context # noqa: PLC0415 - options = entry.options - agent_id = options.get(CONF_ASSIST_AGENT) or None - language = options.get(CONF_ASSIST_LANGUAGE) or hass.config.language + 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(source), + 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(runtime.manager, source, f"⚠️ Assist error: {err}", "Error") + await _reply(manager, source, f"⚠️ Assist error: {err}", "Error") return - # Remember the conversation so this peer keeps context. conv_id = getattr(result, "conversation_id", None) if conv_id: - runtime.conversation_ids[source] = conv_id + runtime.conversation_ids[conv_key] = conv_id reply = _extract_speech(result) if reply: - await _reply(runtime.manager, source, reply, "Assist") + await _reply(manager, source, reply, "Assist") def _extract_speech(result: Any) -> str | None: @@ -303,12 +327,12 @@ def _extract_speech(result: Any) -> str | None: async def _reply( - manager: ReticulumManager, destination: str, content: str, title: str + 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 Assist reply to %s: %s", destination, err) + _LOGGER.warning("Could not send reply to %s: %s", destination, err) # --------------------------------------------------------------------------- @@ -321,20 +345,42 @@ SEND_MESSAGE_SCHEMA = vol.Schema( 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}) -SET_PROPAGATION_SCHEMA = vol.Schema({vol.Required(ATTR_DESTINATION): cv.string}) -SYNC_SCHEMA = vol.Schema( - {vol.Optional(ATTR_MAX_MESSAGES): vol.All(vol.Coerce(int), vol.Range(min=1))} +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 _first_manager(hass: HomeAssistant) -> ReticulumManager: +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, {}) - if not data: - raise HomeAssistantError("Reticulum is not configured") - return next(iter(data.values())).manager + 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: @@ -342,7 +388,7 @@ def _async_register_services(hass: HomeAssistant) -> None: return async def _send(call: ServiceCall) -> ServiceResponse: - manager = _first_manager(hass) + manager = _resolve_manager(hass, call.data.get(ATTR_IDENTITY)) try: message_hash = await manager.async_send_message( call.data[ATTR_DESTINATION], @@ -353,33 +399,33 @@ def _async_register_services(hass: HomeAssistant) -> None: ) except ReticulumError as err: raise HomeAssistantError(str(err)) from err - return {"message_hash": message_hash} + 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 _first_manager(hass).async_announce() + 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 _first_manager(hass).async_request_path(call.data[ATTR_DESTINATION]) + 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 _first_manager(hass).async_set_propagation_node( - call.data[ATTR_DESTINATION] - ) + 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 _first_manager(hass).async_sync_propagation( - call.data.get(ATTR_MAX_MESSAGES) - ) + await manager.async_sync_propagation(call.data.get(ATTR_MAX_MESSAGES)) except ReticulumError as err: raise HomeAssistantError(str(err)) from err @@ -390,7 +436,9 @@ def _async_register_services(hass: HomeAssistant) -> None: schema=SEND_MESSAGE_SCHEMA, supports_response=SupportsResponse.OPTIONAL, ) - hass.services.async_register(DOMAIN, SERVICE_ANNOUNCE, _announce) + 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 ) diff --git a/custom_components/reticulum/binary_sensor.py b/custom_components/reticulum/binary_sensor.py index 70effd8..a0b6340 100644 --- a/custom_components/reticulum/binary_sensor.py +++ b/custom_components/reticulum/binary_sensor.py @@ -11,8 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .entity import ReticulumEntity -from .reticulum_client import ReticulumManager +from .entity import ReticulumStackEntity async def async_setup_entry( @@ -20,21 +19,21 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Reticulum connectivity binary sensor.""" - manager: ReticulumManager = hass.data[DOMAIN][entry.entry_id].manager - async_add_entities([ReticulumConnectivity(manager, entry.entry_id)]) + """Set up the hub connectivity binary sensor.""" + runtime = hass.data[DOMAIN][entry.entry_id] + async_add_entities([ReticulumConnectivity(runtime.stack, entry)]) -class ReticulumConnectivity(ReticulumEntity, BinarySensorEntity): - """Reports whether the outbound Reticulum interface is online.""" +class ReticulumConnectivity(ReticulumStackEntity, BinarySensorEntity): + """Whether the outbound Reticulum interface is online.""" _attr_translation_key = "connected" _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY - def __init__(self, manager: ReticulumManager, entry_id: str) -> None: - super().__init__(manager, entry_id) - self._attr_unique_id = f"{entry_id}_connected" + def __init__(self, stack, entry) -> None: + super().__init__(stack, entry) + self._attr_unique_id = f"{entry.entry_id}_connected" @property def is_on(self) -> bool: - return self._manager.state.interface_online + return self._stack.state.interface_online diff --git a/custom_components/reticulum/button.py b/custom_components/reticulum/button.py index 87ab7c8..b36b46c 100644 --- a/custom_components/reticulum/button.py +++ b/custom_components/reticulum/button.py @@ -1,17 +1,17 @@ -"""Button platform for Reticulum.""" +"""Button platform for Reticulum (per identity).""" from __future__ import annotations import logging -from homeassistant.components.button import ButtonEntity +from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .entity import ReticulumEntity -from .reticulum_client import ReticulumError, ReticulumManager +from .const import DOMAIN, SUBENTRY_TYPE_IDENTITY +from .entity import ReticulumIdentityEntity +from .reticulum_client import ReticulumError _LOGGER = logging.getLogger(__name__) @@ -21,25 +21,33 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Reticulum buttons.""" - manager: ReticulumManager = hass.data[DOMAIN][entry.entry_id].manager - async_add_entities( - [ - ReticulumAnnounceButton(manager, entry.entry_id), - ReticulumSyncButton(manager, entry.entry_id), - ] - ) + """Set up per-identity buttons.""" + runtime = hass.data[DOMAIN][entry.entry_id] + 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 + async_add_entities( + [ + ReticulumAnnounceButton(manager, entry, subentry), + ReticulumSyncButton(manager, entry, subentry), + ReticulumRegenerateButton(manager, entry, subentry), + ], + config_subentry_id=subentry.subentry_id, + ) -class ReticulumAnnounceButton(ReticulumEntity, ButtonEntity): - """Announce our LXMF destination on demand.""" +class ReticulumAnnounceButton(ReticulumIdentityEntity, ButtonEntity): + """Announce this identity's LXMF destination.""" _attr_translation_key = "announce" _attr_icon = "mdi:bullhorn" - def __init__(self, manager: ReticulumManager, entry_id: str) -> None: - super().__init__(manager, entry_id) - self._attr_unique_id = f"{entry_id}_announce" + def __init__(self, manager, entry, subentry) -> None: + super().__init__(manager, entry, subentry) + self._attr_unique_id = f"{subentry.subentry_id}_announce" async def async_press(self) -> None: try: @@ -48,18 +56,42 @@ class ReticulumAnnounceButton(ReticulumEntity, ButtonEntity): _LOGGER.warning("Announce failed: %s", err) -class ReticulumSyncButton(ReticulumEntity, ButtonEntity): - """Pull queued messages from the configured propagation node.""" +class ReticulumSyncButton(ReticulumIdentityEntity, ButtonEntity): + """Pull queued messages from the propagation node.""" _attr_translation_key = "sync" _attr_icon = "mdi:sync" - def __init__(self, manager: ReticulumManager, entry_id: str) -> None: - super().__init__(manager, entry_id) - self._attr_unique_id = f"{entry_id}_sync" + def __init__(self, manager, entry, subentry) -> None: + super().__init__(manager, entry, subentry) + self._attr_unique_id = f"{subentry.subentry_id}_sync" async def async_press(self) -> None: try: await self._manager.async_sync_propagation() except ReticulumError as err: _LOGGER.warning("Propagation sync failed: %s", err) + + +class ReticulumRegenerateButton(ReticulumIdentityEntity, ButtonEntity): + """Generate a brand-new identity/address for this identity.""" + + _attr_translation_key = "regenerate" + _attr_icon = "mdi:key-change" + _attr_device_class = ButtonDeviceClass.RESTART + + def __init__(self, manager, entry, subentry) -> None: + super().__init__(manager, entry, subentry) + self._attr_unique_id = f"{subentry.subentry_id}_regenerate" + + async def async_press(self) -> None: + try: + address = await self._manager.async_regenerate_identity() + except ReticulumError as err: + _LOGGER.warning("Identity regeneration failed: %s", err) + return + _LOGGER.info( + "Identity '%s' regenerated; new address %s", + self._manager.display_name, + address, + ) diff --git a/custom_components/reticulum/config_flow.py b/custom_components/reticulum/config_flow.py index 275a036..94df6b4 100644 --- a/custom_components/reticulum/config_flow.py +++ b/custom_components/reticulum/config_flow.py @@ -1,4 +1,8 @@ -"""Config flow for the Reticulum integration.""" +"""Config flow for the Reticulum integration. + +The config entry is the shared Reticulum stack (RNS + TCP interface). Each LXMF +identity is a config subentry of type ``identity``. +""" from __future__ import annotations @@ -11,7 +15,9 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, + ConfigSubentryFlow, OptionsFlow, + SubentryFlowResult, ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv @@ -57,9 +63,14 @@ from .const import ( DEFAULT_TARGET_PORT, DELIVERY_METHODS, DOMAIN, + SUBENTRY_TYPE_IDENTITY, ) +class CannotConnect(Exception): + """Error to indicate we cannot connect to the TCP server.""" + + async def _test_connection(host: str, port: int) -> None: """Verify the Reticulum TCP server is reachable. Raises on failure.""" try: @@ -75,36 +86,126 @@ async def _test_connection(host: str, port: int) -> None: raise CannotConnect(str(err)) from err -class CannotConnect(Exception): - """Error to indicate we cannot connect to the TCP server.""" - - STEP_USER_SCHEMA = vol.Schema( { vol.Required(CONF_TARGET_HOST): cv.string, vol.Required(CONF_TARGET_PORT, default=DEFAULT_TARGET_PORT): vol.All( vol.Coerce(int), vol.Range(min=1, max=65535) ), - vol.Required(CONF_DISPLAY_NAME, default=DEFAULT_DISPLAY_NAME): cv.string, vol.Required( CONF_INTERFACE_NAME, default=DEFAULT_INTERFACE_NAME ): cv.string, + vol.Required(CONF_DISPLAY_NAME, default=DEFAULT_DISPLAY_NAME): cv.string, } ) +def _default_identity_data(display_name: str) -> dict[str, Any]: + return { + CONF_DISPLAY_NAME: display_name, + CONF_ENABLE_ASSIST: True, + CONF_ALLOW_ALL: True, + CONF_ALLOWED_IDENTITIES: [], + CONF_DELIVERY_METHOD: DEFAULT_DELIVERY_METHOD, + CONF_ANNOUNCE_INTERVAL: DEFAULT_ANNOUNCE_INTERVAL, + CONF_SYNC_INTERVAL: DEFAULT_SYNC_INTERVAL, + } + + +def _normalise_identity(user_input: dict[str, Any]) -> dict[str, Any]: + """Coerce the allow-list text field into a list.""" + data = dict(user_input) + raw = data.get(CONF_ALLOWED_IDENTITIES, "") + if isinstance(raw, str): + data[CONF_ALLOWED_IDENTITIES] = [ + item.strip() + for item in raw.replace("\n", ",").split(",") + if item.strip() + ] + return data + + +def _identity_schema(defaults: dict[str, Any]) -> vol.Schema: + allowed = defaults.get(CONF_ALLOWED_IDENTITIES, []) + allowed_str = ", ".join(allowed) if isinstance(allowed, list) else (allowed or "") + return vol.Schema( + { + vol.Required( + CONF_DISPLAY_NAME, + default=defaults.get(CONF_DISPLAY_NAME, DEFAULT_DISPLAY_NAME), + ): cv.string, + vol.Required( + CONF_ENABLE_ASSIST, default=defaults.get(CONF_ENABLE_ASSIST, True) + ): BooleanSelector(), + vol.Optional( + CONF_ASSIST_AGENT, + description={"suggested_value": defaults.get(CONF_ASSIST_AGENT)}, + ): ConversationAgentSelector(ConversationAgentSelectorConfig()), + vol.Optional( + CONF_ASSIST_LANGUAGE, + description={"suggested_value": defaults.get(CONF_ASSIST_LANGUAGE)}, + ): LanguageSelector(LanguageSelectorConfig()), + vol.Required( + CONF_ALLOW_ALL, default=defaults.get(CONF_ALLOW_ALL, True) + ): BooleanSelector(), + vol.Optional( + CONF_ALLOWED_IDENTITIES, + description={"suggested_value": allowed_str}, + ): TextSelector(TextSelectorConfig(multiline=True)), + vol.Optional( + CONF_DEFAULT_RECIPIENT, + description={"suggested_value": defaults.get(CONF_DEFAULT_RECIPIENT, "")}, + ): TextSelector(), + vol.Optional( + CONF_GREETING, + description={"suggested_value": defaults.get(CONF_GREETING, "")}, + ): TextSelector(TextSelectorConfig(multiline=True)), + vol.Required( + CONF_DELIVERY_METHOD, + default=defaults.get(CONF_DELIVERY_METHOD, DEFAULT_DELIVERY_METHOD), + ): SelectSelector( + SelectSelectorConfig( + options=DELIVERY_METHODS, + translation_key="delivery_method", + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required( + CONF_ANNOUNCE_INTERVAL, + default=defaults.get(CONF_ANNOUNCE_INTERVAL, DEFAULT_ANNOUNCE_INTERVAL), + ): NumberSelector( + NumberSelectorConfig( + min=0, max=86400, step=60, mode=NumberSelectorMode.BOX, + unit_of_measurement="s", + ) + ), + vol.Optional( + CONF_PROPAGATION_NODE, + description={"suggested_value": defaults.get(CONF_PROPAGATION_NODE, "")}, + ): TextSelector(), + vol.Required( + CONF_SYNC_INTERVAL, + default=defaults.get(CONF_SYNC_INTERVAL, DEFAULT_SYNC_INTERVAL), + ): NumberSelector( + NumberSelectorConfig( + min=0, max=86400, step=60, mode=NumberSelectorMode.BOX, + unit_of_measurement="s", + ) + ), + } + ) + + class ReticulumConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for Reticulum.""" + """Handle the Reticulum stack (hub) config flow.""" VERSION = 1 async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the initial step.""" - self._async_abort_entries_match() # single_config_entry also enforces this + """Configure the shared Reticulum stack and a first identity.""" errors: dict[str, str] = {} - if user_input is not None: try: await _test_connection( @@ -115,12 +216,24 @@ class ReticulumConfigFlow(ConfigFlow, domain=DOMAIN): else: await self.async_set_unique_id(DOMAIN) self._abort_if_unique_id_configured() + display_name = user_input[CONF_DISPLAY_NAME] return self.async_create_entry( - title=f"Reticulum ({user_input[CONF_DISPLAY_NAME]})", - data=user_input, - options=_default_options(), + title="Reticulum", + data={ + CONF_TARGET_HOST: user_input[CONF_TARGET_HOST], + CONF_TARGET_PORT: user_input[CONF_TARGET_PORT], + CONF_INTERFACE_NAME: user_input[CONF_INTERFACE_NAME], + }, + options={CONF_LOGLEVEL: DEFAULT_LOGLEVEL}, + subentries=[ + { + "subentry_type": SUBENTRY_TYPE_IDENTITY, + "title": display_name, + "data": _default_identity_data(display_name), + "unique_id": None, + } + ], ) - return self.async_show_form( step_id="user", data_schema=STEP_USER_SCHEMA, errors=errors ) @@ -128,113 +241,27 @@ class ReticulumConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: - """Get the options flow for this handler.""" return ReticulumOptionsFlow() - -def _default_options() -> dict[str, Any]: - return { - CONF_ENABLE_ASSIST: True, - CONF_ALLOW_ALL: True, - CONF_ALLOWED_IDENTITIES: [], - CONF_ANNOUNCE_INTERVAL: DEFAULT_ANNOUNCE_INTERVAL, - CONF_SYNC_INTERVAL: DEFAULT_SYNC_INTERVAL, - CONF_DELIVERY_METHOD: DEFAULT_DELIVERY_METHOD, - CONF_LOGLEVEL: DEFAULT_LOGLEVEL, - } + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + return {SUBENTRY_TYPE_IDENTITY: IdentitySubentryFlow} class ReticulumOptionsFlow(OptionsFlow): - """Handle Reticulum options.""" + """Hub-level options (stack settings).""" async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Manage the options.""" if user_input is not None: - # Normalise the comma/newline separated allow-list into a list. - raw = user_input.get(CONF_ALLOWED_IDENTITIES, "") - if isinstance(raw, str): - user_input[CONF_ALLOWED_IDENTITIES] = [ - item.strip() - for item in raw.replace("\n", ",").split(",") - if item.strip() - ] return self.async_create_entry(data=user_input) - opts = self.config_entry.options - allowed = opts.get(CONF_ALLOWED_IDENTITIES, []) - allowed_str = ", ".join(allowed) if isinstance(allowed, list) else allowed - schema = vol.Schema( { - vol.Required( - CONF_ENABLE_ASSIST, - default=opts.get(CONF_ENABLE_ASSIST, True), - ): BooleanSelector(), - vol.Optional( - CONF_ASSIST_AGENT, - description={"suggested_value": opts.get(CONF_ASSIST_AGENT)}, - ): ConversationAgentSelector(ConversationAgentSelectorConfig()), - vol.Optional( - CONF_ASSIST_LANGUAGE, - description={ - "suggested_value": opts.get(CONF_ASSIST_LANGUAGE) - }, - ): LanguageSelector(LanguageSelectorConfig()), - vol.Required( - CONF_ALLOW_ALL, default=opts.get(CONF_ALLOW_ALL, True) - ): BooleanSelector(), - vol.Optional( - CONF_ALLOWED_IDENTITIES, - description={"suggested_value": allowed_str}, - ): TextSelector(TextSelectorConfig(multiline=True)), - vol.Optional( - CONF_DEFAULT_RECIPIENT, - description={ - "suggested_value": opts.get(CONF_DEFAULT_RECIPIENT, "") - }, - ): TextSelector(), - vol.Optional( - CONF_GREETING, - description={"suggested_value": opts.get(CONF_GREETING, "")}, - ): TextSelector(TextSelectorConfig(multiline=True)), - vol.Required( - CONF_DELIVERY_METHOD, - default=opts.get(CONF_DELIVERY_METHOD, DEFAULT_DELIVERY_METHOD), - ): SelectSelector( - SelectSelectorConfig( - options=DELIVERY_METHODS, - translation_key="delivery_method", - mode=SelectSelectorMode.DROPDOWN, - ) - ), - vol.Required( - CONF_ANNOUNCE_INTERVAL, - default=opts.get( - CONF_ANNOUNCE_INTERVAL, DEFAULT_ANNOUNCE_INTERVAL - ), - ): NumberSelector( - NumberSelectorConfig( - min=0, max=86400, step=60, mode=NumberSelectorMode.BOX, - unit_of_measurement="s", - ) - ), - vol.Optional( - CONF_PROPAGATION_NODE, - description={ - "suggested_value": opts.get(CONF_PROPAGATION_NODE, "") - }, - ): TextSelector(), - vol.Required( - CONF_SYNC_INTERVAL, - default=opts.get(CONF_SYNC_INTERVAL, DEFAULT_SYNC_INTERVAL), - ): NumberSelector( - NumberSelectorConfig( - min=0, max=86400, step=60, mode=NumberSelectorMode.BOX, - unit_of_measurement="s", - ) - ), vol.Required( CONF_LOGLEVEL, default=opts.get(CONF_LOGLEVEL, DEFAULT_LOGLEVEL) ): NumberSelector( @@ -245,3 +272,35 @@ class ReticulumOptionsFlow(OptionsFlow): } ) return self.async_show_form(step_id="init", data_schema=schema) + + +class IdentitySubentryFlow(ConfigSubentryFlow): + """Add or reconfigure an LXMF identity.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + if user_input is not None: + data = _normalise_identity(user_input) + return self.async_create_entry( + title=data[CONF_DISPLAY_NAME], data=data + ) + return self.async_show_form( + step_id="user", data_schema=_identity_schema({}) + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + subentry = self._get_reconfigure_subentry() + if user_input is not None: + data = _normalise_identity(user_input) + return self.async_update_and_abort( + self._get_entry(), + subentry, + title=data[CONF_DISPLAY_NAME], + data=data, + ) + return self.async_show_form( + step_id="reconfigure", data_schema=_identity_schema(dict(subentry.data)) + ) diff --git a/custom_components/reticulum/const.py b/custom_components/reticulum/const.py index a52dcc3..94d6f81 100644 --- a/custom_components/reticulum/const.py +++ b/custom_components/reticulum/const.py @@ -43,10 +43,18 @@ DEFAULT_LOGLEVEL: Final = 3 # RNS.LOG_NOTICE DELIVERY_METHODS: Final = ["direct", "opportunistic", "propagated"] +# --------------------------------------------------------------------------- +# Subentries +# --------------------------------------------------------------------------- +# The config entry is the shared Reticulum stack (RNS + TCP interface); each +# LXMF identity is a config subentry of this type. +SUBENTRY_TYPE_IDENTITY: Final = "identity" + # --------------------------------------------------------------------------- # Storage # --------------------------------------------------------------------------- STORAGE_SUBDIR: Final = "reticulum" +IDENTITIES_SUBDIR: Final = "identities" IDENTITY_FILENAME: Final = "identity" CONFIG_FILENAME: Final = "config" ATTACHMENTS_SUBDIR: Final = "attachments" @@ -54,7 +62,19 @@ ATTACHMENTS_SUBDIR: Final = "attachments" # --------------------------------------------------------------------------- # Dispatcher signals # --------------------------------------------------------------------------- -SIGNAL_STATE_UPDATED: Final = f"{DOMAIN}_state_updated" +# Per-stack and per-identity update signals; suffix with entry_id / subentry_id. +SIGNAL_STACK_PREFIX: Final = f"{DOMAIN}_stack" +SIGNAL_IDENTITY_PREFIX: Final = f"{DOMAIN}_identity" + + +def stack_signal(entry_id: str) -> str: + """Dispatcher signal for a stack (hub) state update.""" + return f"{SIGNAL_STACK_PREFIX}_{entry_id}" + + +def identity_signal(subentry_id: str) -> str: + """Dispatcher signal for an identity state update.""" + return f"{SIGNAL_IDENTITY_PREFIX}_{subentry_id}" # --------------------------------------------------------------------------- # Events @@ -79,6 +99,9 @@ ATTR_TITLE: Final = "title" ATTR_METHOD: Final = "method" ATTR_FIELDS: Final = "fields" ATTR_MAX_MESSAGES: Final = "max_messages" +# Which identity a domain service should act through (title or LXMF address). +# Omit to use the only/first identity. +ATTR_IDENTITY: Final = "identity" # --------------------------------------------------------------------------- # LXMF address geometry diff --git a/custom_components/reticulum/diagnostics.py b/custom_components/reticulum/diagnostics.py index 30a4bfb..b59de8d 100644 --- a/custom_components/reticulum/diagnostics.py +++ b/custom_components/reticulum/diagnostics.py @@ -16,26 +16,41 @@ TO_REDACT = {CONF_DEFAULT_RECIPIENT, CONF_ALLOWED_IDENTITIES} async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: - """Return diagnostics for a config entry.""" + """Return diagnostics for the hub entry and its identities.""" runtime = hass.data[DOMAIN][entry.entry_id] - state = runtime.manager.state + stack = runtime.stack.state + + identities = [] + for subentry_id, manager in runtime.identities.items(): + s = manager.state + subentry = entry.subentries.get(subentry_id) + identities.append( + { + "title": subentry.title if subentry else None, + "options": async_redact_data( + dict(subentry.data) if subentry else {}, TO_REDACT + ), + "lxmf_address": s.lxmf_address, + "display_name": s.display_name, + "messages_received": s.messages_received, + "messages_sent": s.messages_sent, + "messages_failed": s.messages_failed, + } + ) + return { - "entry": { - "data": dict(entry.data), - "options": async_redact_data(dict(entry.options), TO_REDACT), - }, - "state": { - "started": state.started, - "interface_online": state.interface_online, - "lxmf_address": state.lxmf_address, - "display_name": state.display_name, - "messages_received": state.messages_received, - "messages_sent": state.messages_sent, - "messages_failed": state.messages_failed, - "peer_count": len(state.peers), + "entry": {"data": dict(entry.data), "options": dict(entry.options)}, + "stack": { + "started": stack.started, + "interface_online": stack.interface_online, + "rxb": stack.tel_rxb, + "txb": stack.tel_txb, + "bitrate": stack.tel_bitrate, + "peer_count": len(stack.peers), "peers": [ {"name": p.display_name, "hops": p.hops, "stamp_cost": p.stamp_cost} - for p in state.peers.values() + for p in stack.peers.values() ], }, + "identities": identities, } diff --git a/custom_components/reticulum/entity.py b/custom_components/reticulum/entity.py index 8f21104..e02208b 100644 --- a/custom_components/reticulum/entity.py +++ b/custom_components/reticulum/entity.py @@ -1,44 +1,80 @@ -"""Base entity for the Reticulum integration.""" +"""Base entities for the Reticulum integration.""" from __future__ import annotations +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity -from .const import DOMAIN, SIGNAL_STATE_UPDATED -from .reticulum_client import ReticulumManager +from .const import DOMAIN, identity_signal, stack_signal +from .reticulum_client import IdentityManager, ReticulumStack -class ReticulumEntity(Entity): - """Base class wiring entities to the manager state updates.""" +class ReticulumStackEntity(Entity): + """Base for hub (stack-level) entities.""" _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, manager: ReticulumManager, entry_id: str) -> None: - self._manager = manager - self._entry_id = entry_id + def __init__(self, stack: ReticulumStack, entry: ConfigEntry) -> None: + self._stack = stack + self._entry = entry self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, entry_id)}, + identifiers={(DOMAIN, entry.entry_id)}, name="Reticulum", manufacturer="Reticulum Network Stack", - model="LXMF Peer", - sw_version=manager.state.lxmf_address or None, + model="RNS stack", + entry_type=DeviceEntryType.SERVICE, configuration_url="https://reticulum.network/", ) async def async_added_to_hass(self) -> None: - """Subscribe to state-update signals.""" self.async_on_remove( async_dispatcher_connect( - self.hass, SIGNAL_STATE_UPDATED, self._handle_update + self.hass, stack_signal(self._entry.entry_id), self._handle_update + ) + ) + + @callback + def _handle_update(self) -> None: + self.async_write_ha_state() + + +class ReticulumIdentityEntity(Entity): + """Base for per-identity (subentry) entities.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__( + self, + manager: IdentityManager, + entry: ConfigEntry, + subentry: ConfigSubentry, + ) -> None: + self._manager = manager + self._entry = entry + self._subentry = subentry + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + manufacturer="Reticulum Network Stack", + model="LXMF identity", + entry_type=DeviceEntryType.SERVICE, + via_device=(DOMAIN, entry.entry_id), + ) + + async def async_added_to_hass(self) -> None: + self.async_on_remove( + async_dispatcher_connect( + self.hass, + identity_signal(self._subentry.subentry_id), + self._handle_update, ) ) @callback def _handle_update(self) -> None: - # Decorated with @callback so the dispatcher runs it on the event loop - # (a plain sync callback would be offloaded to an executor thread, and - # async_write_ha_state is not thread-safe). self.async_write_ha_state() diff --git a/custom_components/reticulum/manifest.json b/custom_components/reticulum/manifest.json index d647ccd..0cdbba5 100644 --- a/custom_components/reticulum/manifest.json +++ b/custom_components/reticulum/manifest.json @@ -12,5 +12,5 @@ "loggers": ["RNS", "LXMF"], "requirements": ["rns>=0.9.0", "lxmf>=0.6.0"], "single_config_entry": true, - "version": "1.0.8" + "version": "1.1.0" } diff --git a/custom_components/reticulum/notify.py b/custom_components/reticulum/notify.py index 4714cac..1b2d6a2 100644 --- a/custom_components/reticulum/notify.py +++ b/custom_components/reticulum/notify.py @@ -1,8 +1,8 @@ -"""Notify platform for Reticulum. +"""Notify platform for Reticulum (per identity). -Exposes a ``notify`` entity that sends an LXMF message to the configured -default recipient. For arbitrary destinations use the ``reticulum.send_message`` -service instead. +Each identity exposes a ``notify`` entity that sends an LXMF message from that +identity to its configured default recipient. For arbitrary destinations use +the ``reticulum.send_message`` service. """ from __future__ import annotations @@ -13,9 +13,15 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_DEFAULT_RECIPIENT, CONF_DELIVERY_METHOD, DEFAULT_DELIVERY_METHOD, DOMAIN -from .entity import ReticulumEntity -from .reticulum_client import ReticulumError, ReticulumManager +from .const import ( + CONF_DEFAULT_RECIPIENT, + CONF_DELIVERY_METHOD, + DEFAULT_DELIVERY_METHOD, + DOMAIN, + SUBENTRY_TYPE_IDENTITY, +) +from .entity import ReticulumIdentityEntity +from .reticulum_client import ReticulumError async def async_setup_entry( @@ -23,32 +29,42 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Reticulum notify entity.""" - manager: ReticulumManager = hass.data[DOMAIN][entry.entry_id].manager - async_add_entities([ReticulumNotify(manager, entry)]) + """Set up per-identity notify entities.""" + runtime = hass.data[DOMAIN][entry.entry_id] + 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 + async_add_entities( + [ReticulumNotify(manager, entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) -class ReticulumNotify(ReticulumEntity, NotifyEntity): - """Send LXMF messages to the default recipient.""" +class ReticulumNotify(ReticulumIdentityEntity, NotifyEntity): + """Send LXMF messages from this identity to its default recipient.""" _attr_translation_key = "message" _attr_icon = "mdi:message-fast" _attr_supported_features = NotifyEntityFeature.TITLE - def __init__(self, manager: ReticulumManager, entry: ConfigEntry) -> None: - super().__init__(manager, entry.entry_id) - self._entry = entry - self._attr_unique_id = f"{entry.entry_id}_notify" + def __init__(self, manager, entry, subentry) -> None: + super().__init__(manager, entry, subentry) + self._attr_unique_id = f"{subentry.subentry_id}_notify" async def async_send_message(self, message: str, title: str | None = None) -> None: - recipient = self._entry.options.get(CONF_DEFAULT_RECIPIENT) + recipient = self._subentry.data.get(CONF_DEFAULT_RECIPIENT) if not recipient: raise ServiceValidationError( - "No default recipient configured. Set one in the Reticulum " - "integration options, or use the reticulum.send_message service " + "No default recipient configured for this identity. Set one in " + "the identity's options, or use the reticulum.send_message service " "with an explicit destination." ) - method = self._entry.options.get(CONF_DELIVERY_METHOD, DEFAULT_DELIVERY_METHOD) + method = self._subentry.data.get( + CONF_DELIVERY_METHOD, DEFAULT_DELIVERY_METHOD + ) try: await self._manager.async_send_message( recipient, message, title=title or "", method=method diff --git a/custom_components/reticulum/reticulum_client.py b/custom_components/reticulum/reticulum_client.py index 82d6304..5465b50 100644 --- a/custom_components/reticulum/reticulum_client.py +++ b/custom_components/reticulum/reticulum_client.py @@ -1,18 +1,24 @@ -"""Reticulum / LXMF client manager for Home Assistant. +"""Reticulum / LXMF client 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. +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. -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. +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 asyncio import atexit import logging import os @@ -20,8 +26,9 @@ 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 +from typing import TYPE_CHECKING, Any, Callable, Iterator from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -30,8 +37,10 @@ from .const import ( ATTACHMENTS_SUBDIR, CONFIG_FILENAME, DEST_HASH_LEN, + IDENTITIES_SUBDIR, IDENTITY_FILENAME, - SIGNAL_STATE_UPDATED, + identity_signal, + stack_signal, ) if TYPE_CHECKING: @@ -40,34 +49,65 @@ if TYPE_CHECKING: _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). +# Process-wide singletons / caches (survive config-entry reloads). _RNS_INSTANCE: Any = None -_LXM_ROUTER: Any = None -_LOCAL_DESTINATION: Any = None -_IDENTITY: 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 -# 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 +@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: - """A Reticulum LXMF peer we have heard announce.""" + """An LXMF peer heard announcing.""" destination_hash: str display_name: str | None @@ -77,182 +117,121 @@ class Peer: @dataclass(slots=True) -class ManagerState: - """Mutable state surfaced to entities and diagnostics.""" +class StackState: + """Hub-level state (shared RNS instance).""" 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 + 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 - 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.""" +# --------------------------------------------------------------------------- +# 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, - display_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.display_name = display_name self.loglevel = loglevel - - self.state = ManagerState(display_name=display_name) - - # Populated in _start (executor). + self.state = StackState() 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) + await self.hass.async_add_executor_job(self._start) self._push_state() def _start(self) -> None: - """Blocking init wrapper (executor thread). - - Both ``RNS.Reticulum()`` and ``LXMF.LXMRouter()`` call - ``signal.signal()`` in their constructors, which only works on the main - thread. We run init in an executor thread, so neutralise ``signal.signal`` - for the whole init. As a bonus this stops RNS/LXMF from hijacking Home - Assistant's own SIGINT/SIGTERM handling (needed for clean shutdown, - especially under Kubernetes). - """ - patched = threading.current_thread() is not threading.main_thread() - saved_signal = signal.signal - if patched: - signal.signal = _noop_signal # type: ignore[assignment] - try: - self._start_impl() - finally: - if patched: - signal.signal = saved_signal # type: ignore[assignment] - - def _start_impl(self) -> None: - """Blocking init. Runs in the executor thread (signals suppressed).""" - global _RNS_INSTANCE, _LXM_ROUTER, _LOCAL_DESTINATION # noqa: PLW0603 - global _IDENTITY, _ANNOUNCE_HANDLER # noqa: PLW0603 - import LXMF # noqa: PLC0415 + 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() - # 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 + except Exception: # noqa: BLE001 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) + 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 - self._tame_exit_handler(RNS.Reticulum) + _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. + # (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 - best effort / older RNS + except Exception: # noqa: BLE001 _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) + _ANNOUNCE_HANDLER = _AnnounceHandler(self) + RNS.Transport.register_announce_handler(_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, + "Reticulum stack ready (%s:%s via %s)", + self.target_host, + self.target_port, + self.interface_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" @@ -271,44 +250,10 @@ class ReticulumManager: 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 (called with signal handlers suppressed).""" - 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 - - @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_stop(self) -> None: + self.state.started = False 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) @@ -324,314 +269,14 @@ class ReticulumManager: continue try: fn() - except Exception: # noqa: BLE001 - best effort + except Exception: # noqa: BLE001 _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) - message_hash = await self.hass.async_add_executor_job( - self._send, dest_hash, content, title, method, fields - ) - # Count at send time. The delivery callback only fires on a returned - # proof, which many peers/methods never send, so counting there would - # leave this stuck at 0 even for messages that were delivered fine. - 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) - _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: - # messages_sent is counted at send time (see async_send_message); this - # callback only confirms delivery, so it just emits the event. - import RNS # noqa: PLC0415 - - _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 - # ------------------------------------------------------------------ @callback def refresh_interface_status(self, now: Any = None) -> None: - """Recompute interface online status and collect telemetry. - - Decorated with @callback and used directly as the async_track_time_interval - action so it runs on the event loop (only fast in-memory interface - attribute reads happen here, no blocking I/O). 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. - """ + """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: @@ -646,7 +291,6 @@ class ReticulumManager: 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) @@ -670,7 +314,6 @@ class ReticulumManager: ) if snapshot == current: return - ( self.state.interface_online, self.state.tel_rxb, @@ -682,34 +325,377 @@ class ReticulumManager: ) = 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, SIGNAL_STATE_UPDATED) + async_dispatcher_send(self.hass, stack_signal(self.entry_id)) - @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)" + +# --------------------------------------------------------------------------- +# 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: - return bytes.fromhex(cleaned) - except ValueError as err: - raise ReticulumError(f"Invalid Reticulum address '{value}'") from err + 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, manager: ReticulumManager) -> None: - self._manager = manager - # LXMF requests receipt of the full announce data path. + def __init__(self, stack: ReticulumStack) -> None: + self._stack = stack self.receive_path_responses = False def received_announce( @@ -718,10 +704,8 @@ class _AnnounceHandler: 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. + # 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 @@ -732,7 +716,7 @@ class _AnnounceHandler: 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 + except Exception: # noqa: BLE001 if isinstance(app_data, (bytes, bytearray)): try: display_name = app_data.decode("utf-8", errors="replace") @@ -745,7 +729,7 @@ class _AnnounceHandler: except Exception: # noqa: BLE001 hops = None - self._manager._register_peer( + self._stack.register_peer( RNS.hexrep(destination_hash, delimit=False), display_name, stamp_cost, @@ -753,24 +737,66 @@ class _AnnounceHandler: ) -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, +# --------------------------------------------------------------------------- +# 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, + }, ) - mapping = { - "delivered": EVENT_MESSAGE_DELIVERED, - "failed": EVENT_MESSAGE_FAILED, - "announce": EVENT_ANNOUNCE_RECEIVED, - } - hass.bus.async_fire(mapping[kind], data) + +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: - """Call ``func`` supporting both coroutine and plain callables.""" +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 diff --git a/custom_components/reticulum/sensor.py b/custom_components/reticulum/sensor.py index 4302777..f58d7dd 100644 --- a/custom_components/reticulum/sensor.py +++ b/custom_components/reticulum/sensor.py @@ -24,55 +24,35 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .entity import ReticulumEntity -from .reticulum_client import ManagerState, ReticulumManager +from .const import DOMAIN, SUBENTRY_TYPE_IDENTITY +from .entity import ReticulumIdentityEntity, ReticulumStackEntity +from .reticulum_client import IdentityState, StackState @dataclass(frozen=True, kw_only=True) -class ReticulumSensorDescription(SensorEntityDescription): - """Describes a Reticulum sensor.""" +class StackSensorDescription(SensorEntityDescription): + """Hub sensor.""" - value_fn: Callable[[ManagerState], Any] - attr_fn: Callable[[ManagerState], dict[str, Any]] | None = None + value_fn: Callable[[StackState], Any] + attr_fn: Callable[[StackState], dict[str, Any]] | None = None -def _last_message_time(state: ManagerState) -> datetime | None: +@dataclass(frozen=True, kw_only=True) +class IdentitySensorDescription(SensorEntityDescription): + """Per-identity sensor.""" + + value_fn: Callable[[IdentityState], Any] + attr_fn: Callable[[IdentityState], dict[str, Any]] | None = None + + +def _last_message_time(state: IdentityState) -> datetime | None: if state.last_message_time is None: return None return datetime.fromtimestamp(state.last_message_time, tz=timezone.utc) -SENSORS: tuple[ReticulumSensorDescription, ...] = ( - ReticulumSensorDescription( - key="lxmf_address", - translation_key="lxmf_address", - icon="mdi:identifier", - entity_category=None, - value_fn=lambda s: s.lxmf_address, - ), - ReticulumSensorDescription( - key="messages_received", - translation_key="messages_received", - icon="mdi:inbox-arrow-down", - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda s: s.messages_received, - ), - ReticulumSensorDescription( - key="messages_sent", - translation_key="messages_sent", - icon="mdi:inbox-arrow-up", - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda s: s.messages_sent, - ), - ReticulumSensorDescription( - key="messages_failed", - translation_key="messages_failed", - icon="mdi:message-alert", - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda s: s.messages_failed, - ), - ReticulumSensorDescription( +STACK_SENSORS: tuple[StackSensorDescription, ...] = ( + StackSensorDescription( key="peers", translation_key="peers", icon="mdi:account-group", @@ -92,50 +72,31 @@ SENSORS: tuple[ReticulumSensorDescription, ...] = ( ] }, ), - ReticulumSensorDescription( - key="last_message", - translation_key="last_message", - icon="mdi:message-text", - value_fn=lambda s: (s.last_message or "")[:255] or None, - attr_fn=lambda s: { - "source": s.last_message_source, - "title": s.last_message_title, - "full_content": s.last_message, - }, - ), - ReticulumSensorDescription( - key="last_message_time", - translation_key="last_message_time", - icon="mdi:clock-outline", - device_class=SensorDeviceClass.TIMESTAMP, - value_fn=_last_message_time, - ), - # --- Interface telemetry ------------------------------------------------- - ReticulumSensorDescription( + StackSensorDescription( key="rx_bytes", translation_key="rx_bytes", icon="mdi:download-network", device_class=SensorDeviceClass.DATA_SIZE, native_unit_of_measurement=UnitOfInformation.BYTES, - state_class=SensorStateClass.TOTAL_INCREASING, suggested_unit_of_measurement=UnitOfInformation.KIBIBYTES, suggested_display_precision=1, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda s: s.tel_rxb, ), - ReticulumSensorDescription( + StackSensorDescription( key="tx_bytes", translation_key="tx_bytes", icon="mdi:upload-network", device_class=SensorDeviceClass.DATA_SIZE, native_unit_of_measurement=UnitOfInformation.BYTES, - state_class=SensorStateClass.TOTAL_INCREASING, suggested_unit_of_measurement=UnitOfInformation.KIBIBYTES, suggested_display_precision=1, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda s: s.tel_txb, ), - ReticulumSensorDescription( + StackSensorDescription( key="bitrate", translation_key="bitrate", icon="mdi:speedometer", @@ -145,9 +106,7 @@ SENSORS: tuple[ReticulumSensorDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda s: s.tel_bitrate, ), - # RSSI / SNR / quality are only meaningful on physical interfaces - # (RNode/LoRa). Disabled by default; enable them if a physical link is used. - ReticulumSensorDescription( + StackSensorDescription( key="rssi", translation_key="rssi", icon="mdi:signal", @@ -158,7 +117,7 @@ SENSORS: tuple[ReticulumSensorDescription, ...] = ( entity_registry_enabled_default=False, value_fn=lambda s: s.tel_rssi, ), - ReticulumSensorDescription( + StackSensorDescription( key="snr", translation_key="snr", icon="mdi:signal-variant", @@ -168,7 +127,7 @@ SENSORS: tuple[ReticulumSensorDescription, ...] = ( entity_registry_enabled_default=False, value_fn=lambda s: s.tel_snr, ), - ReticulumSensorDescription( + StackSensorDescription( key="link_quality", translation_key="link_quality", icon="mdi:gauge", @@ -181,32 +140,114 @@ SENSORS: tuple[ReticulumSensorDescription, ...] = ( ) +IDENTITY_SENSORS: tuple[IdentitySensorDescription, ...] = ( + IdentitySensorDescription( + key="lxmf_address", + translation_key="lxmf_address", + icon="mdi:identifier", + value_fn=lambda s: s.lxmf_address, + ), + IdentitySensorDescription( + key="messages_received", + translation_key="messages_received", + icon="mdi:inbox-arrow-down", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda s: s.messages_received, + ), + IdentitySensorDescription( + key="messages_sent", + translation_key="messages_sent", + icon="mdi:inbox-arrow-up", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda s: s.messages_sent, + ), + IdentitySensorDescription( + key="messages_failed", + translation_key="messages_failed", + icon="mdi:message-alert", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda s: s.messages_failed, + ), + IdentitySensorDescription( + key="last_message", + translation_key="last_message", + icon="mdi:message-text", + value_fn=lambda s: (s.last_message or "")[:255] or None, + attr_fn=lambda s: { + "source": s.last_message_source, + "title": s.last_message_title, + "full_content": s.last_message, + }, + ), + IdentitySensorDescription( + key="last_message_time", + translation_key="last_message_time", + icon="mdi:clock-outline", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=_last_message_time, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Reticulum sensors.""" - manager: ReticulumManager = hass.data[DOMAIN][entry.entry_id].manager + """Set up hub + per-identity sensors.""" + runtime = hass.data[DOMAIN][entry.entry_id] + async_add_entities( - ReticulumSensor(manager, entry.entry_id, desc) for desc in SENSORS + ReticulumStackSensor(runtime.stack, entry, desc) for desc in STACK_SENSORS ) + 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 + async_add_entities( + ( + ReticulumIdentitySensor(manager, entry, subentry, desc) + for desc in IDENTITY_SENSORS + ), + config_subentry_id=subentry.subentry_id, + ) -class ReticulumSensor(ReticulumEntity, SensorEntity): - """A Reticulum sensor.""" - entity_description: ReticulumSensorDescription +class ReticulumStackSensor(ReticulumStackEntity, SensorEntity): + """Hub sensor.""" + + entity_description: StackSensorDescription + + def __init__(self, stack, entry, description: StackSensorDescription) -> None: + super().__init__(stack, entry) + self.entity_description = description + self._attr_unique_id = f"{entry.entry_id}_{description.key}" + + @property + def native_value(self) -> Any: + return self.entity_description.value_fn(self._stack.state) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + if self.entity_description.attr_fn is None: + return None + return self.entity_description.attr_fn(self._stack.state) + + +class ReticulumIdentitySensor(ReticulumIdentityEntity, SensorEntity): + """Per-identity sensor.""" + + entity_description: IdentitySensorDescription def __init__( - self, - manager: ReticulumManager, - entry_id: str, - description: ReticulumSensorDescription, + self, manager, entry, subentry, description: IdentitySensorDescription ) -> None: - super().__init__(manager, entry_id) + super().__init__(manager, entry, subentry) self.entity_description = description - self._attr_unique_id = f"{entry_id}_{description.key}" + self._attr_unique_id = f"{subentry.subentry_id}_{description.key}" @property def native_value(self) -> Any: diff --git a/custom_components/reticulum/services.yaml b/custom_components/reticulum/services.yaml index d6ac2ba..58135b8 100644 --- a/custom_components/reticulum/services.yaml +++ b/custom_components/reticulum/services.yaml @@ -26,13 +26,24 @@ send_message: - direct - opportunistic - propagated + identity: + required: false + example: "Home Assistant" + selector: + text: fields: required: false - example: '{ }' + example: "{ }" selector: object: announce: + fields: + identity: + required: false + example: "Home Assistant" + selector: + text: request_path: fields: @@ -41,6 +52,10 @@ request_path: example: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" selector: text: + identity: + required: false + selector: + text: set_propagation_node: fields: @@ -49,6 +64,10 @@ set_propagation_node: example: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" selector: text: + identity: + required: false + selector: + text: sync_propagation: fields: @@ -60,3 +79,7 @@ sync_propagation: min: 1 max: 1000 mode: box + identity: + required: false + selector: + text: diff --git a/custom_components/reticulum/strings.json b/custom_components/reticulum/strings.json index 07fa6ac..e55fcc9 100644 --- a/custom_components/reticulum/strings.json +++ b/custom_components/reticulum/strings.json @@ -3,18 +3,18 @@ "step": { "user": { "title": "Connect to Reticulum", - "description": "Home Assistant will run a Reticulum instance and connect out to your Reticulum stack over TCP (the neighbouring machine must expose a TCPServerInterface).", + "description": "Home Assistant will run a Reticulum instance and connect out to your Reticulum stack over TCP. A first LXMF identity is created automatically; add more later from the integration page.", "data": { "target_host": "Target host", "target_port": "Target port", - "display_name": "Display name", - "interface_name": "Interface name" + "interface_name": "Interface name", + "display_name": "First identity name" }, "data_description": { "target_host": "Hostname or IP of the machine running the Reticulum TCP server.", "target_port": "TCP port of the Reticulum TCPServerInterface (default 4242).", - "display_name": "Name announced to other Reticulum peers for this Home Assistant.", - "interface_name": "Label used for this connection in the Reticulum config." + "interface_name": "Label used for this connection in the Reticulum config.", + "display_name": "Announce name of the first LXMF identity." } } }, @@ -23,39 +23,81 @@ }, "abort": { "already_configured": "Reticulum is already configured.", - "single_instance_allowed": "Only a single Reticulum instance is allowed." + "single_instance_allowed": "Only a single Reticulum stack is allowed. Add more identities to the existing one." } }, "options": { "step": { "init": { - "title": "Reticulum options", + "title": "Reticulum stack options", "data": { - "enable_assist": "Route incoming messages to Assist", - "assist_agent": "Assist agent (entity id / agent id, blank = default)", - "assist_language": "Assist language (blank = system default)", - "allow_all_senders": "Reply to any sender", - "allowed_identities": "Allowed sender addresses", - "default_recipient": "Default recipient address (for the notify entity)", - "greeting": "Auto-reply message (when Assist is off)", - "delivery_method": "Default delivery method", - "announce_interval": "Announce interval", - "propagation_node": "Propagation node address", - "sync_interval": "Propagation sync interval", "loglevel": "Reticulum log level (0-7)" - }, - "data_description": { - "enable_assist": "When enabled, incoming LXMF messages are answered by the Assist conversation agent and the reply is sent back over Reticulum.", - "allowed_identities": "One address per line (or comma separated). Only used when 'Reply to any sender' is off.", - "default_recipient": "16-byte hex LXMF address that the notify.reticulum entity sends to.", - "greeting": "Sent as an automatic reply to incoming messages only when 'Route incoming messages to Assist' is turned off. Leave blank to disable.", - "announce_interval": "How often (seconds) to announce our address. 0 disables periodic announces.", - "propagation_node": "Optional LXMF propagation node for store-and-forward delivery.", - "sync_interval": "How often (seconds) to pull queued messages from the propagation node. 0 disables." } } } }, + "config_subentries": { + "identity": { + "initiate_flow": { + "user": "Add identity", + "reconfigure": "Reconfigure identity" + }, + "entry_type": "LXMF identity", + "step": { + "user": { + "title": "Add LXMF identity", + "data": { + "display_name": "Name (announced to peers)", + "enable_assist": "Route incoming messages to Assist", + "assist_agent": "Assist agent", + "assist_language": "Assist language", + "allow_all_senders": "Reply to any sender", + "allowed_identities": "Allowed sender addresses", + "default_recipient": "Default recipient address", + "greeting": "Auto-reply message (when Assist is off)", + "delivery_method": "Default delivery method", + "announce_interval": "Announce interval", + "propagation_node": "Propagation node address", + "sync_interval": "Propagation sync interval" + }, + "data_description": { + "display_name": "Contact name announced to other Reticulum peers. Also used as the identity's name in Home Assistant.", + "enable_assist": "When enabled, incoming LXMF messages are answered by the Assist conversation agent and the reply is sent back.", + "allowed_identities": "One address per line (or comma separated). Only used when 'Reply to any sender' is off.", + "greeting": "Sent as an automatic reply to incoming messages only when 'Route incoming messages to Assist' is off. Leave blank to disable.", + "default_recipient": "16-byte hex LXMF address that this identity's notify entity sends to.", + "announce_interval": "How often (seconds) to announce this identity. 0 disables periodic announces.", + "propagation_node": "Optional LXMF propagation node for store-and-forward delivery.", + "sync_interval": "How often (seconds) to pull queued messages from the propagation node. 0 disables." + } + }, + "reconfigure": { + "title": "Reconfigure LXMF identity", + "data": { + "display_name": "Name (announced to peers)", + "enable_assist": "Route incoming messages to Assist", + "assist_agent": "Assist agent", + "assist_language": "Assist language", + "allow_all_senders": "Reply to any sender", + "allowed_identities": "Allowed sender addresses", + "default_recipient": "Default recipient address", + "greeting": "Auto-reply message (when Assist is off)", + "delivery_method": "Default delivery method", + "announce_interval": "Announce interval", + "propagation_node": "Propagation node address", + "sync_interval": "Propagation sync interval" + }, + "data_description": { + "display_name": "Contact name announced to other Reticulum peers. Changing it updates future announces.", + "greeting": "Sent as an automatic reply to incoming messages only when 'Route incoming messages to Assist' is off. Leave blank to disable." + } + } + }, + "abort": { + "reconfigure_successful": "Identity updated." + } + } + }, "entity": { "sensor": { "lxmf_address": { "name": "LXMF address" }, @@ -77,7 +119,8 @@ }, "button": { "announce": { "name": "Announce" }, - "sync": { "name": "Sync propagation node" } + "sync": { "name": "Sync propagation node" }, + "regenerate": { "name": "Regenerate identity" } }, "notify": { "message": { "name": "Send message" } @@ -109,17 +152,12 @@ "name": "Destination", "description": "16-byte hex LXMF destination hash of the recipient." }, - "content": { - "name": "Content", - "description": "The message body." - }, - "title": { - "name": "Title", - "description": "Optional message title / subject." - }, - "method": { - "name": "Method", - "description": "Delivery method to use." + "content": { "name": "Content", "description": "The message body." }, + "title": { "name": "Title", "description": "Optional message title / subject." }, + "method": { "name": "Method", "description": "Delivery method to use." }, + "identity": { + "name": "Identity", + "description": "Which identity to send from (name or LXMF address). Defaults to the first identity." }, "fields": { "name": "Fields", @@ -129,7 +167,13 @@ }, "announce": { "name": "Announce", - "description": "Announce this Home Assistant's LXMF destination on the network." + "description": "Announce an identity's LXMF destination on the network.", + "fields": { + "identity": { + "name": "Identity", + "description": "Which identity to announce. Defaults to the first identity." + } + } }, "request_path": { "name": "Request path", @@ -138,26 +182,38 @@ "destination": { "name": "Destination", "description": "Destination hash to resolve a path to." + }, + "identity": { + "name": "Identity", + "description": "Which identity to act through. Defaults to the first identity." } } }, "set_propagation_node": { "name": "Set propagation node", - "description": "Set the outbound LXMF propagation node.", + "description": "Set the outbound LXMF propagation node for an identity.", "fields": { "destination": { "name": "Destination", "description": "Propagation node destination hash." + }, + "identity": { + "name": "Identity", + "description": "Which identity to configure. Defaults to the first identity." } } }, "sync_propagation": { "name": "Sync propagation node", - "description": "Pull queued messages from the configured propagation node.", + "description": "Pull queued messages from an identity's propagation node.", "fields": { "max_messages": { "name": "Max messages", "description": "Maximum number of messages to retrieve." + }, + "identity": { + "name": "Identity", + "description": "Which identity to sync. Defaults to the first identity." } } } diff --git a/custom_components/reticulum/translations/en.json b/custom_components/reticulum/translations/en.json index 07fa6ac..e55fcc9 100644 --- a/custom_components/reticulum/translations/en.json +++ b/custom_components/reticulum/translations/en.json @@ -3,18 +3,18 @@ "step": { "user": { "title": "Connect to Reticulum", - "description": "Home Assistant will run a Reticulum instance and connect out to your Reticulum stack over TCP (the neighbouring machine must expose a TCPServerInterface).", + "description": "Home Assistant will run a Reticulum instance and connect out to your Reticulum stack over TCP. A first LXMF identity is created automatically; add more later from the integration page.", "data": { "target_host": "Target host", "target_port": "Target port", - "display_name": "Display name", - "interface_name": "Interface name" + "interface_name": "Interface name", + "display_name": "First identity name" }, "data_description": { "target_host": "Hostname or IP of the machine running the Reticulum TCP server.", "target_port": "TCP port of the Reticulum TCPServerInterface (default 4242).", - "display_name": "Name announced to other Reticulum peers for this Home Assistant.", - "interface_name": "Label used for this connection in the Reticulum config." + "interface_name": "Label used for this connection in the Reticulum config.", + "display_name": "Announce name of the first LXMF identity." } } }, @@ -23,39 +23,81 @@ }, "abort": { "already_configured": "Reticulum is already configured.", - "single_instance_allowed": "Only a single Reticulum instance is allowed." + "single_instance_allowed": "Only a single Reticulum stack is allowed. Add more identities to the existing one." } }, "options": { "step": { "init": { - "title": "Reticulum options", + "title": "Reticulum stack options", "data": { - "enable_assist": "Route incoming messages to Assist", - "assist_agent": "Assist agent (entity id / agent id, blank = default)", - "assist_language": "Assist language (blank = system default)", - "allow_all_senders": "Reply to any sender", - "allowed_identities": "Allowed sender addresses", - "default_recipient": "Default recipient address (for the notify entity)", - "greeting": "Auto-reply message (when Assist is off)", - "delivery_method": "Default delivery method", - "announce_interval": "Announce interval", - "propagation_node": "Propagation node address", - "sync_interval": "Propagation sync interval", "loglevel": "Reticulum log level (0-7)" - }, - "data_description": { - "enable_assist": "When enabled, incoming LXMF messages are answered by the Assist conversation agent and the reply is sent back over Reticulum.", - "allowed_identities": "One address per line (or comma separated). Only used when 'Reply to any sender' is off.", - "default_recipient": "16-byte hex LXMF address that the notify.reticulum entity sends to.", - "greeting": "Sent as an automatic reply to incoming messages only when 'Route incoming messages to Assist' is turned off. Leave blank to disable.", - "announce_interval": "How often (seconds) to announce our address. 0 disables periodic announces.", - "propagation_node": "Optional LXMF propagation node for store-and-forward delivery.", - "sync_interval": "How often (seconds) to pull queued messages from the propagation node. 0 disables." } } } }, + "config_subentries": { + "identity": { + "initiate_flow": { + "user": "Add identity", + "reconfigure": "Reconfigure identity" + }, + "entry_type": "LXMF identity", + "step": { + "user": { + "title": "Add LXMF identity", + "data": { + "display_name": "Name (announced to peers)", + "enable_assist": "Route incoming messages to Assist", + "assist_agent": "Assist agent", + "assist_language": "Assist language", + "allow_all_senders": "Reply to any sender", + "allowed_identities": "Allowed sender addresses", + "default_recipient": "Default recipient address", + "greeting": "Auto-reply message (when Assist is off)", + "delivery_method": "Default delivery method", + "announce_interval": "Announce interval", + "propagation_node": "Propagation node address", + "sync_interval": "Propagation sync interval" + }, + "data_description": { + "display_name": "Contact name announced to other Reticulum peers. Also used as the identity's name in Home Assistant.", + "enable_assist": "When enabled, incoming LXMF messages are answered by the Assist conversation agent and the reply is sent back.", + "allowed_identities": "One address per line (or comma separated). Only used when 'Reply to any sender' is off.", + "greeting": "Sent as an automatic reply to incoming messages only when 'Route incoming messages to Assist' is off. Leave blank to disable.", + "default_recipient": "16-byte hex LXMF address that this identity's notify entity sends to.", + "announce_interval": "How often (seconds) to announce this identity. 0 disables periodic announces.", + "propagation_node": "Optional LXMF propagation node for store-and-forward delivery.", + "sync_interval": "How often (seconds) to pull queued messages from the propagation node. 0 disables." + } + }, + "reconfigure": { + "title": "Reconfigure LXMF identity", + "data": { + "display_name": "Name (announced to peers)", + "enable_assist": "Route incoming messages to Assist", + "assist_agent": "Assist agent", + "assist_language": "Assist language", + "allow_all_senders": "Reply to any sender", + "allowed_identities": "Allowed sender addresses", + "default_recipient": "Default recipient address", + "greeting": "Auto-reply message (when Assist is off)", + "delivery_method": "Default delivery method", + "announce_interval": "Announce interval", + "propagation_node": "Propagation node address", + "sync_interval": "Propagation sync interval" + }, + "data_description": { + "display_name": "Contact name announced to other Reticulum peers. Changing it updates future announces.", + "greeting": "Sent as an automatic reply to incoming messages only when 'Route incoming messages to Assist' is off. Leave blank to disable." + } + } + }, + "abort": { + "reconfigure_successful": "Identity updated." + } + } + }, "entity": { "sensor": { "lxmf_address": { "name": "LXMF address" }, @@ -77,7 +119,8 @@ }, "button": { "announce": { "name": "Announce" }, - "sync": { "name": "Sync propagation node" } + "sync": { "name": "Sync propagation node" }, + "regenerate": { "name": "Regenerate identity" } }, "notify": { "message": { "name": "Send message" } @@ -109,17 +152,12 @@ "name": "Destination", "description": "16-byte hex LXMF destination hash of the recipient." }, - "content": { - "name": "Content", - "description": "The message body." - }, - "title": { - "name": "Title", - "description": "Optional message title / subject." - }, - "method": { - "name": "Method", - "description": "Delivery method to use." + "content": { "name": "Content", "description": "The message body." }, + "title": { "name": "Title", "description": "Optional message title / subject." }, + "method": { "name": "Method", "description": "Delivery method to use." }, + "identity": { + "name": "Identity", + "description": "Which identity to send from (name or LXMF address). Defaults to the first identity." }, "fields": { "name": "Fields", @@ -129,7 +167,13 @@ }, "announce": { "name": "Announce", - "description": "Announce this Home Assistant's LXMF destination on the network." + "description": "Announce an identity's LXMF destination on the network.", + "fields": { + "identity": { + "name": "Identity", + "description": "Which identity to announce. Defaults to the first identity." + } + } }, "request_path": { "name": "Request path", @@ -138,26 +182,38 @@ "destination": { "name": "Destination", "description": "Destination hash to resolve a path to." + }, + "identity": { + "name": "Identity", + "description": "Which identity to act through. Defaults to the first identity." } } }, "set_propagation_node": { "name": "Set propagation node", - "description": "Set the outbound LXMF propagation node.", + "description": "Set the outbound LXMF propagation node for an identity.", "fields": { "destination": { "name": "Destination", "description": "Propagation node destination hash." + }, + "identity": { + "name": "Identity", + "description": "Which identity to configure. Defaults to the first identity." } } }, "sync_propagation": { "name": "Sync propagation node", - "description": "Pull queued messages from the configured propagation node.", + "description": "Pull queued messages from an identity's propagation node.", "fields": { "max_messages": { "name": "Max messages", "description": "Maximum number of messages to retrieve." + }, + "identity": { + "name": "Identity", + "description": "Which identity to sync. Defaults to the first identity." } } } diff --git a/custom_components/reticulum/translations/ru.json b/custom_components/reticulum/translations/ru.json index 637be94..662b3ba 100644 --- a/custom_components/reticulum/translations/ru.json +++ b/custom_components/reticulum/translations/ru.json @@ -3,18 +3,18 @@ "step": { "user": { "title": "Подключение к Reticulum", - "description": "Home Assistant запустит экземпляр Reticulum и подключится к вашему стеку Reticulum по TCP (на соседней машине должен быть настроен TCPServerInterface).", + "description": "Home Assistant запустит экземпляр Reticulum и подключится к вашему стеку по TCP. Первая LXMF-identity создаётся автоматически; остальные можно добавить позже на странице интеграции.", "data": { "target_host": "Хост", "target_port": "Порт", - "display_name": "Отображаемое имя", - "interface_name": "Название интерфейса" + "interface_name": "Название интерфейса", + "display_name": "Имя первой identity" }, "data_description": { "target_host": "Имя хоста или IP-адрес машины с TCP-сервером Reticulum.", "target_port": "TCP-порт TCPServerInterface Reticulum (по умолчанию 4242).", - "display_name": "Имя, анонсируемое другим узлам Reticulum для этого Home Assistant.", - "interface_name": "Метка соединения в конфигурации Reticulum." + "interface_name": "Метка соединения в конфигурации Reticulum.", + "display_name": "Имя первой LXMF-identity, анонсируемое узлам." } } }, @@ -23,39 +23,81 @@ }, "abort": { "already_configured": "Reticulum уже настроен.", - "single_instance_allowed": "Допускается только один экземпляр Reticulum." + "single_instance_allowed": "Допускается только один стек Reticulum. Добавляйте новые identity к существующему." } }, "options": { "step": { "init": { - "title": "Параметры Reticulum", + "title": "Параметры стека Reticulum", "data": { - "enable_assist": "Направлять входящие сообщения в Assist", - "assist_agent": "Агент Assist (id сущности/агента, пусто = по умолчанию)", - "assist_language": "Язык Assist (пусто = язык системы)", - "allow_all_senders": "Отвечать любому отправителю", - "allowed_identities": "Разрешённые адреса отправителей", - "default_recipient": "Адрес получателя по умолчанию (для сущности notify)", - "greeting": "Автоответ (когда Assist выключен)", - "delivery_method": "Способ доставки по умолчанию", - "announce_interval": "Интервал анонсов", - "propagation_node": "Адрес узла ретрансляции (propagation node)", - "sync_interval": "Интервал синхронизации с узлом ретрансляции", "loglevel": "Уровень логирования Reticulum (0-7)" - }, - "data_description": { - "enable_assist": "Если включено, входящие сообщения LXMF обрабатываются агентом Assist, а ответ отправляется обратно через Reticulum.", - "allowed_identities": "По одному адресу в строке (или через запятую). Используется только если «Отвечать любому отправителю» выключено.", - "default_recipient": "16-байтовый hex-адрес LXMF, на который отправляет сущность notify.reticulum.", - "greeting": "Отправляется автоматически в ответ на входящие сообщения только когда «Направлять входящие сообщения в Assist» выключено. Оставьте пустым, чтобы отключить.", - "announce_interval": "Как часто (в секундах) анонсировать наш адрес. 0 отключает периодические анонсы.", - "propagation_node": "Необязательный узел ретрансляции LXMF для доставки по принципу store-and-forward.", - "sync_interval": "Как часто (в секундах) забирать сообщения из очереди узла ретрансляции. 0 отключает." } } } }, + "config_subentries": { + "identity": { + "initiate_flow": { + "user": "Добавить identity", + "reconfigure": "Изменить identity" + }, + "entry_type": "LXMF identity", + "step": { + "user": { + "title": "Добавить LXMF identity", + "data": { + "display_name": "Имя (анонсируется узлам)", + "enable_assist": "Направлять входящие сообщения в Assist", + "assist_agent": "Агент Assist", + "assist_language": "Язык Assist", + "allow_all_senders": "Отвечать любому отправителю", + "allowed_identities": "Разрешённые адреса отправителей", + "default_recipient": "Адрес получателя по умолчанию", + "greeting": "Автоответ (когда Assist выключен)", + "delivery_method": "Способ доставки по умолчанию", + "announce_interval": "Интервал анонсов", + "propagation_node": "Адрес узла ретрансляции", + "sync_interval": "Интервал синхронизации с узлом ретрансляции" + }, + "data_description": { + "display_name": "Имя контакта, анонсируемое другим узлам Reticulum. Также используется как имя identity в Home Assistant.", + "enable_assist": "Если включено, входящие сообщения LXMF обрабатываются агентом Assist, а ответ отправляется обратно.", + "allowed_identities": "По одному адресу в строке (или через запятую). Используется только если «Отвечать любому отправителю» выключено.", + "greeting": "Отправляется автоматически в ответ на входящие сообщения только когда «Направлять входящие сообщения в Assist» выключено. Оставьте пустым, чтобы отключить.", + "default_recipient": "16-байтовый hex-адрес LXMF, на который отправляет сущность notify этой identity.", + "announce_interval": "Как часто (в секундах) анонсировать эту identity. 0 отключает периодические анонсы.", + "propagation_node": "Необязательный узел ретрансляции LXMF (store-and-forward).", + "sync_interval": "Как часто (в секундах) забирать сообщения из очереди узла ретрансляции. 0 отключает." + } + }, + "reconfigure": { + "title": "Изменить LXMF identity", + "data": { + "display_name": "Имя (анонсируется узлам)", + "enable_assist": "Направлять входящие сообщения в Assist", + "assist_agent": "Агент Assist", + "assist_language": "Язык Assist", + "allow_all_senders": "Отвечать любому отправителю", + "allowed_identities": "Разрешённые адреса отправителей", + "default_recipient": "Адрес получателя по умолчанию", + "greeting": "Автоответ (когда Assist выключен)", + "delivery_method": "Способ доставки по умолчанию", + "announce_interval": "Интервал анонсов", + "propagation_node": "Адрес узла ретрансляции", + "sync_interval": "Интервал синхронизации с узлом ретрансляции" + }, + "data_description": { + "display_name": "Имя контакта, анонсируемое другим узлам Reticulum. Изменение применится к будущим анонсам.", + "greeting": "Отправляется автоматически в ответ на входящие сообщения только когда «Направлять входящие сообщения в Assist» выключено. Оставьте пустым, чтобы отключить." + } + } + }, + "abort": { + "reconfigure_successful": "Identity обновлена." + } + } + }, "entity": { "sensor": { "lxmf_address": { "name": "Адрес LXMF" }, @@ -77,7 +119,8 @@ }, "button": { "announce": { "name": "Анонсировать" }, - "sync": { "name": "Синхронизировать узел ретрансляции" } + "sync": { "name": "Синхронизировать узел ретрансляции" }, + "regenerate": { "name": "Перегенерировать identity" } }, "notify": { "message": { "name": "Отправить сообщение" } @@ -109,17 +152,12 @@ "name": "Получатель", "description": "16-байтовый hex-хэш адреса LXMF получателя." }, - "content": { - "name": "Текст", - "description": "Тело сообщения." - }, - "title": { - "name": "Заголовок", - "description": "Необязательный заголовок/тема сообщения." - }, - "method": { - "name": "Способ", - "description": "Используемый способ доставки." + "content": { "name": "Текст", "description": "Тело сообщения." }, + "title": { "name": "Заголовок", "description": "Необязательный заголовок/тема сообщения." }, + "method": { "name": "Способ", "description": "Используемый способ доставки." }, + "identity": { + "name": "Identity", + "description": "От какой identity отправлять (имя или адрес LXMF). По умолчанию — первая identity." }, "fields": { "name": "Поля", @@ -129,7 +167,13 @@ }, "announce": { "name": "Анонсировать", - "description": "Анонсировать адрес LXMF этого Home Assistant в сети." + "description": "Анонсировать адрес LXMF identity в сети.", + "fields": { + "identity": { + "name": "Identity", + "description": "Какую identity анонсировать. По умолчанию — первая." + } + } }, "request_path": { "name": "Запросить маршрут", @@ -138,26 +182,38 @@ "destination": { "name": "Получатель", "description": "Хэш адреса, до которого нужно найти маршрут." + }, + "identity": { + "name": "Identity", + "description": "От какой identity действовать. По умолчанию — первая." } } }, "set_propagation_node": { "name": "Задать узел ретрансляции", - "description": "Задать исходящий узел ретрансляции LXMF.", + "description": "Задать исходящий узел ретрансляции LXMF для identity.", "fields": { "destination": { "name": "Получатель", "description": "Хэш адреса узла ретрансляции." + }, + "identity": { + "name": "Identity", + "description": "Какую identity настроить. По умолчанию — первая." } } }, "sync_propagation": { "name": "Синхронизировать узел ретрансляции", - "description": "Забрать сообщения из очереди настроенного узла ретрансляции.", + "description": "Забрать сообщения из очереди узла ретрансляции identity.", "fields": { "max_messages": { "name": "Макс. сообщений", "description": "Максимальное число сообщений для получения." + }, + "identity": { + "name": "Identity", + "description": "Какую identity синхронизировать. По умолчанию — первая." } } }