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 синхронизировать. По умолчанию — первая." } } }