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:
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)
|
||||
Reference in New Issue
Block a user