- entity.py: decorate the dispatcher target _handle_update with @callback so it runs on the event loop. Without it, async_dispatcher_send offloaded the plain sync callback to an executor thread, and async_write_ha_state raised "calls async_write_ha_state from a thread other than the event loop". - config_flow.py: replace the free-text Assist agent field with ConversationAgentSelector (a proper agent dropdown), and use LanguageSelector for the language field. - README: clarify that the built-in Assist agent only handles device commands; an LLM conversation agent is required for free-form chat. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
248 lines
8.3 KiB
Python
248 lines
8.3 KiB
Python
"""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,
|
|
ConversationAgentSelector,
|
|
ConversationAgentSelectorConfig,
|
|
LanguageSelector,
|
|
LanguageSelectorConfig,
|
|
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)},
|
|
): ConversationAgentSelector(ConversationAgentSelectorConfig()),
|
|
vol.Optional(
|
|
CONF_ASSIST_LANGUAGE,
|
|
description={
|
|
"suggested_value": opts.get(CONF_ASSIST_LANGUAGE)
|
|
},
|
|
): LanguageSelector(LanguageSelectorConfig()),
|
|
vol.Required(
|
|
CONF_ALLOW_ALL, default=opts.get(CONF_ALLOW_ALL, True)
|
|
): BooleanSelector(),
|
|
vol.Optional(
|
|
CONF_ALLOWED_IDENTITIES,
|
|
description={"suggested_value": allowed_str},
|
|
): TextSelector(TextSelectorConfig(multiline=True)),
|
|
vol.Optional(
|
|
CONF_DEFAULT_RECIPIENT,
|
|
description={
|
|
"suggested_value": opts.get(CONF_DEFAULT_RECIPIENT, "")
|
|
},
|
|
): TextSelector(),
|
|
vol.Optional(
|
|
CONF_GREETING,
|
|
description={"suggested_value": opts.get(CONF_GREETING, "")},
|
|
): TextSelector(TextSelectorConfig(multiline=True)),
|
|
vol.Required(
|
|
CONF_DELIVERY_METHOD,
|
|
default=opts.get(CONF_DELIVERY_METHOD, DEFAULT_DELIVERY_METHOD),
|
|
): SelectSelector(
|
|
SelectSelectorConfig(
|
|
options=DELIVERY_METHODS,
|
|
translation_key="delivery_method",
|
|
mode=SelectSelectorMode.DROPDOWN,
|
|
)
|
|
),
|
|
vol.Required(
|
|
CONF_ANNOUNCE_INTERVAL,
|
|
default=opts.get(
|
|
CONF_ANNOUNCE_INTERVAL, DEFAULT_ANNOUNCE_INTERVAL
|
|
),
|
|
): NumberSelector(
|
|
NumberSelectorConfig(
|
|
min=0, max=86400, step=60, mode=NumberSelectorMode.BOX,
|
|
unit_of_measurement="s",
|
|
)
|
|
),
|
|
vol.Optional(
|
|
CONF_PROPAGATION_NODE,
|
|
description={
|
|
"suggested_value": opts.get(CONF_PROPAGATION_NODE, "")
|
|
},
|
|
): TextSelector(),
|
|
vol.Required(
|
|
CONF_SYNC_INTERVAL,
|
|
default=opts.get(CONF_SYNC_INTERVAL, DEFAULT_SYNC_INTERVAL),
|
|
): NumberSelector(
|
|
NumberSelectorConfig(
|
|
min=0, max=86400, step=60, mode=NumberSelectorMode.BOX,
|
|
unit_of_measurement="s",
|
|
)
|
|
),
|
|
vol.Required(
|
|
CONF_LOGLEVEL, default=opts.get(CONF_LOGLEVEL, DEFAULT_LOGLEVEL)
|
|
): NumberSelector(
|
|
NumberSelectorConfig(
|
|
min=0, max=7, step=1, mode=NumberSelectorMode.SLIDER
|
|
)
|
|
),
|
|
}
|
|
)
|
|
return self.async_show_form(step_id="init", data_schema=schema)
|