Multiple identities via config subentries + regenerate + editable name

Refactor from one-entry-one-identity to a hub config entry (shared RNS stack)
plus config subentries of type "identity", each an independent LXMF identity
with its own LXMRouter, address, device and entities. This is required because
LXMRouter allows only one delivery identity per instance.

- ReticulumStack: owns the single process-wide RNS instance, peer discovery and
  interface telemetry (hub device).
- IdentityManager: one per subentry, its own LXMRouter/identity/destination and
  delivery callback (identity device). Signal handlers suppressed and atexit
  handlers tamed for each router too.
- Config subentry flow (add / reconfigure identity); a first identity is seeded
  on stack creation. Editable announce display name = subentry title.
- Per-identity "Regenerate identity" button: new address + re-announce
  (clears the router's single delivery destination first).
- Services and events gain an "identity" field to target/distinguish identities.
- Per-identity assist bridge, greeting-when-assist-off, allow-list, notify,
  announce/sync buttons, message counters; hub-level connectivity/telemetry/peers.

Verified against current HA config_entries/entity_platform/selector and RNS/LXMF
sources. Docs and en/ru translations updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude
2026-07-22 20:29:05 +03:00
parent c3b98043f7
commit 027ff685b7
16 changed files with 1572 additions and 1069 deletions

View File

@@ -1,4 +1,8 @@
"""Config flow for the Reticulum integration."""
"""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
@@ -11,7 +15,9 @@ from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
OptionsFlow,
SubentryFlowResult,
)
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
@@ -57,9 +63,14 @@ from .const import (
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:
@@ -75,36 +86,126 @@ async def _test_connection(host: str, port: int) -> None:
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,
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 a config flow for Reticulum."""
"""Handle the Reticulum stack (hub) config flow."""
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
"""Configure the shared Reticulum stack and a first identity."""
errors: dict[str, str] = {}
if user_input is not None:
try:
await _test_connection(
@@ -115,12 +216,24 @@ class ReticulumConfigFlow(ConfigFlow, domain=DOMAIN):
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=f"Reticulum ({user_input[CONF_DISPLAY_NAME]})",
data=user_input,
options=_default_options(),
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
)
@@ -128,113 +241,27 @@ class ReticulumConfigFlow(ConfigFlow, domain=DOMAIN):
@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,
}
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
return {SUBENTRY_TYPE_IDENTITY: IdentitySubentryFlow}
class ReticulumOptionsFlow(OptionsFlow):
"""Handle Reticulum options."""
"""Hub-level options (stack settings)."""
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(
@@ -245,3 +272,35 @@ class ReticulumOptionsFlow(OptionsFlow):
}
)
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))
)