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

@@ -24,55 +24,35 @@ from homeassistant.const import (
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
from .const import DOMAIN, SUBENTRY_TYPE_IDENTITY
from .entity import ReticulumIdentityEntity, ReticulumStackEntity
from .reticulum_client import IdentityState, StackState
@dataclass(frozen=True, kw_only=True)
class ReticulumSensorDescription(SensorEntityDescription):
"""Describes a Reticulum sensor."""
class StackSensorDescription(SensorEntityDescription):
"""Hub sensor."""
value_fn: Callable[[ManagerState], Any]
attr_fn: Callable[[ManagerState], dict[str, Any]] | None = None
value_fn: Callable[[StackState], Any]
attr_fn: Callable[[StackState], dict[str, Any]] | None = None
def _last_message_time(state: ManagerState) -> datetime | None:
@dataclass(frozen=True, kw_only=True)
class IdentitySensorDescription(SensorEntityDescription):
"""Per-identity sensor."""
value_fn: Callable[[IdentityState], Any]
attr_fn: Callable[[IdentityState], dict[str, Any]] | None = None
def _last_message_time(state: IdentityState) -> 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(
STACK_SENSORS: tuple[StackSensorDescription, ...] = (
StackSensorDescription(
key="peers",
translation_key="peers",
icon="mdi:account-group",
@@ -92,50 +72,31 @@ SENSORS: tuple[ReticulumSensorDescription, ...] = (
]
},
),
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(
StackSensorDescription(
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,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda s: s.tel_rxb,
),
ReticulumSensorDescription(
StackSensorDescription(
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,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda s: s.tel_txb,
),
ReticulumSensorDescription(
StackSensorDescription(
key="bitrate",
translation_key="bitrate",
icon="mdi:speedometer",
@@ -145,9 +106,7 @@ SENSORS: tuple[ReticulumSensorDescription, ...] = (
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(
StackSensorDescription(
key="rssi",
translation_key="rssi",
icon="mdi:signal",
@@ -158,7 +117,7 @@ SENSORS: tuple[ReticulumSensorDescription, ...] = (
entity_registry_enabled_default=False,
value_fn=lambda s: s.tel_rssi,
),
ReticulumSensorDescription(
StackSensorDescription(
key="snr",
translation_key="snr",
icon="mdi:signal-variant",
@@ -168,7 +127,7 @@ SENSORS: tuple[ReticulumSensorDescription, ...] = (
entity_registry_enabled_default=False,
value_fn=lambda s: s.tel_snr,
),
ReticulumSensorDescription(
StackSensorDescription(
key="link_quality",
translation_key="link_quality",
icon="mdi:gauge",
@@ -181,32 +140,114 @@ SENSORS: tuple[ReticulumSensorDescription, ...] = (
)
IDENTITY_SENSORS: tuple[IdentitySensorDescription, ...] = (
IdentitySensorDescription(
key="lxmf_address",
translation_key="lxmf_address",
icon="mdi:identifier",
value_fn=lambda s: s.lxmf_address,
),
IdentitySensorDescription(
key="messages_received",
translation_key="messages_received",
icon="mdi:inbox-arrow-down",
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda s: s.messages_received,
),
IdentitySensorDescription(
key="messages_sent",
translation_key="messages_sent",
icon="mdi:inbox-arrow-up",
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda s: s.messages_sent,
),
IdentitySensorDescription(
key="messages_failed",
translation_key="messages_failed",
icon="mdi:message-alert",
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda s: s.messages_failed,
),
IdentitySensorDescription(
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,
},
),
IdentitySensorDescription(
key="last_message_time",
translation_key="last_message_time",
icon="mdi:clock-outline",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=_last_message_time,
),
)
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
"""Set up hub + per-identity sensors."""
runtime = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
ReticulumSensor(manager, entry.entry_id, desc) for desc in SENSORS
ReticulumStackSensor(runtime.stack, entry, desc) for desc in STACK_SENSORS
)
for subentry in entry.subentries.values():
if subentry.subentry_type != SUBENTRY_TYPE_IDENTITY:
continue
manager = runtime.identities.get(subentry.subentry_id)
if manager is None:
continue
async_add_entities(
(
ReticulumIdentitySensor(manager, entry, subentry, desc)
for desc in IDENTITY_SENSORS
),
config_subentry_id=subentry.subentry_id,
)
class ReticulumSensor(ReticulumEntity, SensorEntity):
"""A Reticulum sensor."""
entity_description: ReticulumSensorDescription
class ReticulumStackSensor(ReticulumStackEntity, SensorEntity):
"""Hub sensor."""
entity_description: StackSensorDescription
def __init__(self, stack, entry, description: StackSensorDescription) -> None:
super().__init__(stack, entry)
self.entity_description = description
self._attr_unique_id = f"{entry.entry_id}_{description.key}"
@property
def native_value(self) -> Any:
return self.entity_description.value_fn(self._stack.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._stack.state)
class ReticulumIdentitySensor(ReticulumIdentityEntity, SensorEntity):
"""Per-identity sensor."""
entity_description: IdentitySensorDescription
def __init__(
self,
manager: ReticulumManager,
entry_id: str,
description: ReticulumSensorDescription,
self, manager, entry, subentry, description: IdentitySensorDescription
) -> None:
super().__init__(manager, entry_id)
super().__init__(manager, entry, subentry)
self.entity_description = description
self._attr_unique_id = f"{entry_id}_{description.key}"
self._attr_unique_id = f"{subentry.subentry_id}_{description.key}"
@property
def native_value(self) -> Any: