Add Reticulum/LXMF integration for Home Assistant
Custom integration connecting Home Assistant to a Reticulum network over a TCPClientInterface and exchanging LXMF messages, with an Assist conversation bridge, notify entity, sensors (incl. interface telemetry), buttons, services, bus events and device triggers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
400
custom_components/reticulum/__init__.py
Normal file
400
custom_components/reticulum/__init__.py
Normal file
@@ -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)
|
||||
40
custom_components/reticulum/binary_sensor.py
Normal file
40
custom_components/reticulum/binary_sensor.py
Normal file
@@ -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
|
||||
65
custom_components/reticulum/button.py
Normal file
65
custom_components/reticulum/button.py
Normal file
@@ -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)
|
||||
245
custom_components/reticulum/config_flow.py
Normal file
245
custom_components/reticulum/config_flow.py
Normal file
@@ -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)
|
||||
87
custom_components/reticulum/const.py
Normal file
87
custom_components/reticulum/const.py
Normal file
@@ -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
|
||||
82
custom_components/reticulum/device_trigger.py
Normal file
82
custom_components/reticulum/device_trigger.py
Normal file
@@ -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"
|
||||
)
|
||||
41
custom_components/reticulum/diagnostics.py
Normal file
41
custom_components/reticulum/diagnostics.py
Normal file
@@ -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()
|
||||
],
|
||||
},
|
||||
}
|
||||
39
custom_components/reticulum/entity.py
Normal file
39
custom_components/reticulum/entity.py
Normal file
@@ -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()
|
||||
16
custom_components/reticulum/manifest.json
Normal file
16
custom_components/reticulum/manifest.json
Normal file
@@ -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"
|
||||
}
|
||||
57
custom_components/reticulum/notify.py
Normal file
57
custom_components/reticulum/notify.py
Normal file
@@ -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
|
||||
647
custom_components/reticulum/reticulum_client.py
Normal file
647
custom_components/reticulum/reticulum_client.py
Normal file
@@ -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
|
||||
219
custom_components/reticulum/sensor.py
Normal file
219
custom_components/reticulum/sensor.py
Normal file
@@ -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)
|
||||
62
custom_components/reticulum/services.yaml
Normal file
62
custom_components/reticulum/services.yaml
Normal file
@@ -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
|
||||
164
custom_components/reticulum/strings.json
Normal file
164
custom_components/reticulum/strings.json
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
164
custom_components/reticulum/translations/en.json
Normal file
164
custom_components/reticulum/translations/en.json
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
164
custom_components/reticulum/translations/ru.json
Normal file
164
custom_components/reticulum/translations/ru.json
Normal file
@@ -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": "Максимальное число сообщений для получения."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user