diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a7e04f --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.claude/ +__pycache__/ +*.py[cod] +.venv/ +venv/ +.env +.DS_Store +*.egg-info/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ diff --git a/README.md b/README.md index b6aaa7c..5adcfb7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,219 @@ -# ha-reticulum +# Reticulum for Home Assistant +A Home Assistant custom integration that connects Home Assistant to a +[Reticulum](https://reticulum.network/) network over TCP and exchanges +[LXMF](https://github.com/markqvist/LXMF) messages with users. + +The headline feature: **a user can chat with the Home Assistant Assist agent +over Reticulum**. Any LXMF client (Sideband, MeshChat, Nomad Network) can send a +message to Home Assistant's address; the text is routed through the Assist +conversation pipeline and the answer is sent straight back — over LoRa, packet +radio, I2P, TCP, or anything else Reticulum runs on. + +It also exposes a `notify` entity, a rich service API, sensors, and bus events +so LXMF messaging can drive and be driven by automations. + +--- + +## How it fits together + +``` +┌────────────────────────┐ TCP ┌─────────────────────────┐ +│ Home Assistant (k8s) │ TCPClientInterface│ Neighbouring machine │ +│ │ ─────────────────► │ Reticulum instance │ +│ reticulum integration │ │ (TCPServerInterface) │ +│ • runs its own RNS │ ◄───────────────── │ + any other interfaces │ +│ • LXMF delivery dest. │ │ (LoRa / RNode / I2P …) │ +│ • Assist bridge │ └─────────────────────────┘ +└────────────────────────┘ +``` + +Home Assistant runs a **standalone, non-shared** Reticulum instance inside its +own process and reaches the wider network through the single `TCPClientInterface` +pointing at the neighbouring stack. No shared-instance socket is used, which is +what you want inside a container/pod. + +--- + +## Requirements + +- Home Assistant 2024.11 or newer. +- A reachable Reticulum instance on another machine with a `TCPServerInterface` + enabled. Example `~/.reticulum/config` on that machine: + + ``` + [interfaces] + [[TCP Server Interface]] + type = TCPServerInterface + enabled = yes + listen_ip = 0.0.0.0 + listen_port = 4242 + ``` + + In Kubernetes, expose that port to the HA pod (Service / NodePort / host + networking as appropriate). Do **not** enable `kiss_framing` on either side. + +The integration declares its Python dependencies (`rns`, `lxmf`) in +`manifest.json`; Home Assistant installs them automatically on first setup. + +--- + +## Installation + +### HACS (custom repository) +1. HACS → ⋮ → **Custom repositories**. +2. Add this repository URL, category **Integration**. +3. Install **Reticulum**, then restart Home Assistant. + +### Manual +Copy `custom_components/reticulum` into your Home Assistant `config/custom_components/` +directory and restart. + +--- + +## Configuration + +**Settings → Devices & Services → Add Integration → Reticulum.** + +| 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 | + +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: + +``` +Reticulum ready. LXMF address: a1b2c3… (Home Assistant) +``` + +Add that address as a contact in Sideband/MeshChat and send a message — Assist +will answer. + +### Options (⚙ → Configure) + +- **Route incoming messages to Assist** – turn the chat bridge on/off. +- **Assist agent / language** – pick a specific conversation agent, or leave + blank for the default pipeline. +- **Reply to any sender** / **Allowed sender addresses** – restrict who Assist + will answer. +- **Default recipient** – address used by the `notify.reticulum` entity. +- **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`. + +--- + +## Entities + +| 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_message` | Send a message to the default recipient | + +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. + +--- + +## Services + +### `reticulum.send_message` +Send to any destination. Returns the message hash. + +```yaml +action: reticulum.send_message +data: + destination: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6 + content: "The garage door is still open." + title: "Home Assistant" + method: direct # direct | opportunistic | propagated +``` + +Other services: `reticulum.announce`, `reticulum.request_path`, +`reticulum.set_propagation_node`, `reticulum.sync_propagation`. + +--- + +## 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_announce_received` — `{destination, display_name, stamp_cost, hops}` + +### Example automation — forward every inbound message to a mobile push + +```yaml +alias: Reticulum → phone +trigger: + - platform: event + event_type: reticulum_message_received +action: + - action: notify.mobile_app_phone + data: + title: "Reticulum: {{ trigger.event.data.source[:8] }}" + message: "{{ trigger.event.data.content }}" +``` + +### Device triggers (automation UI) + +Instead of raw event triggers you can pick these from the automation editor +under the Reticulum device ("When… → Reticulum → …"): + +- **Message received** +- **Message delivered** +- **Message delivery failed** +- **Announce received (peer heard)** + +Each wraps the matching bus event, so `trigger.event.data` carries the same +payload described above. + +### Example automation — alert over Reticulum when a door opens + +```yaml +alias: Door alert over Reticulum +trigger: + - platform: state + entity_id: binary_sensor.front_door + to: "on" +action: + - action: reticulum.send_message + data: + destination: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6 + content: "Front door opened at {{ now().strftime('%H:%M') }}" +``` + +--- + +## 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, changing the host/port or fully restarting the stack requires + restarting Home Assistant. 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. +- Assist replies use the standard conversation pipeline, so whichever agent you + select (built-in intents, a local LLM, a cloud LLM, …) is what answers. + +## License +MIT diff --git a/custom_components/reticulum/__init__.py b/custom_components/reticulum/__init__.py new file mode 100644 index 0000000..7ef57ad --- /dev/null +++ b/custom_components/reticulum/__init__.py @@ -0,0 +1,400 @@ +"""The Reticulum integration.""" + +from __future__ import annotations + +import logging +from datetime import timedelta +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.event import async_track_time_interval + +from .const import ( + ATTR_CONTENT, + ATTR_DESTINATION, + ATTR_FIELDS, + ATTR_MAX_MESSAGES, + ATTR_METHOD, + ATTR_TITLE, + CONF_ALLOW_ALL, + CONF_ALLOWED_IDENTITIES, + CONF_ANNOUNCE_INTERVAL, + CONF_ASSIST_AGENT, + CONF_ASSIST_LANGUAGE, + CONF_DISPLAY_NAME, + CONF_ENABLE_ASSIST, + CONF_GREETING, + CONF_INTERFACE_NAME, + CONF_LOGLEVEL, + CONF_PROPAGATION_NODE, + CONF_SYNC_INTERVAL, + CONF_TARGET_HOST, + CONF_TARGET_PORT, + DEFAULT_ANNOUNCE_INTERVAL, + DEFAULT_DISPLAY_NAME, + DEFAULT_INTERFACE_NAME, + DEFAULT_LOGLEVEL, + DEFAULT_SYNC_INTERVAL, + DELIVERY_METHODS, + DOMAIN, + EVENT_MESSAGE_RECEIVED, + SERVICE_ANNOUNCE, + SERVICE_REQUEST_PATH, + SERVICE_SEND_MESSAGE, + SERVICE_SET_PROPAGATION_NODE, + SERVICE_SYNC_PROPAGATION, + STORAGE_SUBDIR, +) +from .reticulum_client import ReticulumError, ReticulumManager + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.NOTIFY, + 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.""" + + def __init__(self, manager: ReticulumManager) -> None: + self.manager = manager + 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. + 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 + storage_dir = hass.config.path(STORAGE_SUBDIR) + + manager = ReticulumManager( + hass, + storage_dir, + 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)), + ) + + try: + await manager.async_start() + except Exception as err: # noqa: BLE001 + raise ConfigEntryNotReady(f"Could not start Reticulum: {err}") from err + + runtime = RuntimeData(manager) + 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) + + 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) + + # Poll interface status. + runtime.unsubs.append( + async_track_time_interval( + hass, lambda _now: manager.refresh_interface_status(), INTERFACE_POLL + ) + ) + + entry.async_on_unload(entry.add_update_listener(_async_options_updated)) + _async_register_services(hass) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config 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() + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id, None) + if not hass.data[DOMAIN]: + _async_unregister_services(hass) + return unload_ok + + +async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload the entry so option changes take effect.""" + await hass.config_entries.async_reload(entry.entry_id) + + +# --------------------------------------------------------------------------- +# Periodic tasks +# --------------------------------------------------------------------------- +def _schedule_periodic( + hass: HomeAssistant, entry: ConfigEntry, runtime: RuntimeData +) -> None: + manager = runtime.manager + announce_interval = entry.options.get( + CONF_ANNOUNCE_INTERVAL, DEFAULT_ANNOUNCE_INTERVAL + ) + if announce_interval and announce_interval > 0: + + async def _do_announce(_now) -> None: + await _safe_announce(manager) + + runtime.unsubs.append( + async_track_time_interval( + hass, _do_announce, timedelta(seconds=announce_interval) + ) + ) + + sync_interval = entry.options.get(CONF_SYNC_INTERVAL, DEFAULT_SYNC_INTERVAL) + propagation_node = entry.options.get(CONF_PROPAGATION_NODE) + if propagation_node: + hass.async_create_task(_set_propagation(manager, propagation_node)) + if sync_interval and sync_interval > 0: + + async def _do_sync(_now) -> None: + try: + await manager.async_sync_propagation() + except ReticulumError as err: + _LOGGER.warning("Propagation sync failed: %s", err) + + runtime.unsubs.append( + async_track_time_interval( + hass, _do_sync, timedelta(seconds=sync_interval) + ) + ) + + +async def _set_propagation(manager: ReticulumManager, 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: + try: + await manager.async_announce() + except ReticulumError as err: + _LOGGER.debug("Announce skipped: %s", err) + + +# --------------------------------------------------------------------------- +# Incoming message pipeline +# --------------------------------------------------------------------------- +def _make_incoming_handler( + hass: HomeAssistant, entry: ConfigEntry, runtime: RuntimeData +): + 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) + allowed = { + a.strip().lower() + for a in options.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 + + if not options.get(CONF_ENABLE_ASSIST, True): + return + + text = (payload.get("content") or "").strip() + if not text: + return + + await _run_assist(hass, entry, runtime, source, text) + + return _handle + + +async def _run_assist( + hass: HomeAssistant, + entry: ConfigEntry, + runtime: RuntimeData, + 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 + + try: + result = await conversation.async_converse( + hass=hass, + text=text, + conversation_id=runtime.conversation_ids.get(source), + 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") + 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 + + reply = _extract_speech(result) + if reply: + await _reply(runtime.manager, source, reply, "Assist") + + +def _extract_speech(result: Any) -> str | None: + try: + speech = result.response.speech + if isinstance(speech, dict): + return speech.get("plain", {}).get("speech") + except AttributeError: + return None + return None + + +async def _reply( + manager: ReticulumManager, 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) + + +# --------------------------------------------------------------------------- +# Services +# --------------------------------------------------------------------------- +SEND_MESSAGE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DESTINATION): cv.string, + vol.Required(ATTR_CONTENT): cv.string, + vol.Optional(ATTR_TITLE, default=""): cv.string, + vol.Optional(ATTR_METHOD, default="direct"): vol.In(DELIVERY_METHODS), + vol.Optional(ATTR_FIELDS): dict, + } +) +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))} +) + + +def _first_manager(hass: HomeAssistant) -> ReticulumManager: + data: dict[str, RuntimeData] = hass.data.get(DOMAIN, {}) + if not data: + raise HomeAssistantError("Reticulum is not configured") + return next(iter(data.values())).manager + + +def _async_register_services(hass: HomeAssistant) -> None: + if hass.services.has_service(DOMAIN, SERVICE_SEND_MESSAGE): + return + + async def _send(call: ServiceCall) -> ServiceResponse: + manager = _first_manager(hass) + try: + message_hash = await manager.async_send_message( + call.data[ATTR_DESTINATION], + call.data[ATTR_CONTENT], + title=call.data.get(ATTR_TITLE, ""), + method=call.data.get(ATTR_METHOD, "direct"), + fields=call.data.get(ATTR_FIELDS), + ) + except ReticulumError as err: + raise HomeAssistantError(str(err)) from err + return {"message_hash": message_hash} + + async def _announce(call: ServiceCall) -> None: + try: + await _first_manager(hass).async_announce() + except ReticulumError as err: + raise HomeAssistantError(str(err)) from err + + async def _request_path(call: ServiceCall) -> None: + try: + await _first_manager(hass).async_request_path(call.data[ATTR_DESTINATION]) + except ReticulumError as err: + raise HomeAssistantError(str(err)) from err + + async def _set_propagation(call: ServiceCall) -> None: + try: + await _first_manager(hass).async_set_propagation_node( + call.data[ATTR_DESTINATION] + ) + except ReticulumError as err: + raise HomeAssistantError(str(err)) from err + + async def _sync(call: ServiceCall) -> None: + try: + await _first_manager(hass).async_sync_propagation( + call.data.get(ATTR_MAX_MESSAGES) + ) + except ReticulumError as err: + raise HomeAssistantError(str(err)) from err + + hass.services.async_register( + DOMAIN, + SERVICE_SEND_MESSAGE, + _send, + schema=SEND_MESSAGE_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) + hass.services.async_register(DOMAIN, SERVICE_ANNOUNCE, _announce) + hass.services.async_register( + DOMAIN, SERVICE_REQUEST_PATH, _request_path, schema=REQUEST_PATH_SCHEMA + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_PROPAGATION_NODE, + _set_propagation, + schema=SET_PROPAGATION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, SERVICE_SYNC_PROPAGATION, _sync, schema=SYNC_SCHEMA + ) + + +def _async_unregister_services(hass: HomeAssistant) -> None: + for service in ( + SERVICE_SEND_MESSAGE, + SERVICE_ANNOUNCE, + SERVICE_REQUEST_PATH, + SERVICE_SET_PROPAGATION_NODE, + SERVICE_SYNC_PROPAGATION, + ): + if hass.services.has_service(DOMAIN, service): + hass.services.async_remove(DOMAIN, service) diff --git a/custom_components/reticulum/binary_sensor.py b/custom_components/reticulum/binary_sensor.py new file mode 100644 index 0000000..70effd8 --- /dev/null +++ b/custom_components/reticulum/binary_sensor.py @@ -0,0 +1,40 @@ +"""Binary sensor platform for Reticulum.""" + +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +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 ReticulumManager + + +async def async_setup_entry( + hass: HomeAssistant, + 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)]) + + +class ReticulumConnectivity(ReticulumEntity, BinarySensorEntity): + """Reports 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" + + @property + def is_on(self) -> bool: + return self._manager.state.interface_online diff --git a/custom_components/reticulum/button.py b/custom_components/reticulum/button.py new file mode 100644 index 0000000..87ab7c8 --- /dev/null +++ b/custom_components/reticulum/button.py @@ -0,0 +1,65 @@ +"""Button platform for Reticulum.""" + +from __future__ import annotations + +import logging + +from homeassistant.components.button import 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 + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + 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), + ] + ) + + +class ReticulumAnnounceButton(ReticulumEntity, ButtonEntity): + """Announce our LXMF destination on demand.""" + + _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" + + async def async_press(self) -> None: + try: + await self._manager.async_announce() + except ReticulumError as err: + _LOGGER.warning("Announce failed: %s", err) + + +class ReticulumSyncButton(ReticulumEntity, ButtonEntity): + """Pull queued messages from the configured 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" + + async def async_press(self) -> None: + try: + await self._manager.async_sync_propagation() + except ReticulumError as err: + _LOGGER.warning("Propagation sync failed: %s", err) diff --git a/custom_components/reticulum/config_flow.py b/custom_components/reticulum/config_flow.py new file mode 100644 index 0000000..b4e58eb --- /dev/null +++ b/custom_components/reticulum/config_flow.py @@ -0,0 +1,245 @@ +"""Config flow for the Reticulum integration.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import ( + BooleanSelector, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, + TextSelectorConfig, +) + +from .const import ( + CONF_ALLOW_ALL, + CONF_ALLOWED_IDENTITIES, + CONF_ANNOUNCE_INTERVAL, + CONF_ASSIST_AGENT, + CONF_ASSIST_LANGUAGE, + CONF_DEFAULT_RECIPIENT, + CONF_DELIVERY_METHOD, + CONF_DISPLAY_NAME, + CONF_ENABLE_ASSIST, + CONF_GREETING, + CONF_INTERFACE_NAME, + CONF_LOGLEVEL, + CONF_PROPAGATION_NODE, + CONF_SYNC_INTERVAL, + CONF_TARGET_HOST, + CONF_TARGET_PORT, + DEFAULT_ANNOUNCE_INTERVAL, + DEFAULT_DELIVERY_METHOD, + DEFAULT_DISPLAY_NAME, + DEFAULT_INTERFACE_NAME, + DEFAULT_LOGLEVEL, + DEFAULT_SYNC_INTERVAL, + DEFAULT_TARGET_PORT, + DELIVERY_METHODS, + DOMAIN, +) + + +async def _test_connection(host: str, port: int) -> None: + """Verify the Reticulum TCP server is reachable. Raises on failure.""" + try: + _, writer = await asyncio.wait_for( + asyncio.open_connection(host, port), timeout=10 + ) + writer.close() + try: + await writer.wait_closed() + except Exception: # noqa: BLE001 + pass + except (OSError, asyncio.TimeoutError) as err: + 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, + } +) + + +class ReticulumConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Reticulum.""" + + 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 + errors: dict[str, str] = {} + + if user_input is not None: + try: + await _test_connection( + user_input[CONF_TARGET_HOST], user_input[CONF_TARGET_PORT] + ) + except CannotConnect: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(DOMAIN) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"Reticulum ({user_input[CONF_DISPLAY_NAME]})", + data=user_input, + options=_default_options(), + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_SCHEMA, errors=errors + ) + + @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, + } + + +class ReticulumOptionsFlow(OptionsFlow): + """Handle Reticulum options.""" + + 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, "") + }, + ): TextSelector(), + vol.Optional( + CONF_ASSIST_LANGUAGE, + description={ + "suggested_value": opts.get(CONF_ASSIST_LANGUAGE, "") + }, + ): TextSelector(), + 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( + NumberSelectorConfig( + min=0, max=7, step=1, mode=NumberSelectorMode.SLIDER + ) + ), + } + ) + return self.async_show_form(step_id="init", data_schema=schema) diff --git a/custom_components/reticulum/const.py b/custom_components/reticulum/const.py new file mode 100644 index 0000000..a52dcc3 --- /dev/null +++ b/custom_components/reticulum/const.py @@ -0,0 +1,87 @@ +"""Constants for the Reticulum integration.""" + +from __future__ import annotations + +from typing import Final + +DOMAIN: Final = "reticulum" + +# --------------------------------------------------------------------------- +# Configuration keys +# --------------------------------------------------------------------------- +CONF_TARGET_HOST: Final = "target_host" +CONF_TARGET_PORT: Final = "target_port" +CONF_INTERFACE_NAME: Final = "interface_name" +CONF_DISPLAY_NAME: Final = "display_name" + +# Options +CONF_ENABLE_ASSIST: Final = "enable_assist" +CONF_ASSIST_AGENT: Final = "assist_agent" +CONF_ASSIST_LANGUAGE: Final = "assist_language" +CONF_ALLOWED_IDENTITIES: Final = "allowed_identities" +CONF_ALLOW_ALL: Final = "allow_all_senders" +CONF_DEFAULT_RECIPIENT: Final = "default_recipient" +CONF_ANNOUNCE_INTERVAL: Final = "announce_interval" +CONF_PROPAGATION_NODE: Final = "propagation_node" +CONF_SYNC_INTERVAL: Final = "sync_interval" +CONF_DELIVERY_METHOD: Final = "delivery_method" +CONF_STAMP_COST: Final = "stamp_cost" +CONF_LOGLEVEL: Final = "loglevel" +CONF_GREETING: Final = "greeting" + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- +DEFAULT_INTERFACE_NAME: Final = "HA TCP Client" +DEFAULT_DISPLAY_NAME: Final = "Home Assistant" +DEFAULT_TARGET_PORT: Final = 4242 +DEFAULT_ANNOUNCE_INTERVAL: Final = 1800 # seconds; 0 disables periodic announce +DEFAULT_SYNC_INTERVAL: Final = 0 # seconds; 0 disables propagation-node sync +DEFAULT_DELIVERY_METHOD: Final = "direct" +DEFAULT_STAMP_COST: Final = 0 +DEFAULT_LOGLEVEL: Final = 3 # RNS.LOG_NOTICE + +DELIVERY_METHODS: Final = ["direct", "opportunistic", "propagated"] + +# --------------------------------------------------------------------------- +# Storage +# --------------------------------------------------------------------------- +STORAGE_SUBDIR: Final = "reticulum" +IDENTITY_FILENAME: Final = "identity" +CONFIG_FILENAME: Final = "config" +ATTACHMENTS_SUBDIR: Final = "attachments" + +# --------------------------------------------------------------------------- +# Dispatcher signals +# --------------------------------------------------------------------------- +SIGNAL_STATE_UPDATED: Final = f"{DOMAIN}_state_updated" + +# --------------------------------------------------------------------------- +# Events +# --------------------------------------------------------------------------- +EVENT_MESSAGE_RECEIVED: Final = f"{DOMAIN}_message_received" +EVENT_MESSAGE_DELIVERED: Final = f"{DOMAIN}_message_delivered" +EVENT_MESSAGE_FAILED: Final = f"{DOMAIN}_message_failed" +EVENT_ANNOUNCE_RECEIVED: Final = f"{DOMAIN}_announce_received" + +# --------------------------------------------------------------------------- +# Services +# --------------------------------------------------------------------------- +SERVICE_SEND_MESSAGE: Final = "send_message" +SERVICE_ANNOUNCE: Final = "announce" +SERVICE_REQUEST_PATH: Final = "request_path" +SERVICE_SYNC_PROPAGATION: Final = "sync_propagation" +SERVICE_SET_PROPAGATION_NODE: Final = "set_propagation_node" + +ATTR_DESTINATION: Final = "destination" +ATTR_CONTENT: Final = "content" +ATTR_TITLE: Final = "title" +ATTR_METHOD: Final = "method" +ATTR_FIELDS: Final = "fields" +ATTR_MAX_MESSAGES: Final = "max_messages" + +# --------------------------------------------------------------------------- +# LXMF address geometry +# --------------------------------------------------------------------------- +# Reticulum truncated destination hashes are 16 bytes -> 32 hex chars. +DEST_HASH_LEN: Final = 32 diff --git a/custom_components/reticulum/device_trigger.py b/custom_components/reticulum/device_trigger.py new file mode 100644 index 0000000..ff3f94c --- /dev/null +++ b/custom_components/reticulum/device_trigger.py @@ -0,0 +1,82 @@ +"""Device triggers for Reticulum. + +Exposes the integration's bus events as device triggers so they can be picked +from the automation UI ("Do something when a Reticulum device …"). +""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.homeassistant.triggers import event as event_trigger +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from .const import ( + DOMAIN, + EVENT_ANNOUNCE_RECEIVED, + EVENT_MESSAGE_DELIVERED, + EVENT_MESSAGE_FAILED, + EVENT_MESSAGE_RECEIVED, +) + +TRIGGER_MESSAGE_RECEIVED = "message_received" +TRIGGER_MESSAGE_DELIVERED = "message_delivered" +TRIGGER_MESSAGE_FAILED = "message_failed" +TRIGGER_ANNOUNCE_RECEIVED = "announce_received" + +_EVENT_FOR_TYPE: dict[str, str] = { + TRIGGER_MESSAGE_RECEIVED: EVENT_MESSAGE_RECEIVED, + TRIGGER_MESSAGE_DELIVERED: EVENT_MESSAGE_DELIVERED, + TRIGGER_MESSAGE_FAILED: EVENT_MESSAGE_FAILED, + TRIGGER_ANNOUNCE_RECEIVED: EVENT_ANNOUNCE_RECEIVED, +} + +TRIGGER_TYPES = set(_EVENT_FOR_TYPE) + +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), + } +) + + +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: + """List device triggers for a Reticulum device.""" + return [ + { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device_id, + CONF_TYPE: trigger_type, + } + for trigger_type in TRIGGER_TYPES + ] + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Attach a device trigger by wrapping the corresponding bus event.""" + event_config = event_trigger.TRIGGER_SCHEMA( + { + event_trigger.CONF_PLATFORM: "event", + event_trigger.CONF_EVENT_TYPE: _EVENT_FOR_TYPE[config[CONF_TYPE]], + } + ) + return await event_trigger.async_attach_trigger( + hass, event_config, action, trigger_info, platform_type="device" + ) diff --git a/custom_components/reticulum/diagnostics.py b/custom_components/reticulum/diagnostics.py new file mode 100644 index 0000000..30a4bfb --- /dev/null +++ b/custom_components/reticulum/diagnostics.py @@ -0,0 +1,41 @@ +"""Diagnostics support for Reticulum.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import CONF_ALLOWED_IDENTITIES, CONF_DEFAULT_RECIPIENT, DOMAIN + +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.""" + runtime = hass.data[DOMAIN][entry.entry_id] + state = runtime.manager.state + 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), + "peers": [ + {"name": p.display_name, "hops": p.hops, "stamp_cost": p.stamp_cost} + for p in state.peers.values() + ], + }, + } diff --git a/custom_components/reticulum/entity.py b/custom_components/reticulum/entity.py new file mode 100644 index 0000000..fd06f1a --- /dev/null +++ b/custom_components/reticulum/entity.py @@ -0,0 +1,39 @@ +"""Base entity for the Reticulum integration.""" + +from __future__ import annotations + +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import DOMAIN, SIGNAL_STATE_UPDATED +from .reticulum_client import ReticulumManager + + +class ReticulumEntity(Entity): + """Base class wiring entities to the manager state updates.""" + + _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 + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + name="Reticulum", + manufacturer="Reticulum Network Stack", + model="LXMF Peer", + sw_version=manager.state.lxmf_address or None, + 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 + ) + ) + + def _handle_update(self) -> None: + self.async_write_ha_state() diff --git a/custom_components/reticulum/manifest.json b/custom_components/reticulum/manifest.json new file mode 100644 index 0000000..628c632 --- /dev/null +++ b/custom_components/reticulum/manifest.json @@ -0,0 +1,16 @@ +{ + "domain": "reticulum", + "name": "Reticulum", + "codeowners": [], + "config_flow": true, + "dependencies": [], + "after_dependencies": ["conversation"], + "documentation": "https://github.com/tewris/ha-reticulum", + "integration_type": "hub", + "iot_class": "local_push", + "issue_tracker": "https://github.com/tewris/ha-reticulum/issues", + "loggers": ["RNS", "LXMF"], + "requirements": ["rns>=0.9.0", "lxmf>=0.6.0"], + "single_config_entry": true, + "version": "1.0.0" +} diff --git a/custom_components/reticulum/notify.py b/custom_components/reticulum/notify.py new file mode 100644 index 0000000..4714cac --- /dev/null +++ b/custom_components/reticulum/notify.py @@ -0,0 +1,57 @@ +"""Notify platform for Reticulum. + +Exposes a ``notify`` entity that sends an LXMF message to the configured +default recipient. For arbitrary destinations use the ``reticulum.send_message`` +service instead. +""" + +from __future__ import annotations + +from homeassistant.components.notify import NotifyEntity, NotifyEntityFeature +from homeassistant.config_entries import ConfigEntry +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 + + +async def async_setup_entry( + hass: HomeAssistant, + 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)]) + + +class ReticulumNotify(ReticulumEntity, NotifyEntity): + """Send LXMF messages to the 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" + + async def async_send_message(self, message: str, title: str | None = None) -> None: + recipient = self._entry.options.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 " + "with an explicit destination." + ) + method = self._entry.options.get(CONF_DELIVERY_METHOD, DEFAULT_DELIVERY_METHOD) + try: + await self._manager.async_send_message( + recipient, message, title=title or "", method=method + ) + except ReticulumError as err: + raise ServiceValidationError(str(err)) from err diff --git a/custom_components/reticulum/reticulum_client.py b/custom_components/reticulum/reticulum_client.py new file mode 100644 index 0000000..f24de5f --- /dev/null +++ b/custom_components/reticulum/reticulum_client.py @@ -0,0 +1,647 @@ +"""Reticulum / LXMF client manager for Home Assistant. + +This module isolates all interaction with the (blocking, thread-based) +Reticulum Network Stack (``RNS``) and the LXMF messaging layer, and bridges +their callbacks (which fire on RNS-owned threads) back onto the Home Assistant +event loop. + +The neighbouring machine runs a full Reticulum instance exposing a +``TCPServerInterface``. We connect out to it with a ``TCPClientInterface`` and +run a standalone (non-shared) RNS instance inside the Home Assistant process. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import time +from collections import deque +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Callable + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + ATTACHMENTS_SUBDIR, + CONFIG_FILENAME, + DEST_HASH_LEN, + IDENTITY_FILENAME, + SIGNAL_STATE_UPDATED, +) + +if TYPE_CHECKING: + import LXMF + import RNS + +_LOGGER = logging.getLogger(__name__) + +# A process may only ever hold a single RNS.Reticulum instance. Home Assistant +# is a single process, so we guard against re-initialisation across config-entry +# reloads by caching the running instance module-side. +_RNS_INSTANCE: Any = None + +# How long (seconds) to wait for a path/identity to resolve before giving up on +# an outbound message. +PATH_RESOLVE_TIMEOUT = 15.0 + +# Ring buffer size for recently seen peers / messages surfaced as attributes. +MAX_TRACKED_PEERS = 200 +MAX_RECENT_MESSAGES = 25 + + +@dataclass(slots=True) +class Peer: + """A Reticulum LXMF peer we have heard announce.""" + + destination_hash: str + display_name: str | None + stamp_cost: int | None + last_heard: float + hops: int | None = None + + +@dataclass(slots=True) +class ManagerState: + """Mutable state surfaced to entities and diagnostics.""" + + started: bool = False + interface_online: bool = False + lxmf_address: str | None = None + display_name: str | None = None + messages_received: int = 0 + messages_sent: int = 0 + messages_failed: int = 0 + # Interface telemetry (populated by refresh_interface_status). + tel_rxb: int | None = None + tel_txb: int | None = None + tel_bitrate: float | None = None + tel_rssi: float | None = None + tel_snr: float | None = None + tel_quality: float | None = None + last_message: str | None = None + last_message_source: str | None = None + last_message_title: str | None = None + last_message_time: float | None = None + peers: dict[str, Peer] = field(default_factory=dict) + recent_messages: deque = field(default_factory=lambda: deque(maxlen=MAX_RECENT_MESSAGES)) + + +class ReticulumError(Exception): + """Raised for recoverable Reticulum operations (surfaced to the UI).""" + + +class ReticulumManager: + """Owns the RNS instance, the LXMF router and all bridging logic.""" + + def __init__( + self, + hass: HomeAssistant, + storage_dir: str, + *, + target_host: str, + target_port: int, + interface_name: str, + display_name: str, + loglevel: int, + ) -> None: + self.hass = hass + self.storage_dir = storage_dir + self.target_host = target_host + self.target_port = target_port + self.interface_name = interface_name + self.display_name = display_name + self.loglevel = loglevel + + self.state = ManagerState(display_name=display_name) + + # Populated in _start (executor). + self._rns: Any = None + self._router: Any = None + self._identity: Any = None + self._local_destination: Any = None + self._announce_handler: Any = None + + # Incoming-message handler installed by __init__.py once options are + # known (assist bridge, allow-list, …). + self.incoming_handler: Callable[[dict[str, Any]], Any] | None = None + + self._lock = asyncio.Lock() + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + async def async_start(self) -> None: + """Initialise RNS + LXMF (runs the blocking parts in an executor).""" + async with self._lock: + await self.hass.async_add_executor_job(self._start) + self._push_state() + + def _start(self) -> None: + """Blocking init. Runs in the executor thread.""" + global _RNS_INSTANCE # noqa: PLW0603 + import LXMF # noqa: PLC0415 + import RNS # noqa: PLC0415 + + os.makedirs(self.storage_dir, exist_ok=True) + os.makedirs(os.path.join(self.storage_dir, ATTACHMENTS_SUBDIR), exist_ok=True) + self._write_config_file() + + if _RNS_INSTANCE is None: + _LOGGER.debug("Initialising Reticulum instance at %s", self.storage_dir) + _RNS_INSTANCE = RNS.Reticulum( + configdir=self.storage_dir, loglevel=self.loglevel + ) + else: + _LOGGER.debug("Reusing existing Reticulum instance") + self._rns = _RNS_INSTANCE + + # Stable identity so our LXMF address survives restarts. + identity_path = os.path.join(self.storage_dir, IDENTITY_FILENAME) + if os.path.isfile(identity_path): + self._identity = RNS.Identity.from_file(identity_path) + if self._identity is None: + self._identity = RNS.Identity() + self._identity.to_file(identity_path) + + self._router = LXMF.LXMRouter( + storagepath=os.path.join(self.storage_dir, "lxmf"), + ) + self._local_destination = self._router.register_delivery_identity( + self._identity, display_name=self.display_name + ) + self._router.register_delivery_callback(self._delivery_callback) + + # Discover peers via their LXMF delivery announces. + self._announce_handler = _AnnounceHandler(self) + RNS.Transport.register_announce_handler(self._announce_handler) + + self.state.lxmf_address = RNS.hexrep( + self._local_destination.hash, delimit=False + ) + self.state.started = True + _LOGGER.info( + "Reticulum ready. LXMF address: %s (%s)", + self.state.lxmf_address, + self.display_name, + ) + + def _write_config_file(self) -> None: + """Write a minimal RNS config with only our TCP client interface.""" + config_path = os.path.join(self.storage_dir, CONFIG_FILENAME) + contents = ( + "[reticulum]\n" + " enable_transport = False\n" + " share_instance = No\n" + " panic_on_interface_error = No\n\n" + "[logging]\n" + f" loglevel = {self.loglevel}\n\n" + "[interfaces]\n" + f" [[{self.interface_name}]]\n" + " type = TCPClientInterface\n" + " enabled = yes\n" + f" target_host = {self.target_host}\n" + f" target_port = {self.target_port}\n" + ) + with open(config_path, "w", encoding="utf-8") as handle: + handle.write(contents) + + async def async_stop(self) -> None: + """Detach our callbacks. The RNS instance itself lives for the process. + + RNS/LXMF do not support a clean per-entry teardown inside a running + process, so a full stack restart requires restarting Home Assistant. + We at least stop delivering into a torn-down entry. + """ + self.incoming_handler = None + if self._router is not None: + try: + self._router.register_delivery_callback(lambda _msg: None) + except Exception: # noqa: BLE001 - best effort + _LOGGER.debug("Could not reset delivery callback", exc_info=True) + if self._announce_handler is not None: + try: + import RNS # noqa: PLC0415 + + RNS.Transport.deregister_announce_handler(self._announce_handler) + except Exception: # noqa: BLE001 - best effort / older RNS + _LOGGER.debug("Could not deregister announce handler", exc_info=True) + self.state.started = False + + # ------------------------------------------------------------------ + # Outbound + # ------------------------------------------------------------------ + async def async_send_message( + self, + destination: str, + content: str, + title: str = "", + method: str = "direct", + fields: dict | None = None, + ) -> str: + """Send an LXMF message. Returns the message hash (hex).""" + dest_hash = self._normalise_hash(destination) + return await self.hass.async_add_executor_job( + self._send, dest_hash, content, title, method, fields + ) + + def _send( + self, + dest_hash: bytes, + content: str, + title: str, + method: str, + fields: dict | None, + ) -> str: + import LXMF # noqa: PLC0415 + import RNS # noqa: PLC0415 + + recipient_identity = self._resolve_identity(dest_hash) + if recipient_identity is None: + raise ReticulumError( + f"Could not resolve a path/identity for " + f"{RNS.prettyhexrep(dest_hash)} within {PATH_RESOLVE_TIMEOUT:.0f}s" + ) + + dest = RNS.Destination( + recipient_identity, + RNS.Destination.OUT, + RNS.Destination.SINGLE, + "lxmf", + "delivery", + ) + desired = { + "direct": LXMF.LXMessage.DIRECT, + "opportunistic": LXMF.LXMessage.OPPORTUNISTIC, + "propagated": LXMF.LXMessage.PROPAGATED, + }.get(method, LXMF.LXMessage.DIRECT) + + lxm = LXMF.LXMessage( + dest, + self._local_destination, + content, + title, + desired_method=desired, + ) + if fields: + lxm.fields = fields + + lxm.register_delivery_callback(self._outbound_delivered) + lxm.register_failed_callback(self._outbound_failed) + + self._router.handle_outbound(lxm) + _LOGGER.debug( + "Queued LXMF message to %s via %s", RNS.prettyhexrep(dest_hash), method + ) + return RNS.hexrep(lxm.hash, delimit=False) + + def _resolve_identity(self, dest_hash: bytes) -> Any: + """Recall (and if needed, request a path for) a destination identity.""" + import RNS # noqa: PLC0415 + + identity = RNS.Identity.recall(dest_hash) + if identity is not None: + return identity + + if not RNS.Transport.has_path(dest_hash): + RNS.Transport.request_path(dest_hash) + + deadline = time.monotonic() + PATH_RESOLVE_TIMEOUT + while time.monotonic() < deadline: + identity = RNS.Identity.recall(dest_hash) + if identity is not None: + return identity + time.sleep(0.25) + return RNS.Identity.recall(dest_hash) + + async def async_announce(self) -> None: + """Announce our LXMF delivery destination on the network.""" + if self._router is None or self._local_destination is None: + raise ReticulumError("Reticulum is not started") + await self.hass.async_add_executor_job( + self._router.announce, self._local_destination.hash + ) + _LOGGER.debug("Announced LXMF destination %s", self.state.lxmf_address) + + async def async_request_path(self, destination: str) -> None: + """Request a network path to a destination.""" + dest_hash = self._normalise_hash(destination) + + def _request() -> None: + import RNS # noqa: PLC0415 + + RNS.Transport.request_path(dest_hash) + + await self.hass.async_add_executor_job(_request) + + async def async_set_propagation_node(self, destination: str) -> None: + """Set the outbound LXMF propagation (store-and-forward) node.""" + dest_hash = self._normalise_hash(destination) + await self.hass.async_add_executor_job( + self._router.set_outbound_propagation_node, dest_hash + ) + + async def async_sync_propagation(self, max_messages: int | None = None) -> None: + """Pull any queued messages from the configured propagation node.""" + + def _sync() -> None: + if max_messages is None: + self._router.request_messages_from_propagation_node(self._identity) + else: + self._router.request_messages_from_propagation_node( + self._identity, max_messages + ) + + await self.hass.async_add_executor_job(_sync) + + # ------------------------------------------------------------------ + # Inbound (RNS thread -> HA loop bridging) + # ------------------------------------------------------------------ + def _delivery_callback(self, message: Any) -> None: + """LXMF delivery callback. Runs on an RNS-owned thread.""" + import RNS # noqa: PLC0415 + + try: + source_hash = RNS.hexrep(message.source_hash, delimit=False) + payload: dict[str, Any] = { + "content": message.content_as_string() or "", + "title": message.title_as_string() or "", + "source": source_hash, + "destination": RNS.hexrep(message.destination_hash, delimit=False), + "timestamp": getattr(message, "timestamp", None), + "signature_validated": getattr(message, "signature_validated", None), + "fields": dict(getattr(message, "fields", {}) or {}), + } + except Exception: # noqa: BLE001 - never let a bad message kill the thread + _LOGGER.exception("Failed to parse inbound LXMF message") + return + + # Hop back onto the event loop thread. + self.hass.loop.call_soon_threadsafe(self._dispatch_incoming, payload) + + @callback + def _dispatch_incoming(self, payload: dict[str, Any]) -> None: + """Runs on the event loop: update state, hand off to handler.""" + self.state.messages_received += 1 + self.state.last_message = payload["content"] + self.state.last_message_source = payload["source"] + self.state.last_message_title = payload["title"] + self.state.last_message_time = payload.get("timestamp") or time.time() + self.state.recent_messages.appendleft( + { + "source": payload["source"], + "title": payload["title"], + "content": payload["content"], + "time": self.state.last_message_time, + } + ) + self._push_state() + + if self.incoming_handler is not None: + self.hass.async_create_task( + _maybe_await(self.incoming_handler, payload), + name="reticulum_incoming", + ) + + # ------------------------------------------------------------------ + # Outbound delivery status (RNS thread) + # ------------------------------------------------------------------ + def _outbound_delivered(self, message: Any) -> None: + self.hass.loop.call_soon_threadsafe(self._on_outbound_delivered, message) + + def _outbound_failed(self, message: Any) -> None: + self.hass.loop.call_soon_threadsafe(self._on_outbound_failed, message) + + @callback + def _on_outbound_delivered(self, message: Any) -> None: + import RNS # noqa: PLC0415 + + self.state.messages_sent += 1 + self._push_state() + _fire_event( + self.hass, + "delivered", + { + "message_hash": RNS.hexrep(message.hash, delimit=False), + "destination": RNS.hexrep(message.destination_hash, delimit=False), + }, + ) + + @callback + def _on_outbound_failed(self, message: Any) -> None: + import RNS # noqa: PLC0415 + + self.state.messages_failed += 1 + self._push_state() + _fire_event( + self.hass, + "failed", + { + "message_hash": RNS.hexrep(message.hash, delimit=False), + "destination": RNS.hexrep(message.destination_hash, delimit=False), + }, + ) + + # ------------------------------------------------------------------ + # Peer tracking (called from announce handler on RNS thread) + # ------------------------------------------------------------------ + def _register_peer( + self, + destination_hash: str, + display_name: str | None, + stamp_cost: int | None, + hops: int | None, + ) -> None: + self.hass.loop.call_soon_threadsafe( + self._store_peer, destination_hash, display_name, stamp_cost, hops + ) + + @callback + def _store_peer( + self, + destination_hash: str, + display_name: str | None, + stamp_cost: int | None, + hops: int | None, + ) -> None: + peer = Peer( + destination_hash=destination_hash, + display_name=display_name, + stamp_cost=stamp_cost, + last_heard=time.time(), + hops=hops, + ) + self.state.peers[destination_hash] = peer + # Keep the peer table bounded. + if len(self.state.peers) > MAX_TRACKED_PEERS: + oldest = min(self.state.peers.values(), key=lambda p: p.last_heard) + self.state.peers.pop(oldest.destination_hash, None) + self._push_state() + _fire_event( + self.hass, + "announce", + { + "destination": destination_hash, + "display_name": display_name, + "stamp_cost": stamp_cost, + "hops": hops, + }, + ) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + def refresh_interface_status(self) -> None: + """Recompute interface online status and collect telemetry. + + Called on the event loop from a periodic timer. RX/TX byte counters and + bitrate are available for the TCP client interface; RSSI/SNR/quality are + only populated when the underlying interface is a physical one (e.g. an + RNode/LoRa interface) and stay ``None`` for a plain TCP link. + """ + if self._rns is None: + return + + online = False + rxb = txb = bitrate = rssi = snr = quality = None + try: + import RNS # noqa: PLC0415 + + target = None + interfaces = list(RNS.Transport.interfaces) + for iface in interfaces: + name = getattr(iface, "name", "") or str(iface) + if self.interface_name in name: + target = iface + break + if target is None and interfaces: + target = interfaces[0] + + if target is not None: + online = bool(getattr(target, "online", False)) + rxb = getattr(target, "rxb", None) + txb = getattr(target, "txb", None) + bitrate = getattr(target, "bitrate", None) + rssi = getattr(target, "rssi", None) + snr = getattr(target, "snr", None) + quality = getattr(target, "q", None) + except Exception: # noqa: BLE001 + online = False + + snapshot = (online, rxb, txb, bitrate, rssi, snr, quality) + current = ( + self.state.interface_online, + self.state.tel_rxb, + self.state.tel_txb, + self.state.tel_bitrate, + self.state.tel_rssi, + self.state.tel_snr, + self.state.tel_quality, + ) + if snapshot == current: + return + + ( + self.state.interface_online, + self.state.tel_rxb, + self.state.tel_txb, + self.state.tel_bitrate, + self.state.tel_rssi, + self.state.tel_snr, + self.state.tel_quality, + ) = snapshot + self._push_state() + + @callback + def _push_state(self) -> None: + async_dispatcher_send(self.hass, SIGNAL_STATE_UPDATED) + + @staticmethod + def _normalise_hash(value: str) -> bytes: + cleaned = value.strip().lower().replace(":", "").replace(" ", "") + if cleaned.startswith("0x"): + cleaned = cleaned[2:] + if len(cleaned) != DEST_HASH_LEN: + raise ReticulumError( + f"Invalid Reticulum address '{value}': expected {DEST_HASH_LEN} " + f"hex characters (a 16-byte destination hash)" + ) + try: + return bytes.fromhex(cleaned) + except ValueError as err: + raise ReticulumError(f"Invalid Reticulum address '{value}'") from err + + +class _AnnounceHandler: + """RNS announce handler filtered to the LXMF delivery aspect.""" + + aspect_filter = "lxmf.delivery" + + def __init__(self, manager: ReticulumManager) -> None: + self._manager = manager + # LXMF requests receipt of the full announce data path. + self.receive_path_responses = False + + def received_announce( + self, + destination_hash: bytes, + announced_identity: Any, + app_data: Any, + ) -> None: + # NOTE: RNS dispatches this callback by *exact* parameter count and + # passes arguments by keyword. The 3-parameter form (destination_hash, + # announced_identity, app_data) is supported by every RNS version; the + # optional announce_packet_hash / is_path_response are unused here. + import RNS # noqa: PLC0415 + + display_name: str | None = None + stamp_cost: int | None = None + try: + import LXMF # noqa: PLC0415 + + if app_data is not None: + display_name = LXMF.display_name_from_app_data(app_data) + stamp_cost = LXMF.stamp_cost_from_app_data(app_data) + except Exception: # noqa: BLE001 - app_data helpers vary by version + if isinstance(app_data, (bytes, bytearray)): + try: + display_name = app_data.decode("utf-8", errors="replace") + except Exception: # noqa: BLE001 + display_name = None + + hops = None + try: + hops = RNS.Transport.hops_to(destination_hash) + except Exception: # noqa: BLE001 + hops = None + + self._manager._register_peer( + RNS.hexrep(destination_hash, delimit=False), + display_name, + stamp_cost, + hops, + ) + + +def _fire_event(hass: HomeAssistant, kind: str, data: dict[str, Any]) -> None: + """Fire one of the reticulum_* bus events (imported lazily to avoid cycle).""" + from .const import ( # noqa: PLC0415 + EVENT_ANNOUNCE_RECEIVED, + EVENT_MESSAGE_DELIVERED, + EVENT_MESSAGE_FAILED, + ) + + mapping = { + "delivered": EVENT_MESSAGE_DELIVERED, + "failed": EVENT_MESSAGE_FAILED, + "announce": EVENT_ANNOUNCE_RECEIVED, + } + hass.bus.async_fire(mapping[kind], data) + + +async def _maybe_await(func: Callable[[dict[str, Any]], Any], payload: dict[str, Any]) -> None: + """Call ``func`` supporting both coroutine and plain callables.""" + result = func(payload) + if asyncio.iscoroutine(result): + await result diff --git a/custom_components/reticulum/sensor.py b/custom_components/reticulum/sensor.py new file mode 100644 index 0000000..4302777 --- /dev/null +++ b/custom_components/reticulum/sensor.py @@ -0,0 +1,219 @@ +"""Sensor platform for Reticulum.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfDataRate, + UnitOfInformation, +) +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 + + +@dataclass(frozen=True, kw_only=True) +class ReticulumSensorDescription(SensorEntityDescription): + """Describes a Reticulum sensor.""" + + value_fn: Callable[[ManagerState], Any] + attr_fn: Callable[[ManagerState], dict[str, Any]] | None = None + + +def _last_message_time(state: ManagerState) -> 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( + key="peers", + translation_key="peers", + icon="mdi:account-group", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda s: len(s.peers), + attr_fn=lambda s: { + "peers": [ + { + "address": p.destination_hash, + "name": p.display_name, + "hops": p.hops, + "stamp_cost": p.stamp_cost, + } + for p in sorted( + s.peers.values(), key=lambda x: x.last_heard, reverse=True + ) + ] + }, + ), + 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( + 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, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda s: s.tel_rxb, + ), + ReticulumSensorDescription( + 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, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda s: s.tel_txb, + ), + ReticulumSensorDescription( + key="bitrate", + translation_key="bitrate", + icon="mdi:speedometer", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + 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( + key="rssi", + translation_key="rssi", + icon="mdi:signal", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda s: s.tel_rssi, + ), + ReticulumSensorDescription( + key="snr", + translation_key="snr", + icon="mdi:signal-variant", + native_unit_of_measurement="dB", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda s: s.tel_snr, + ), + ReticulumSensorDescription( + key="link_quality", + translation_key="link_quality", + icon="mdi:gauge", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda s: s.tel_quality, + ), +) + + +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 + async_add_entities( + ReticulumSensor(manager, entry.entry_id, desc) for desc in SENSORS + ) + + +class ReticulumSensor(ReticulumEntity, SensorEntity): + """A Reticulum sensor.""" + + entity_description: ReticulumSensorDescription + + def __init__( + self, + manager: ReticulumManager, + entry_id: str, + description: ReticulumSensorDescription, + ) -> None: + super().__init__(manager, entry_id) + self.entity_description = description + self._attr_unique_id = f"{entry_id}_{description.key}" + + @property + def native_value(self) -> Any: + return self.entity_description.value_fn(self._manager.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._manager.state) diff --git a/custom_components/reticulum/services.yaml b/custom_components/reticulum/services.yaml new file mode 100644 index 0000000..d6ac2ba --- /dev/null +++ b/custom_components/reticulum/services.yaml @@ -0,0 +1,62 @@ +send_message: + fields: + destination: + required: true + example: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" + selector: + text: + content: + required: true + example: "Hello from Home Assistant" + selector: + text: + multiline: true + title: + required: false + example: "Notification" + selector: + text: + method: + required: false + default: direct + selector: + select: + translation_key: delivery_method + options: + - direct + - opportunistic + - propagated + fields: + required: false + example: '{ }' + selector: + object: + +announce: + +request_path: + fields: + destination: + required: true + example: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" + selector: + text: + +set_propagation_node: + fields: + destination: + required: true + example: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" + selector: + text: + +sync_propagation: + fields: + max_messages: + required: false + example: 10 + selector: + number: + min: 1 + max: 1000 + mode: box diff --git a/custom_components/reticulum/strings.json b/custom_components/reticulum/strings.json new file mode 100644 index 0000000..fdb91fd --- /dev/null +++ b/custom_components/reticulum/strings.json @@ -0,0 +1,164 @@ +{ + "config": { + "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).", + "data": { + "target_host": "Target host", + "target_port": "Target port", + "display_name": "Display name", + "interface_name": "Interface 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." + } + } + }, + "error": { + "cannot_connect": "Could not reach the Reticulum TCP server at that host and port." + }, + "abort": { + "already_configured": "Reticulum is already configured.", + "single_instance_allowed": "Only a single Reticulum instance is allowed." + } + }, + "options": { + "step": { + "init": { + "title": "Reticulum 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": "Greeting message", + "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.", + "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." + } + } + } + }, + "entity": { + "sensor": { + "lxmf_address": { "name": "LXMF address" }, + "messages_received": { "name": "Messages received" }, + "messages_sent": { "name": "Messages sent" }, + "messages_failed": { "name": "Messages failed" }, + "peers": { "name": "Known peers" }, + "last_message": { "name": "Last message" }, + "last_message_time": { "name": "Last message time" }, + "rx_bytes": { "name": "Received" }, + "tx_bytes": { "name": "Transmitted" }, + "bitrate": { "name": "Bitrate" }, + "rssi": { "name": "RSSI" }, + "snr": { "name": "SNR" }, + "link_quality": { "name": "Link quality" } + }, + "binary_sensor": { + "connected": { "name": "Connected" } + }, + "button": { + "announce": { "name": "Announce" }, + "sync": { "name": "Sync propagation node" } + }, + "notify": { + "message": { "name": "Message" } + } + }, + "selector": { + "delivery_method": { + "options": { + "direct": "Direct (establish a link)", + "opportunistic": "Opportunistic (single packet)", + "propagated": "Propagated (store and forward)" + } + } + }, + "device_automation": { + "trigger_type": { + "message_received": "Message received", + "message_delivered": "Message delivered", + "message_failed": "Message delivery failed", + "announce_received": "Announce received (peer heard)" + } + }, + "services": { + "send_message": { + "name": "Send message", + "description": "Send an LXMF message to a Reticulum destination.", + "fields": { + "destination": { + "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." + }, + "fields": { + "name": "Fields", + "description": "Optional advanced LXMF fields dictionary." + } + } + }, + "announce": { + "name": "Announce", + "description": "Announce this Home Assistant's LXMF destination on the network." + }, + "request_path": { + "name": "Request path", + "description": "Ask the network for a path to a destination.", + "fields": { + "destination": { + "name": "Destination", + "description": "Destination hash to resolve a path to." + } + } + }, + "set_propagation_node": { + "name": "Set propagation node", + "description": "Set the outbound LXMF propagation node.", + "fields": { + "destination": { + "name": "Destination", + "description": "Propagation node destination hash." + } + } + }, + "sync_propagation": { + "name": "Sync propagation node", + "description": "Pull queued messages from the configured propagation node.", + "fields": { + "max_messages": { + "name": "Max messages", + "description": "Maximum number of messages to retrieve." + } + } + } + } +} diff --git a/custom_components/reticulum/translations/en.json b/custom_components/reticulum/translations/en.json new file mode 100644 index 0000000..fdb91fd --- /dev/null +++ b/custom_components/reticulum/translations/en.json @@ -0,0 +1,164 @@ +{ + "config": { + "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).", + "data": { + "target_host": "Target host", + "target_port": "Target port", + "display_name": "Display name", + "interface_name": "Interface 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." + } + } + }, + "error": { + "cannot_connect": "Could not reach the Reticulum TCP server at that host and port." + }, + "abort": { + "already_configured": "Reticulum is already configured.", + "single_instance_allowed": "Only a single Reticulum instance is allowed." + } + }, + "options": { + "step": { + "init": { + "title": "Reticulum 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": "Greeting message", + "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.", + "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." + } + } + } + }, + "entity": { + "sensor": { + "lxmf_address": { "name": "LXMF address" }, + "messages_received": { "name": "Messages received" }, + "messages_sent": { "name": "Messages sent" }, + "messages_failed": { "name": "Messages failed" }, + "peers": { "name": "Known peers" }, + "last_message": { "name": "Last message" }, + "last_message_time": { "name": "Last message time" }, + "rx_bytes": { "name": "Received" }, + "tx_bytes": { "name": "Transmitted" }, + "bitrate": { "name": "Bitrate" }, + "rssi": { "name": "RSSI" }, + "snr": { "name": "SNR" }, + "link_quality": { "name": "Link quality" } + }, + "binary_sensor": { + "connected": { "name": "Connected" } + }, + "button": { + "announce": { "name": "Announce" }, + "sync": { "name": "Sync propagation node" } + }, + "notify": { + "message": { "name": "Message" } + } + }, + "selector": { + "delivery_method": { + "options": { + "direct": "Direct (establish a link)", + "opportunistic": "Opportunistic (single packet)", + "propagated": "Propagated (store and forward)" + } + } + }, + "device_automation": { + "trigger_type": { + "message_received": "Message received", + "message_delivered": "Message delivered", + "message_failed": "Message delivery failed", + "announce_received": "Announce received (peer heard)" + } + }, + "services": { + "send_message": { + "name": "Send message", + "description": "Send an LXMF message to a Reticulum destination.", + "fields": { + "destination": { + "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." + }, + "fields": { + "name": "Fields", + "description": "Optional advanced LXMF fields dictionary." + } + } + }, + "announce": { + "name": "Announce", + "description": "Announce this Home Assistant's LXMF destination on the network." + }, + "request_path": { + "name": "Request path", + "description": "Ask the network for a path to a destination.", + "fields": { + "destination": { + "name": "Destination", + "description": "Destination hash to resolve a path to." + } + } + }, + "set_propagation_node": { + "name": "Set propagation node", + "description": "Set the outbound LXMF propagation node.", + "fields": { + "destination": { + "name": "Destination", + "description": "Propagation node destination hash." + } + } + }, + "sync_propagation": { + "name": "Sync propagation node", + "description": "Pull queued messages from the configured propagation node.", + "fields": { + "max_messages": { + "name": "Max messages", + "description": "Maximum number of messages to retrieve." + } + } + } + } +} diff --git a/custom_components/reticulum/translations/ru.json b/custom_components/reticulum/translations/ru.json new file mode 100644 index 0000000..826fadf --- /dev/null +++ b/custom_components/reticulum/translations/ru.json @@ -0,0 +1,164 @@ +{ + "config": { + "step": { + "user": { + "title": "Подключение к Reticulum", + "description": "Home Assistant запустит экземпляр Reticulum и подключится к вашему стеку Reticulum по TCP (на соседней машине должен быть настроен TCPServerInterface).", + "data": { + "target_host": "Хост", + "target_port": "Порт", + "display_name": "Отображаемое имя", + "interface_name": "Название интерфейса" + }, + "data_description": { + "target_host": "Имя хоста или IP-адрес машины с TCP-сервером Reticulum.", + "target_port": "TCP-порт TCPServerInterface Reticulum (по умолчанию 4242).", + "display_name": "Имя, анонсируемое другим узлам Reticulum для этого Home Assistant.", + "interface_name": "Метка соединения в конфигурации Reticulum." + } + } + }, + "error": { + "cannot_connect": "Не удалось подключиться к TCP-серверу Reticulum по указанному адресу и порту." + }, + "abort": { + "already_configured": "Reticulum уже настроен.", + "single_instance_allowed": "Допускается только один экземпляр Reticulum." + } + }, + "options": { + "step": { + "init": { + "title": "Параметры Reticulum", + "data": { + "enable_assist": "Направлять входящие сообщения в Assist", + "assist_agent": "Агент Assist (id сущности/агента, пусто = по умолчанию)", + "assist_language": "Язык Assist (пусто = язык системы)", + "allow_all_senders": "Отвечать любому отправителю", + "allowed_identities": "Разрешённые адреса отправителей", + "default_recipient": "Адрес получателя по умолчанию (для сущности notify)", + "greeting": "Приветственное сообщение", + "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.", + "announce_interval": "Как часто (в секундах) анонсировать наш адрес. 0 отключает периодические анонсы.", + "propagation_node": "Необязательный узел ретрансляции LXMF для доставки по принципу store-and-forward.", + "sync_interval": "Как часто (в секундах) забирать сообщения из очереди узла ретрансляции. 0 отключает." + } + } + } + }, + "entity": { + "sensor": { + "lxmf_address": { "name": "Адрес LXMF" }, + "messages_received": { "name": "Сообщений получено" }, + "messages_sent": { "name": "Сообщений отправлено" }, + "messages_failed": { "name": "Сообщений не доставлено" }, + "peers": { "name": "Известные узлы" }, + "last_message": { "name": "Последнее сообщение" }, + "last_message_time": { "name": "Время последнего сообщения" }, + "rx_bytes": { "name": "Принято" }, + "tx_bytes": { "name": "Передано" }, + "bitrate": { "name": "Битрейт" }, + "rssi": { "name": "RSSI" }, + "snr": { "name": "SNR" }, + "link_quality": { "name": "Качество канала" } + }, + "binary_sensor": { + "connected": { "name": "Подключено" } + }, + "button": { + "announce": { "name": "Анонсировать" }, + "sync": { "name": "Синхронизировать узел ретрансляции" } + }, + "notify": { + "message": { "name": "Сообщение" } + } + }, + "selector": { + "delivery_method": { + "options": { + "direct": "Прямая (установить канал)", + "opportunistic": "Оппортунистическая (один пакет)", + "propagated": "Через ретрансляцию (store-and-forward)" + } + } + }, + "device_automation": { + "trigger_type": { + "message_received": "Получено сообщение", + "message_delivered": "Сообщение доставлено", + "message_failed": "Сбой доставки сообщения", + "announce_received": "Получен анонс (услышан узел)" + } + }, + "services": { + "send_message": { + "name": "Отправить сообщение", + "description": "Отправить сообщение LXMF узлу Reticulum.", + "fields": { + "destination": { + "name": "Получатель", + "description": "16-байтовый hex-хэш адреса LXMF получателя." + }, + "content": { + "name": "Текст", + "description": "Тело сообщения." + }, + "title": { + "name": "Заголовок", + "description": "Необязательный заголовок/тема сообщения." + }, + "method": { + "name": "Способ", + "description": "Используемый способ доставки." + }, + "fields": { + "name": "Поля", + "description": "Необязательный словарь расширенных полей LXMF." + } + } + }, + "announce": { + "name": "Анонсировать", + "description": "Анонсировать адрес LXMF этого Home Assistant в сети." + }, + "request_path": { + "name": "Запросить маршрут", + "description": "Запросить у сети маршрут до узла.", + "fields": { + "destination": { + "name": "Получатель", + "description": "Хэш адреса, до которого нужно найти маршрут." + } + } + }, + "set_propagation_node": { + "name": "Задать узел ретрансляции", + "description": "Задать исходящий узел ретрансляции LXMF.", + "fields": { + "destination": { + "name": "Получатель", + "description": "Хэш адреса узла ретрансляции." + } + } + }, + "sync_propagation": { + "name": "Синхронизировать узел ретрансляции", + "description": "Забрать сообщения из очереди настроенного узла ретрансляции.", + "fields": { + "max_messages": { + "name": "Макс. сообщений", + "description": "Максимальное число сообщений для получения." + } + } + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..a2e6b06 --- /dev/null +++ b/hacs.json @@ -0,0 +1,6 @@ +{ + "name": "Reticulum", + "content_in_root": false, + "render_readme": true, + "homeassistant": "2024.11.0" +}