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:
claude
2026-07-22 18:08:12 +03:00
parent 22408c2bd5
commit b40c527515
19 changed files with 2727 additions and 1 deletions

View 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)

View 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

View 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)

View 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)

View 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

View 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"
)

View 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()
],
},
}

View 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()

View 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"
}

View 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

View 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

View 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)

View 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

View 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."
}
}
}
}
}

View 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."
}
}
}
}
}

View 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": "Максимальное число сообщений для получения."
}
}
}
}
}