"""Config flow for the Reticulum integration. The config entry is the shared Reticulum stack (RNS + TCP interface). Each LXMF identity is a config subentry of type ``identity``. """ from __future__ import annotations import asyncio from typing import Any import voluptuous as vol from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, ConfigSubentryFlow, OptionsFlow, SubentryFlowResult, ) 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, SUBENTRY_TYPE_IDENTITY, ) class CannotConnect(Exception): """Error to indicate we cannot connect to the TCP server.""" 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 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_INTERFACE_NAME, default=DEFAULT_INTERFACE_NAME ): cv.string, vol.Required(CONF_DISPLAY_NAME, default=DEFAULT_DISPLAY_NAME): cv.string, } ) def _default_identity_data(display_name: str) -> dict[str, Any]: return { CONF_DISPLAY_NAME: display_name, CONF_ENABLE_ASSIST: True, CONF_ALLOW_ALL: True, CONF_ALLOWED_IDENTITIES: [], CONF_DELIVERY_METHOD: DEFAULT_DELIVERY_METHOD, CONF_ANNOUNCE_INTERVAL: DEFAULT_ANNOUNCE_INTERVAL, CONF_SYNC_INTERVAL: DEFAULT_SYNC_INTERVAL, } def _normalise_identity(user_input: dict[str, Any]) -> dict[str, Any]: """Coerce the allow-list text field into a list.""" data = dict(user_input) raw = data.get(CONF_ALLOWED_IDENTITIES, "") if isinstance(raw, str): data[CONF_ALLOWED_IDENTITIES] = [ item.strip() for item in raw.replace("\n", ",").split(",") if item.strip() ] return data def _identity_schema(defaults: dict[str, Any]) -> vol.Schema: allowed = defaults.get(CONF_ALLOWED_IDENTITIES, []) allowed_str = ", ".join(allowed) if isinstance(allowed, list) else (allowed or "") return vol.Schema( { vol.Required( CONF_DISPLAY_NAME, default=defaults.get(CONF_DISPLAY_NAME, DEFAULT_DISPLAY_NAME), ): cv.string, vol.Required( CONF_ENABLE_ASSIST, default=defaults.get(CONF_ENABLE_ASSIST, True) ): BooleanSelector(), vol.Optional( CONF_ASSIST_AGENT, description={"suggested_value": defaults.get(CONF_ASSIST_AGENT)}, ): ConversationAgentSelector(ConversationAgentSelectorConfig()), vol.Optional( CONF_ASSIST_LANGUAGE, description={"suggested_value": defaults.get(CONF_ASSIST_LANGUAGE)}, ): LanguageSelector(LanguageSelectorConfig()), vol.Required( CONF_ALLOW_ALL, default=defaults.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": defaults.get(CONF_DEFAULT_RECIPIENT, "")}, ): TextSelector(), vol.Optional( CONF_GREETING, description={"suggested_value": defaults.get(CONF_GREETING, "")}, ): TextSelector(TextSelectorConfig(multiline=True)), vol.Required( CONF_DELIVERY_METHOD, default=defaults.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=defaults.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": defaults.get(CONF_PROPAGATION_NODE, "")}, ): TextSelector(), vol.Required( CONF_SYNC_INTERVAL, default=defaults.get(CONF_SYNC_INTERVAL, DEFAULT_SYNC_INTERVAL), ): NumberSelector( NumberSelectorConfig( min=0, max=86400, step=60, mode=NumberSelectorMode.BOX, unit_of_measurement="s", ) ), } ) class ReticulumConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the Reticulum stack (hub) config flow.""" VERSION = 1 async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Configure the shared Reticulum stack and a first identity.""" 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() display_name = user_input[CONF_DISPLAY_NAME] return self.async_create_entry( title="Reticulum", data={ CONF_TARGET_HOST: user_input[CONF_TARGET_HOST], CONF_TARGET_PORT: user_input[CONF_TARGET_PORT], CONF_INTERFACE_NAME: user_input[CONF_INTERFACE_NAME], }, options={CONF_LOGLEVEL: DEFAULT_LOGLEVEL}, subentries=[ { "subentry_type": SUBENTRY_TYPE_IDENTITY, "title": display_name, "data": _default_identity_data(display_name), "unique_id": None, } ], ) 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: return ReticulumOptionsFlow() @classmethod @callback def async_get_supported_subentry_types( cls, config_entry: ConfigEntry ) -> dict[str, type[ConfigSubentryFlow]]: return {SUBENTRY_TYPE_IDENTITY: IdentitySubentryFlow} class ReticulumOptionsFlow(OptionsFlow): """Hub-level options (stack settings).""" async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: if user_input is not None: return self.async_create_entry(data=user_input) opts = self.config_entry.options schema = vol.Schema( { 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) class IdentitySubentryFlow(ConfigSubentryFlow): """Add or reconfigure an LXMF identity.""" async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: if user_input is not None: data = _normalise_identity(user_input) return self.async_create_entry( title=data[CONF_DISPLAY_NAME], data=data ) return self.async_show_form( step_id="user", data_schema=_identity_schema({}) ) async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: subentry = self._get_reconfigure_subentry() if user_input is not None: data = _normalise_identity(user_input) return self.async_update_and_abort( self._get_entry(), subentry, title=data[CONF_DISPLAY_NAME], data=data, ) return self.async_show_form( step_id="reconfigure", data_schema=_identity_schema(dict(subentry.data)) )