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:
129
README.md
129
README.md
@@ -75,69 +75,80 @@ directory and restart.
|
||||
|
||||
**Settings → Devices & Services → Add Integration → Reticulum.**
|
||||
|
||||
| Field | Meaning |
|
||||
You configure the shared **stack** once (the RNS connection), and a first LXMF
|
||||
**identity** is created automatically. Additional identities are added as
|
||||
**sub-entries** from the integration page.
|
||||
|
||||
| Stack field | Meaning |
|
||||
|-------|---------|
|
||||
| Target host | Host/IP of the neighbouring Reticulum TCP server |
|
||||
| Target port | Its `listen_port` (default `4242`) |
|
||||
| Display name | Name announced to other peers (e.g. `Home Assistant`) |
|
||||
| Interface name | Label for the connection in the generated RNS config |
|
||||
| First identity name | Announce name of the first identity |
|
||||
|
||||
On first start the integration generates a stable identity and announces it.
|
||||
Find your **LXMF address** on the *LXMF address* sensor, or in the log:
|
||||
### Identities (sub-entries)
|
||||
|
||||
```
|
||||
Reticulum ready. LXMF address: a1b2c3… (Home Assistant)
|
||||
```
|
||||
Each identity is an independent LXMF address with its own settings, its own
|
||||
device, and its own entities. Add one via the integration's **⋮ → Add identity**
|
||||
(or reconfigure an existing one). Typical uses:
|
||||
|
||||
Add that address as a contact in Sideband/MeshChat and send a message — Assist
|
||||
will answer.
|
||||
- an **assistant** identity (Assist enabled) that users chat with;
|
||||
- a **receive-only** identity (Assist disabled) whose messages are handled by
|
||||
automations via the `reticulum_message_received` event.
|
||||
|
||||
### Options (⚙ → Configure)
|
||||
Per-identity settings:
|
||||
|
||||
- **Name** – contact name announced to peers (also the identity's HA name).
|
||||
Change it any time by reconfiguring the identity; future announces use it.
|
||||
- **Route incoming messages to Assist** – turn the chat bridge on/off.
|
||||
- **Assist agent / language** – pick the conversation agent from the dropdown.
|
||||
> **Want free-form chat?** The default *Home Assistant* agent only recognises
|
||||
> device-control commands and will answer anything else with "Sorry, I
|
||||
> couldn't understand that". To actually chat, install an LLM conversation
|
||||
> integration (OpenAI, Google Generative AI, Anthropic, Ollama, …), then
|
||||
> select that agent here.
|
||||
- **Reply to any sender** / **Allowed sender addresses** – restrict who Assist
|
||||
will answer.
|
||||
- **Default recipient** – address the `notify.reticulum_send_message` entity
|
||||
sends to (see Entities below).
|
||||
- **Greeting / auto-reply** – message sent automatically in reply to incoming
|
||||
messages **only when "Route incoming messages to Assist" is off** (so senders
|
||||
get an acknowledgement instead of silence). Blank = disabled.
|
||||
- **Default delivery method** – `direct`, `opportunistic`, or `propagated`.
|
||||
- **Announce interval** – periodic re-announce (seconds; `0` disables).
|
||||
- **Propagation node** + **sync interval** – optional store-and-forward node so
|
||||
messages queue while HA is offline.
|
||||
- **Reticulum log level** – `0`–`7`.
|
||||
> device-control commands. To chat, install an LLM conversation integration
|
||||
> (OpenAI, Google Generative AI, Anthropic, Ollama, …) and select it here.
|
||||
- **Reply to any sender** / **Allowed sender addresses** – restrict who is answered.
|
||||
- **Default recipient** – address this identity's notify entity sends to.
|
||||
- **Auto-reply (when Assist is off)** – message sent automatically in reply to
|
||||
incoming messages only when Assist routing is off. Blank = disabled.
|
||||
- **Default delivery method**, **announce interval**, **propagation node** +
|
||||
**sync interval**.
|
||||
|
||||
Stack-level options (**⚙ → Configure**): **Reticulum log level** (`0`–`7`).
|
||||
|
||||
Find each identity's **LXMF address** on its *LXMF address* sensor, or in the
|
||||
log (`Reticulum identity '…' ready: a1b2c3…`). Add it as a contact in
|
||||
Sideband/MeshChat and message it.
|
||||
|
||||
---
|
||||
|
||||
## Entities
|
||||
|
||||
**Hub (stack) device — `Reticulum`:**
|
||||
|
||||
| Entity | Description |
|
||||
|--------|-------------|
|
||||
| `sensor.reticulum_lxmf_address` | This HA's LXMF address (share this with users) |
|
||||
| `sensor.reticulum_messages_received` / `_sent` / `_failed` | Counters |
|
||||
| `sensor.reticulum_known_peers` | Count + `peers` attribute (address, name, hops) |
|
||||
| `sensor.reticulum_last_message` | Last inbound text + source/title attributes |
|
||||
| `sensor.reticulum_last_message_time` | Timestamp of last inbound message |
|
||||
| `sensor.reticulum_received` / `_transmitted` | Interface RX/TX byte counters |
|
||||
| `sensor.reticulum_bitrate` | Interface bitrate |
|
||||
| `sensor.reticulum_rssi` / `_snr` / `_link_quality` | Physical-link telemetry (disabled by default) |
|
||||
| `binary_sensor.reticulum_connected` | Whether the TCP interface is online |
|
||||
| `button.reticulum_announce` | Announce now |
|
||||
| `button.reticulum_sync_propagation_node` | Pull queued messages now |
|
||||
| `notify.reticulum_send_message` | `notify` entity that sends an LXMF message to the **default recipient** set in options — use it in automations/scripts (`action: notify.send_message`) or the Developer Tools; for arbitrary addresses use the `reticulum.send_message` service |
|
||||
| `binary_sensor` Connected | Whether the TCP interface is online |
|
||||
| `sensor` Known peers | Count + `peers` attribute (address, name, hops) |
|
||||
| `sensor` Received / Transmitted | Interface RX/TX byte counters |
|
||||
| `sensor` Bitrate | Interface bitrate |
|
||||
| `sensor` RSSI / SNR / Link quality | Physical-link telemetry (disabled by default) |
|
||||
|
||||
The telemetry sensors read from the outbound interface. RX/TX bytes and bitrate
|
||||
work for the TCP link. **RSSI, SNR and link quality only carry data when the
|
||||
underlying interface is a physical one (RNode/LoRa)** — they stay unavailable on
|
||||
a plain TCP link, so they are disabled by default. Enable them from the device
|
||||
page if you run a physical interface.
|
||||
**Per identity device (one per identity):**
|
||||
|
||||
| Entity | Description |
|
||||
|--------|-------------|
|
||||
| `sensor` LXMF address | This identity's address (share it with users) |
|
||||
| `sensor` Messages received / sent / failed | Counters |
|
||||
| `sensor` Last message | Last inbound text + source/title attributes |
|
||||
| `button` Announce | Announce this identity now |
|
||||
| `button` Sync propagation node | Pull queued messages now |
|
||||
| `button` Regenerate identity | Generate a brand-new address for this identity |
|
||||
| `notify` Send message | Sends an LXMF message from this identity to its **default recipient** (`action: notify.send_message`); for arbitrary addresses use the `reticulum.send_message` service |
|
||||
|
||||
RSSI, SNR and link quality only carry data on a physical interface (RNode/LoRa)
|
||||
— they stay unavailable on a plain TCP link, so they are disabled by default.
|
||||
|
||||
> **Regenerate identity** replaces this identity's address with a fresh one and
|
||||
> re-announces. The old address stops working — contacts must re-add the new one.
|
||||
|
||||
---
|
||||
|
||||
@@ -153,20 +164,26 @@ data:
|
||||
content: "The garage door is still open."
|
||||
title: "Home Assistant"
|
||||
method: direct # direct | opportunistic | propagated
|
||||
identity: "Home Assistant" # optional: which identity to send from (name or
|
||||
# LXMF address); defaults to the first identity
|
||||
```
|
||||
|
||||
Other services: `reticulum.announce`, `reticulum.request_path`,
|
||||
`reticulum.set_propagation_node`, `reticulum.sync_propagation`.
|
||||
`reticulum.set_propagation_node`, `reticulum.sync_propagation`. Each accepts an
|
||||
optional `identity` to pick which identity to act through.
|
||||
|
||||
---
|
||||
|
||||
## Events
|
||||
|
||||
- `reticulum_message_received` — `{content, title, source, destination, timestamp, signature_validated, fields}`
|
||||
- `reticulum_message_delivered` — `{message_hash, destination}`
|
||||
- `reticulum_message_failed` — `{message_hash, destination}`
|
||||
- `reticulum_message_received` — `{content, title, source, destination, timestamp, signature_validated, fields, identity, identity_name, local_address}`
|
||||
- `reticulum_message_delivered` — `{message_hash, destination, identity, identity_name, local_address}`
|
||||
- `reticulum_message_failed` — `{message_hash, destination, identity, identity_name, local_address}`
|
||||
- `reticulum_announce_received` — `{destination, display_name, stamp_cost, hops}`
|
||||
|
||||
`identity` is the subentry id and `identity_name` the identity's name, so
|
||||
automations can tell which identity received/sent a message.
|
||||
|
||||
### Example automation — forward every inbound message to a mobile push
|
||||
|
||||
```yaml
|
||||
@@ -213,15 +230,15 @@ action:
|
||||
|
||||
## Notes & limitations
|
||||
|
||||
- Reticulum uses a **single process-wide instance**. Only one Reticulum config
|
||||
entry is allowed, and because RNS/LXMF cannot be cleanly torn down inside a
|
||||
running process, the running stack is reused across reloads. Changing the
|
||||
host/port (or recovering from a failed first start) therefore requires a
|
||||
**full Home Assistant restart**, not just an integration reload — a reload
|
||||
reuses the already-running stack. Option changes that don't touch the stack
|
||||
apply on reload.
|
||||
- The identity is stored in `config/reticulum/identity` — back it up to keep the
|
||||
same address.
|
||||
- Reticulum uses a **single process-wide instance**, so only one Reticulum
|
||||
config entry (the stack) is allowed; add multiple **identities** as sub-entries
|
||||
under it. Because RNS/LXMF cannot be cleanly torn down inside a running
|
||||
process, the running stack and each identity's router are reused across
|
||||
reloads. Changing the host/port (or recovering from a failed first start)
|
||||
therefore requires a **full Home Assistant restart**, not just a reload.
|
||||
- Each identity is stored in `config/reticulum/identities/<subentry_id>/identity`
|
||||
— back it up to keep the same address. **Regenerating** an identity overwrites
|
||||
it with a new address.
|
||||
- RNS and LXMF are standalone-daemon-style libraries — both install
|
||||
SIGINT/SIGTERM handlers and `atexit` persistence in their constructors. To
|
||||
embed them cleanly the integration suppresses those signal handlers during
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"""The Reticulum integration."""
|
||||
"""The Reticulum integration.
|
||||
|
||||
One config entry = the shared Reticulum stack (RNS + TCP interface).
|
||||
Each config subentry (type ``identity``) = an independent LXMF identity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,7 +12,7 @@ from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
@@ -24,6 +28,7 @@ from .const import (
|
||||
ATTR_CONTENT,
|
||||
ATTR_DESTINATION,
|
||||
ATTR_FIELDS,
|
||||
ATTR_IDENTITY,
|
||||
ATTR_MAX_MESSAGES,
|
||||
ATTR_METHOD,
|
||||
ATTR_TITLE,
|
||||
@@ -55,8 +60,13 @@ from .const import (
|
||||
SERVICE_SET_PROPAGATION_NODE,
|
||||
SERVICE_SYNC_PROPAGATION,
|
||||
STORAGE_SUBDIR,
|
||||
SUBENTRY_TYPE_IDENTITY,
|
||||
)
|
||||
from .reticulum_client import (
|
||||
IdentityManager,
|
||||
ReticulumError,
|
||||
ReticulumStack,
|
||||
)
|
||||
from .reticulum_client import ReticulumError, ReticulumManager
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,86 +77,99 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
# Interface-status poll: RNS has no push for link up/down, so poll cheaply.
|
||||
INTERFACE_POLL = timedelta(seconds=15)
|
||||
|
||||
|
||||
class RuntimeData:
|
||||
"""Container stored on the config entry."""
|
||||
"""Runtime objects for the hub entry."""
|
||||
|
||||
def __init__(self, manager: ReticulumManager) -> None:
|
||||
self.manager = manager
|
||||
def __init__(self, stack: ReticulumStack) -> None:
|
||||
self.stack = stack
|
||||
self.identities: dict[str, IdentityManager] = {}
|
||||
self.unsubs: list[Any] = []
|
||||
# Maps a peer address -> the HA conversation_id we opened for it, so
|
||||
# each remote user keeps its own Assist conversation context.
|
||||
# f"{subentry_id}:{source}" -> HA conversation_id
|
||||
self.conversation_ids: dict[str, str] = {}
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Reticulum from a config entry."""
|
||||
options = entry.options
|
||||
"""Set up the Reticulum stack and its identities."""
|
||||
storage_dir = hass.config.path(STORAGE_SUBDIR)
|
||||
|
||||
manager = ReticulumManager(
|
||||
stack = ReticulumStack(
|
||||
hass,
|
||||
storage_dir,
|
||||
entry.entry_id,
|
||||
target_host=entry.data[CONF_TARGET_HOST],
|
||||
target_port=entry.data[CONF_TARGET_PORT],
|
||||
interface_name=entry.data.get(CONF_INTERFACE_NAME, DEFAULT_INTERFACE_NAME),
|
||||
display_name=entry.data.get(CONF_DISPLAY_NAME, DEFAULT_DISPLAY_NAME),
|
||||
loglevel=int(options.get(CONF_LOGLEVEL, DEFAULT_LOGLEVEL)),
|
||||
loglevel=int(entry.options.get(CONF_LOGLEVEL, DEFAULT_LOGLEVEL)),
|
||||
)
|
||||
|
||||
try:
|
||||
await manager.async_start()
|
||||
await stack.async_start()
|
||||
except Exception as err: # noqa: BLE001
|
||||
raise ConfigEntryNotReady(f"Could not start Reticulum: {err}") from err
|
||||
|
||||
runtime = RuntimeData(manager)
|
||||
runtime = RuntimeData(stack)
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime
|
||||
|
||||
# Wire the incoming-message pipeline (assist bridge + event).
|
||||
manager.incoming_handler = _make_incoming_handler(hass, entry, runtime)
|
||||
# Start each identity subentry.
|
||||
for subentry in entry.subentries.values():
|
||||
if subentry.subentry_type != SUBENTRY_TYPE_IDENTITY:
|
||||
continue
|
||||
display_name = subentry.data.get(CONF_DISPLAY_NAME) or subentry.title
|
||||
manager = IdentityManager(
|
||||
hass, stack, subentry.subentry_id, display_name=display_name
|
||||
)
|
||||
try:
|
||||
await manager.async_start()
|
||||
except Exception as err: # noqa: BLE001
|
||||
raise ConfigEntryNotReady(
|
||||
f"Could not start identity '{display_name}': {err}"
|
||||
) from err
|
||||
manager.incoming_handler = _make_incoming_handler(
|
||||
hass, entry, subentry, runtime, manager
|
||||
)
|
||||
runtime.identities[subentry.subentry_id] = manager
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
# Announce on startup so peers learn our address, then periodically.
|
||||
# Per-identity announce + periodic schedules.
|
||||
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
|
||||
await _safe_announce(manager)
|
||||
_schedule_periodic(hass, entry, runtime)
|
||||
_schedule_identity(hass, runtime, manager, subentry)
|
||||
|
||||
# Poll interface status. refresh_interface_status is a @callback, so pass it
|
||||
# directly (a lambda would be a plain sync job and get run in an executor
|
||||
# thread, where its async_dispatcher_send call is not thread-safe).
|
||||
# Hub interface-status poll.
|
||||
runtime.unsubs.append(
|
||||
async_track_time_interval(
|
||||
hass, manager.refresh_interface_status, INTERFACE_POLL
|
||||
hass, stack.refresh_interface_status, INTERFACE_POLL
|
||||
)
|
||||
)
|
||||
|
||||
# Persist RNS/LXMF state off-loop when Home Assistant stops (we unregister
|
||||
# RNS's own atexit handlers, which would otherwise do this with blocking I/O
|
||||
# on the event loop and detach HA's stdout).
|
||||
async def _on_ha_stop(_event) -> None:
|
||||
await manager.async_persist()
|
||||
await stack.async_persist()
|
||||
|
||||
runtime.unsubs.append(
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_ha_stop)
|
||||
)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(_async_options_updated))
|
||||
entry.async_on_unload(entry.add_update_listener(_async_entry_updated))
|
||||
_async_register_services(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
"""Unload the hub entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
runtime: RuntimeData | None = hass.data.get(DOMAIN, {}).get(entry.entry_id)
|
||||
if runtime is not None:
|
||||
for unsub in runtime.unsubs:
|
||||
unsub()
|
||||
await runtime.manager.async_stop()
|
||||
for manager in runtime.identities.values():
|
||||
await manager.async_stop()
|
||||
await runtime.stack.async_stop()
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id, None)
|
||||
if not hass.data[DOMAIN]:
|
||||
@@ -154,25 +177,26 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Reload the entry so option changes take effect."""
|
||||
async def _async_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Reload when options or subentries change."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Periodic tasks
|
||||
# Periodic scheduling
|
||||
# ---------------------------------------------------------------------------
|
||||
def _schedule_periodic(
|
||||
hass: HomeAssistant, entry: ConfigEntry, runtime: RuntimeData
|
||||
def _schedule_identity(
|
||||
hass: HomeAssistant,
|
||||
runtime: RuntimeData,
|
||||
manager: IdentityManager,
|
||||
subentry: ConfigSubentry,
|
||||
) -> None:
|
||||
manager = runtime.manager
|
||||
announce_interval = entry.options.get(
|
||||
CONF_ANNOUNCE_INTERVAL, DEFAULT_ANNOUNCE_INTERVAL
|
||||
)
|
||||
data = subentry.data
|
||||
announce_interval = data.get(CONF_ANNOUNCE_INTERVAL, DEFAULT_ANNOUNCE_INTERVAL)
|
||||
if announce_interval and announce_interval > 0:
|
||||
|
||||
async def _do_announce(_now) -> None:
|
||||
await _safe_announce(manager)
|
||||
async def _do_announce(_now, _mgr=manager) -> None:
|
||||
await _safe_announce(_mgr)
|
||||
|
||||
runtime.unsubs.append(
|
||||
async_track_time_interval(
|
||||
@@ -180,15 +204,15 @@ def _schedule_periodic(
|
||||
)
|
||||
)
|
||||
|
||||
sync_interval = entry.options.get(CONF_SYNC_INTERVAL, DEFAULT_SYNC_INTERVAL)
|
||||
propagation_node = entry.options.get(CONF_PROPAGATION_NODE)
|
||||
propagation_node = data.get(CONF_PROPAGATION_NODE)
|
||||
if propagation_node:
|
||||
hass.async_create_task(_set_propagation(manager, propagation_node))
|
||||
sync_interval = data.get(CONF_SYNC_INTERVAL, DEFAULT_SYNC_INTERVAL)
|
||||
if sync_interval and sync_interval > 0:
|
||||
|
||||
async def _do_sync(_now) -> None:
|
||||
async def _do_sync(_now, _mgr=manager) -> None:
|
||||
try:
|
||||
await manager.async_sync_propagation()
|
||||
await _mgr.async_sync_propagation()
|
||||
except ReticulumError as err:
|
||||
_LOGGER.warning("Propagation sync failed: %s", err)
|
||||
|
||||
@@ -199,14 +223,14 @@ def _schedule_periodic(
|
||||
)
|
||||
|
||||
|
||||
async def _set_propagation(manager: ReticulumManager, node: str) -> None:
|
||||
async def _set_propagation(manager: IdentityManager, node: str) -> None:
|
||||
try:
|
||||
await manager.async_set_propagation_node(node)
|
||||
except ReticulumError as err:
|
||||
_LOGGER.warning("Could not set propagation node: %s", err)
|
||||
|
||||
|
||||
async def _safe_announce(manager: ReticulumManager) -> None:
|
||||
async def _safe_announce(manager: IdentityManager) -> None:
|
||||
try:
|
||||
await manager.async_announce()
|
||||
except ReticulumError as err:
|
||||
@@ -214,82 +238,82 @@ async def _safe_announce(manager: ReticulumManager) -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Incoming message pipeline
|
||||
# Incoming message pipeline (per identity)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _make_incoming_handler(
|
||||
hass: HomeAssistant, entry: ConfigEntry, runtime: RuntimeData
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
subentry: ConfigSubentry,
|
||||
runtime: RuntimeData,
|
||||
manager: IdentityManager,
|
||||
):
|
||||
async def _handle(payload: dict[str, Any]) -> None:
|
||||
source = payload["source"]
|
||||
|
||||
# Always surface the message as an event for automations.
|
||||
hass.bus.async_fire(EVENT_MESSAGE_RECEIVED, payload)
|
||||
|
||||
options = entry.options
|
||||
allow_all = options.get(CONF_ALLOW_ALL, True)
|
||||
data = subentry.data
|
||||
allow_all = data.get(CONF_ALLOW_ALL, True)
|
||||
allowed = {
|
||||
a.strip().lower()
|
||||
for a in options.get(CONF_ALLOWED_IDENTITIES, [])
|
||||
for a in data.get(CONF_ALLOWED_IDENTITIES, [])
|
||||
if a and a.strip()
|
||||
}
|
||||
if not allow_all and source.lower() not in allowed:
|
||||
_LOGGER.debug("Ignoring message from non-allowed sender %s", source)
|
||||
return
|
||||
|
||||
# When messages are NOT routed to Assist, optionally auto-reply with the
|
||||
# greeting so senders get an acknowledgement instead of silence.
|
||||
if not options.get(CONF_ENABLE_ASSIST, True):
|
||||
greeting = (options.get(CONF_GREETING) or "").strip()
|
||||
# When not routing to Assist, optionally auto-reply with the greeting.
|
||||
if not data.get(CONF_ENABLE_ASSIST, True):
|
||||
greeting = (data.get(CONF_GREETING) or "").strip()
|
||||
if greeting:
|
||||
await _reply(runtime.manager, source, greeting, "Home Assistant")
|
||||
await _reply(manager, source, greeting, "Home Assistant")
|
||||
return
|
||||
|
||||
text = (payload.get("content") or "").strip()
|
||||
if not text:
|
||||
return
|
||||
|
||||
await _run_assist(hass, entry, runtime, source, text)
|
||||
await _run_assist(hass, subentry, runtime, manager, source, text)
|
||||
|
||||
return _handle
|
||||
|
||||
|
||||
async def _run_assist(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
subentry: ConfigSubentry,
|
||||
runtime: RuntimeData,
|
||||
manager: IdentityManager,
|
||||
source: str,
|
||||
text: str,
|
||||
) -> None:
|
||||
"""Route a message through the Assist conversation pipeline and reply."""
|
||||
from homeassistant.components import conversation # noqa: PLC0415
|
||||
from homeassistant.core import Context # noqa: PLC0415
|
||||
|
||||
options = entry.options
|
||||
agent_id = options.get(CONF_ASSIST_AGENT) or None
|
||||
language = options.get(CONF_ASSIST_LANGUAGE) or hass.config.language
|
||||
data = subentry.data
|
||||
agent_id = data.get(CONF_ASSIST_AGENT) or None
|
||||
language = data.get(CONF_ASSIST_LANGUAGE) or hass.config.language
|
||||
conv_key = f"{subentry.subentry_id}:{source}"
|
||||
|
||||
try:
|
||||
result = await conversation.async_converse(
|
||||
hass=hass,
|
||||
text=text,
|
||||
conversation_id=runtime.conversation_ids.get(source),
|
||||
conversation_id=runtime.conversation_ids.get(conv_key),
|
||||
context=Context(),
|
||||
language=language,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.exception("Assist conversation failed")
|
||||
await _reply(runtime.manager, source, f"⚠️ Assist error: {err}", "Error")
|
||||
await _reply(manager, source, f"⚠️ Assist error: {err}", "Error")
|
||||
return
|
||||
|
||||
# Remember the conversation so this peer keeps context.
|
||||
conv_id = getattr(result, "conversation_id", None)
|
||||
if conv_id:
|
||||
runtime.conversation_ids[source] = conv_id
|
||||
runtime.conversation_ids[conv_key] = conv_id
|
||||
|
||||
reply = _extract_speech(result)
|
||||
if reply:
|
||||
await _reply(runtime.manager, source, reply, "Assist")
|
||||
await _reply(manager, source, reply, "Assist")
|
||||
|
||||
|
||||
def _extract_speech(result: Any) -> str | None:
|
||||
@@ -303,12 +327,12 @@ def _extract_speech(result: Any) -> str | None:
|
||||
|
||||
|
||||
async def _reply(
|
||||
manager: ReticulumManager, destination: str, content: str, title: str
|
||||
manager: IdentityManager, destination: str, content: str, title: str
|
||||
) -> None:
|
||||
try:
|
||||
await manager.async_send_message(destination, content, title=title)
|
||||
except ReticulumError as err:
|
||||
_LOGGER.warning("Could not send Assist reply to %s: %s", destination, err)
|
||||
_LOGGER.warning("Could not send reply to %s: %s", destination, err)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -321,20 +345,42 @@ SEND_MESSAGE_SCHEMA = vol.Schema(
|
||||
vol.Optional(ATTR_TITLE, default=""): cv.string,
|
||||
vol.Optional(ATTR_METHOD, default="direct"): vol.In(DELIVERY_METHODS),
|
||||
vol.Optional(ATTR_FIELDS): dict,
|
||||
vol.Optional(ATTR_IDENTITY): cv.string,
|
||||
}
|
||||
)
|
||||
REQUEST_PATH_SCHEMA = vol.Schema({vol.Required(ATTR_DESTINATION): cv.string})
|
||||
SET_PROPAGATION_SCHEMA = vol.Schema({vol.Required(ATTR_DESTINATION): cv.string})
|
||||
SYNC_SCHEMA = vol.Schema(
|
||||
{vol.Optional(ATTR_MAX_MESSAGES): vol.All(vol.Coerce(int), vol.Range(min=1))}
|
||||
REQUEST_PATH_SCHEMA = vol.Schema(
|
||||
{vol.Required(ATTR_DESTINATION): cv.string, vol.Optional(ATTR_IDENTITY): cv.string}
|
||||
)
|
||||
SET_PROPAGATION_SCHEMA = vol.Schema(
|
||||
{vol.Required(ATTR_DESTINATION): cv.string, vol.Optional(ATTR_IDENTITY): cv.string}
|
||||
)
|
||||
SYNC_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_MAX_MESSAGES): vol.All(vol.Coerce(int), vol.Range(min=1)),
|
||||
vol.Optional(ATTR_IDENTITY): cv.string,
|
||||
}
|
||||
)
|
||||
ANNOUNCE_SCHEMA = vol.Schema({vol.Optional(ATTR_IDENTITY): cv.string})
|
||||
|
||||
|
||||
def _first_manager(hass: HomeAssistant) -> ReticulumManager:
|
||||
def _resolve_manager(hass: HomeAssistant, identity: str | None) -> IdentityManager:
|
||||
"""Pick an identity manager by name/address, or the first one."""
|
||||
data: dict[str, RuntimeData] = hass.data.get(DOMAIN, {})
|
||||
if not data:
|
||||
raise HomeAssistantError("Reticulum is not configured")
|
||||
return next(iter(data.values())).manager
|
||||
managers: list[IdentityManager] = []
|
||||
for runtime in data.values():
|
||||
managers.extend(runtime.identities.values())
|
||||
if not managers:
|
||||
raise HomeAssistantError("No Reticulum identity is configured")
|
||||
if identity:
|
||||
key = identity.strip().lower()
|
||||
for manager in managers:
|
||||
if (
|
||||
manager.display_name.lower() == key
|
||||
or (manager.state.lxmf_address or "").lower() == key
|
||||
):
|
||||
return manager
|
||||
raise HomeAssistantError(f"No Reticulum identity matches '{identity}'")
|
||||
return managers[0]
|
||||
|
||||
|
||||
def _async_register_services(hass: HomeAssistant) -> None:
|
||||
@@ -342,7 +388,7 @@ def _async_register_services(hass: HomeAssistant) -> None:
|
||||
return
|
||||
|
||||
async def _send(call: ServiceCall) -> ServiceResponse:
|
||||
manager = _first_manager(hass)
|
||||
manager = _resolve_manager(hass, call.data.get(ATTR_IDENTITY))
|
||||
try:
|
||||
message_hash = await manager.async_send_message(
|
||||
call.data[ATTR_DESTINATION],
|
||||
@@ -353,33 +399,33 @@ def _async_register_services(hass: HomeAssistant) -> None:
|
||||
)
|
||||
except ReticulumError as err:
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
return {"message_hash": message_hash}
|
||||
return {"message_hash": message_hash, "identity": manager.display_name}
|
||||
|
||||
async def _announce(call: ServiceCall) -> None:
|
||||
manager = _resolve_manager(hass, call.data.get(ATTR_IDENTITY))
|
||||
try:
|
||||
await _first_manager(hass).async_announce()
|
||||
await manager.async_announce()
|
||||
except ReticulumError as err:
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
|
||||
async def _request_path(call: ServiceCall) -> None:
|
||||
manager = _resolve_manager(hass, call.data.get(ATTR_IDENTITY))
|
||||
try:
|
||||
await _first_manager(hass).async_request_path(call.data[ATTR_DESTINATION])
|
||||
await manager.async_request_path(call.data[ATTR_DESTINATION])
|
||||
except ReticulumError as err:
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
|
||||
async def _set_propagation(call: ServiceCall) -> None:
|
||||
manager = _resolve_manager(hass, call.data.get(ATTR_IDENTITY))
|
||||
try:
|
||||
await _first_manager(hass).async_set_propagation_node(
|
||||
call.data[ATTR_DESTINATION]
|
||||
)
|
||||
await manager.async_set_propagation_node(call.data[ATTR_DESTINATION])
|
||||
except ReticulumError as err:
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
|
||||
async def _sync(call: ServiceCall) -> None:
|
||||
manager = _resolve_manager(hass, call.data.get(ATTR_IDENTITY))
|
||||
try:
|
||||
await _first_manager(hass).async_sync_propagation(
|
||||
call.data.get(ATTR_MAX_MESSAGES)
|
||||
)
|
||||
await manager.async_sync_propagation(call.data.get(ATTR_MAX_MESSAGES))
|
||||
except ReticulumError as err:
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
|
||||
@@ -390,7 +436,9 @@ def _async_register_services(hass: HomeAssistant) -> None:
|
||||
schema=SEND_MESSAGE_SCHEMA,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
hass.services.async_register(DOMAIN, SERVICE_ANNOUNCE, _announce)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_ANNOUNCE, _announce, schema=ANNOUNCE_SCHEMA
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_REQUEST_PATH, _request_path, schema=REQUEST_PATH_SCHEMA
|
||||
)
|
||||
|
||||
@@ -11,8 +11,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import ReticulumEntity
|
||||
from .reticulum_client import ReticulumManager
|
||||
from .entity import ReticulumStackEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -20,21 +19,21 @@ async def async_setup_entry(
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Reticulum connectivity binary sensor."""
|
||||
manager: ReticulumManager = hass.data[DOMAIN][entry.entry_id].manager
|
||||
async_add_entities([ReticulumConnectivity(manager, entry.entry_id)])
|
||||
"""Set up the hub connectivity binary sensor."""
|
||||
runtime = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities([ReticulumConnectivity(runtime.stack, entry)])
|
||||
|
||||
|
||||
class ReticulumConnectivity(ReticulumEntity, BinarySensorEntity):
|
||||
"""Reports whether the outbound Reticulum interface is online."""
|
||||
class ReticulumConnectivity(ReticulumStackEntity, BinarySensorEntity):
|
||||
"""Whether the outbound Reticulum interface is online."""
|
||||
|
||||
_attr_translation_key = "connected"
|
||||
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
|
||||
|
||||
def __init__(self, manager: ReticulumManager, entry_id: str) -> None:
|
||||
super().__init__(manager, entry_id)
|
||||
self._attr_unique_id = f"{entry_id}_connected"
|
||||
def __init__(self, stack, entry) -> None:
|
||||
super().__init__(stack, entry)
|
||||
self._attr_unique_id = f"{entry.entry_id}_connected"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
return self._manager.state.interface_online
|
||||
return self._stack.state.interface_online
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
"""Button platform for Reticulum."""
|
||||
"""Button platform for Reticulum (per identity)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import ReticulumEntity
|
||||
from .reticulum_client import ReticulumError, ReticulumManager
|
||||
from .const import DOMAIN, SUBENTRY_TYPE_IDENTITY
|
||||
from .entity import ReticulumIdentityEntity
|
||||
from .reticulum_client import ReticulumError
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -21,25 +21,33 @@ async def async_setup_entry(
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Reticulum buttons."""
|
||||
manager: ReticulumManager = hass.data[DOMAIN][entry.entry_id].manager
|
||||
"""Set up per-identity buttons."""
|
||||
runtime = hass.data[DOMAIN][entry.entry_id]
|
||||
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(
|
||||
[
|
||||
ReticulumAnnounceButton(manager, entry.entry_id),
|
||||
ReticulumSyncButton(manager, entry.entry_id),
|
||||
]
|
||||
ReticulumAnnounceButton(manager, entry, subentry),
|
||||
ReticulumSyncButton(manager, entry, subentry),
|
||||
ReticulumRegenerateButton(manager, entry, subentry),
|
||||
],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class ReticulumAnnounceButton(ReticulumEntity, ButtonEntity):
|
||||
"""Announce our LXMF destination on demand."""
|
||||
class ReticulumAnnounceButton(ReticulumIdentityEntity, ButtonEntity):
|
||||
"""Announce this identity's LXMF destination."""
|
||||
|
||||
_attr_translation_key = "announce"
|
||||
_attr_icon = "mdi:bullhorn"
|
||||
|
||||
def __init__(self, manager: ReticulumManager, entry_id: str) -> None:
|
||||
super().__init__(manager, entry_id)
|
||||
self._attr_unique_id = f"{entry_id}_announce"
|
||||
def __init__(self, manager, entry, subentry) -> None:
|
||||
super().__init__(manager, entry, subentry)
|
||||
self._attr_unique_id = f"{subentry.subentry_id}_announce"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
try:
|
||||
@@ -48,18 +56,42 @@ class ReticulumAnnounceButton(ReticulumEntity, ButtonEntity):
|
||||
_LOGGER.warning("Announce failed: %s", err)
|
||||
|
||||
|
||||
class ReticulumSyncButton(ReticulumEntity, ButtonEntity):
|
||||
"""Pull queued messages from the configured propagation node."""
|
||||
class ReticulumSyncButton(ReticulumIdentityEntity, ButtonEntity):
|
||||
"""Pull queued messages from the propagation node."""
|
||||
|
||||
_attr_translation_key = "sync"
|
||||
_attr_icon = "mdi:sync"
|
||||
|
||||
def __init__(self, manager: ReticulumManager, entry_id: str) -> None:
|
||||
super().__init__(manager, entry_id)
|
||||
self._attr_unique_id = f"{entry_id}_sync"
|
||||
def __init__(self, manager, entry, subentry) -> None:
|
||||
super().__init__(manager, entry, subentry)
|
||||
self._attr_unique_id = f"{subentry.subentry_id}_sync"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
try:
|
||||
await self._manager.async_sync_propagation()
|
||||
except ReticulumError as err:
|
||||
_LOGGER.warning("Propagation sync failed: %s", err)
|
||||
|
||||
|
||||
class ReticulumRegenerateButton(ReticulumIdentityEntity, ButtonEntity):
|
||||
"""Generate a brand-new identity/address for this identity."""
|
||||
|
||||
_attr_translation_key = "regenerate"
|
||||
_attr_icon = "mdi:key-change"
|
||||
_attr_device_class = ButtonDeviceClass.RESTART
|
||||
|
||||
def __init__(self, manager, entry, subentry) -> None:
|
||||
super().__init__(manager, entry, subentry)
|
||||
self._attr_unique_id = f"{subentry.subentry_id}_regenerate"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
try:
|
||||
address = await self._manager.async_regenerate_identity()
|
||||
except ReticulumError as err:
|
||||
_LOGGER.warning("Identity regeneration failed: %s", err)
|
||||
return
|
||||
_LOGGER.info(
|
||||
"Identity '%s' regenerated; new address %s",
|
||||
self._manager.display_name,
|
||||
address,
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
|
||||
@@ -43,10 +43,18 @@ DEFAULT_LOGLEVEL: Final = 3 # RNS.LOG_NOTICE
|
||||
|
||||
DELIVERY_METHODS: Final = ["direct", "opportunistic", "propagated"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subentries
|
||||
# ---------------------------------------------------------------------------
|
||||
# The config entry is the shared Reticulum stack (RNS + TCP interface); each
|
||||
# LXMF identity is a config subentry of this type.
|
||||
SUBENTRY_TYPE_IDENTITY: Final = "identity"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage
|
||||
# ---------------------------------------------------------------------------
|
||||
STORAGE_SUBDIR: Final = "reticulum"
|
||||
IDENTITIES_SUBDIR: Final = "identities"
|
||||
IDENTITY_FILENAME: Final = "identity"
|
||||
CONFIG_FILENAME: Final = "config"
|
||||
ATTACHMENTS_SUBDIR: Final = "attachments"
|
||||
@@ -54,7 +62,19 @@ ATTACHMENTS_SUBDIR: Final = "attachments"
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatcher signals
|
||||
# ---------------------------------------------------------------------------
|
||||
SIGNAL_STATE_UPDATED: Final = f"{DOMAIN}_state_updated"
|
||||
# Per-stack and per-identity update signals; suffix with entry_id / subentry_id.
|
||||
SIGNAL_STACK_PREFIX: Final = f"{DOMAIN}_stack"
|
||||
SIGNAL_IDENTITY_PREFIX: Final = f"{DOMAIN}_identity"
|
||||
|
||||
|
||||
def stack_signal(entry_id: str) -> str:
|
||||
"""Dispatcher signal for a stack (hub) state update."""
|
||||
return f"{SIGNAL_STACK_PREFIX}_{entry_id}"
|
||||
|
||||
|
||||
def identity_signal(subentry_id: str) -> str:
|
||||
"""Dispatcher signal for an identity state update."""
|
||||
return f"{SIGNAL_IDENTITY_PREFIX}_{subentry_id}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Events
|
||||
@@ -79,6 +99,9 @@ ATTR_TITLE: Final = "title"
|
||||
ATTR_METHOD: Final = "method"
|
||||
ATTR_FIELDS: Final = "fields"
|
||||
ATTR_MAX_MESSAGES: Final = "max_messages"
|
||||
# Which identity a domain service should act through (title or LXMF address).
|
||||
# Omit to use the only/first identity.
|
||||
ATTR_IDENTITY: Final = "identity"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LXMF address geometry
|
||||
|
||||
@@ -16,26 +16,41 @@ TO_REDACT = {CONF_DEFAULT_RECIPIENT, CONF_ALLOWED_IDENTITIES}
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
"""Return diagnostics for the hub entry and its identities."""
|
||||
runtime = hass.data[DOMAIN][entry.entry_id]
|
||||
state = runtime.manager.state
|
||||
stack = runtime.stack.state
|
||||
|
||||
identities = []
|
||||
for subentry_id, manager in runtime.identities.items():
|
||||
s = manager.state
|
||||
subentry = entry.subentries.get(subentry_id)
|
||||
identities.append(
|
||||
{
|
||||
"title": subentry.title if subentry else None,
|
||||
"options": async_redact_data(
|
||||
dict(subentry.data) if subentry else {}, TO_REDACT
|
||||
),
|
||||
"lxmf_address": s.lxmf_address,
|
||||
"display_name": s.display_name,
|
||||
"messages_received": s.messages_received,
|
||||
"messages_sent": s.messages_sent,
|
||||
"messages_failed": s.messages_failed,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"entry": {
|
||||
"data": dict(entry.data),
|
||||
"options": async_redact_data(dict(entry.options), TO_REDACT),
|
||||
},
|
||||
"state": {
|
||||
"started": state.started,
|
||||
"interface_online": state.interface_online,
|
||||
"lxmf_address": state.lxmf_address,
|
||||
"display_name": state.display_name,
|
||||
"messages_received": state.messages_received,
|
||||
"messages_sent": state.messages_sent,
|
||||
"messages_failed": state.messages_failed,
|
||||
"peer_count": len(state.peers),
|
||||
"entry": {"data": dict(entry.data), "options": dict(entry.options)},
|
||||
"stack": {
|
||||
"started": stack.started,
|
||||
"interface_online": stack.interface_online,
|
||||
"rxb": stack.tel_rxb,
|
||||
"txb": stack.tel_txb,
|
||||
"bitrate": stack.tel_bitrate,
|
||||
"peer_count": len(stack.peers),
|
||||
"peers": [
|
||||
{"name": p.display_name, "hops": p.hops, "stamp_cost": p.stamp_cost}
|
||||
for p in state.peers.values()
|
||||
for p in stack.peers.values()
|
||||
],
|
||||
},
|
||||
"identities": identities,
|
||||
}
|
||||
|
||||
@@ -1,44 +1,80 @@
|
||||
"""Base entity for the Reticulum integration."""
|
||||
"""Base entities for the Reticulum integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import DeviceInfo, Entity
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN, SIGNAL_STATE_UPDATED
|
||||
from .reticulum_client import ReticulumManager
|
||||
from .const import DOMAIN, identity_signal, stack_signal
|
||||
from .reticulum_client import IdentityManager, ReticulumStack
|
||||
|
||||
|
||||
class ReticulumEntity(Entity):
|
||||
"""Base class wiring entities to the manager state updates."""
|
||||
class ReticulumStackEntity(Entity):
|
||||
"""Base for hub (stack-level) entities."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, manager: ReticulumManager, entry_id: str) -> None:
|
||||
self._manager = manager
|
||||
self._entry_id = entry_id
|
||||
def __init__(self, stack: ReticulumStack, entry: ConfigEntry) -> None:
|
||||
self._stack = stack
|
||||
self._entry = entry
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry_id)},
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
name="Reticulum",
|
||||
manufacturer="Reticulum Network Stack",
|
||||
model="LXMF Peer",
|
||||
sw_version=manager.state.lxmf_address or None,
|
||||
model="RNS stack",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
configuration_url="https://reticulum.network/",
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to state-update signals."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass, SIGNAL_STATE_UPDATED, self._handle_update
|
||||
self.hass, stack_signal(self._entry.entry_id), self._handle_update
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_update(self) -> None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class ReticulumIdentityEntity(Entity):
|
||||
"""Base for per-identity (subentry) entities."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manager: IdentityManager,
|
||||
entry: ConfigEntry,
|
||||
subentry: ConfigSubentry,
|
||||
) -> None:
|
||||
self._manager = manager
|
||||
self._entry = entry
|
||||
self._subentry = subentry
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="Reticulum Network Stack",
|
||||
model="LXMF identity",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
via_device=(DOMAIN, entry.entry_id),
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
identity_signal(self._subentry.subentry_id),
|
||||
self._handle_update,
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_update(self) -> None:
|
||||
# Decorated with @callback so the dispatcher runs it on the event loop
|
||||
# (a plain sync callback would be offloaded to an executor thread, and
|
||||
# async_write_ha_state is not thread-safe).
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"loggers": ["RNS", "LXMF"],
|
||||
"requirements": ["rns>=0.9.0", "lxmf>=0.6.0"],
|
||||
"single_config_entry": true,
|
||||
"version": "1.0.8"
|
||||
"version": "1.1.0"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Notify platform for Reticulum.
|
||||
"""Notify platform for Reticulum (per identity).
|
||||
|
||||
Exposes a ``notify`` entity that sends an LXMF message to the configured
|
||||
default recipient. For arbitrary destinations use the ``reticulum.send_message``
|
||||
service instead.
|
||||
Each identity exposes a ``notify`` entity that sends an LXMF message from that
|
||||
identity to its configured default recipient. For arbitrary destinations use
|
||||
the ``reticulum.send_message`` service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,9 +13,15 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import CONF_DEFAULT_RECIPIENT, CONF_DELIVERY_METHOD, DEFAULT_DELIVERY_METHOD, DOMAIN
|
||||
from .entity import ReticulumEntity
|
||||
from .reticulum_client import ReticulumError, ReticulumManager
|
||||
from .const import (
|
||||
CONF_DEFAULT_RECIPIENT,
|
||||
CONF_DELIVERY_METHOD,
|
||||
DEFAULT_DELIVERY_METHOD,
|
||||
DOMAIN,
|
||||
SUBENTRY_TYPE_IDENTITY,
|
||||
)
|
||||
from .entity import ReticulumIdentityEntity
|
||||
from .reticulum_client import ReticulumError
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -23,32 +29,42 @@ async def async_setup_entry(
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Reticulum notify entity."""
|
||||
manager: ReticulumManager = hass.data[DOMAIN][entry.entry_id].manager
|
||||
async_add_entities([ReticulumNotify(manager, entry)])
|
||||
"""Set up per-identity notify entities."""
|
||||
runtime = hass.data[DOMAIN][entry.entry_id]
|
||||
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(
|
||||
[ReticulumNotify(manager, entry, subentry)],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class ReticulumNotify(ReticulumEntity, NotifyEntity):
|
||||
"""Send LXMF messages to the default recipient."""
|
||||
class ReticulumNotify(ReticulumIdentityEntity, NotifyEntity):
|
||||
"""Send LXMF messages from this identity to its default recipient."""
|
||||
|
||||
_attr_translation_key = "message"
|
||||
_attr_icon = "mdi:message-fast"
|
||||
_attr_supported_features = NotifyEntityFeature.TITLE
|
||||
|
||||
def __init__(self, manager: ReticulumManager, entry: ConfigEntry) -> None:
|
||||
super().__init__(manager, entry.entry_id)
|
||||
self._entry = entry
|
||||
self._attr_unique_id = f"{entry.entry_id}_notify"
|
||||
def __init__(self, manager, entry, subentry) -> None:
|
||||
super().__init__(manager, entry, subentry)
|
||||
self._attr_unique_id = f"{subentry.subentry_id}_notify"
|
||||
|
||||
async def async_send_message(self, message: str, title: str | None = None) -> None:
|
||||
recipient = self._entry.options.get(CONF_DEFAULT_RECIPIENT)
|
||||
recipient = self._subentry.data.get(CONF_DEFAULT_RECIPIENT)
|
||||
if not recipient:
|
||||
raise ServiceValidationError(
|
||||
"No default recipient configured. Set one in the Reticulum "
|
||||
"integration options, or use the reticulum.send_message service "
|
||||
"No default recipient configured for this identity. Set one in "
|
||||
"the identity's options, or use the reticulum.send_message service "
|
||||
"with an explicit destination."
|
||||
)
|
||||
method = self._entry.options.get(CONF_DELIVERY_METHOD, DEFAULT_DELIVERY_METHOD)
|
||||
method = self._subentry.data.get(
|
||||
CONF_DELIVERY_METHOD, DEFAULT_DELIVERY_METHOD
|
||||
)
|
||||
try:
|
||||
await self._manager.async_send_message(
|
||||
recipient, message, title=title or "", method=method
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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."""
|
||||
class ReticulumStackSensor(ReticulumStackEntity, SensorEntity):
|
||||
"""Hub sensor."""
|
||||
|
||||
entity_description: ReticulumSensorDescription
|
||||
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:
|
||||
|
||||
@@ -26,13 +26,24 @@ send_message:
|
||||
- direct
|
||||
- opportunistic
|
||||
- propagated
|
||||
identity:
|
||||
required: false
|
||||
example: "Home Assistant"
|
||||
selector:
|
||||
text:
|
||||
fields:
|
||||
required: false
|
||||
example: '{ }'
|
||||
example: "{ }"
|
||||
selector:
|
||||
object:
|
||||
|
||||
announce:
|
||||
fields:
|
||||
identity:
|
||||
required: false
|
||||
example: "Home Assistant"
|
||||
selector:
|
||||
text:
|
||||
|
||||
request_path:
|
||||
fields:
|
||||
@@ -41,6 +52,10 @@ request_path:
|
||||
example: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
|
||||
selector:
|
||||
text:
|
||||
identity:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
set_propagation_node:
|
||||
fields:
|
||||
@@ -49,6 +64,10 @@ set_propagation_node:
|
||||
example: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
|
||||
selector:
|
||||
text:
|
||||
identity:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
sync_propagation:
|
||||
fields:
|
||||
@@ -60,3 +79,7 @@ sync_propagation:
|
||||
min: 1
|
||||
max: 1000
|
||||
mode: box
|
||||
identity:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to Reticulum",
|
||||
"description": "Home Assistant will run a Reticulum instance and connect out to your Reticulum stack over TCP (the neighbouring machine must expose a TCPServerInterface).",
|
||||
"description": "Home Assistant will run a Reticulum instance and connect out to your Reticulum stack over TCP. A first LXMF identity is created automatically; add more later from the integration page.",
|
||||
"data": {
|
||||
"target_host": "Target host",
|
||||
"target_port": "Target port",
|
||||
"display_name": "Display name",
|
||||
"interface_name": "Interface name"
|
||||
"interface_name": "Interface name",
|
||||
"display_name": "First identity name"
|
||||
},
|
||||
"data_description": {
|
||||
"target_host": "Hostname or IP of the machine running the Reticulum TCP server.",
|
||||
"target_port": "TCP port of the Reticulum TCPServerInterface (default 4242).",
|
||||
"display_name": "Name announced to other Reticulum peers for this Home Assistant.",
|
||||
"interface_name": "Label used for this connection in the Reticulum config."
|
||||
"interface_name": "Label used for this connection in the Reticulum config.",
|
||||
"display_name": "Announce name of the first LXMF identity."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -23,36 +23,78 @@
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Reticulum is already configured.",
|
||||
"single_instance_allowed": "Only a single Reticulum instance is allowed."
|
||||
"single_instance_allowed": "Only a single Reticulum stack is allowed. Add more identities to the existing one."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Reticulum options",
|
||||
"title": "Reticulum stack options",
|
||||
"data": {
|
||||
"loglevel": "Reticulum log level (0-7)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"identity": {
|
||||
"initiate_flow": {
|
||||
"user": "Add identity",
|
||||
"reconfigure": "Reconfigure identity"
|
||||
},
|
||||
"entry_type": "LXMF identity",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Add LXMF identity",
|
||||
"data": {
|
||||
"display_name": "Name (announced to peers)",
|
||||
"enable_assist": "Route incoming messages to Assist",
|
||||
"assist_agent": "Assist agent (entity id / agent id, blank = default)",
|
||||
"assist_language": "Assist language (blank = system default)",
|
||||
"assist_agent": "Assist agent",
|
||||
"assist_language": "Assist language",
|
||||
"allow_all_senders": "Reply to any sender",
|
||||
"allowed_identities": "Allowed sender addresses",
|
||||
"default_recipient": "Default recipient address (for the notify entity)",
|
||||
"default_recipient": "Default recipient address",
|
||||
"greeting": "Auto-reply message (when Assist is off)",
|
||||
"delivery_method": "Default delivery method",
|
||||
"announce_interval": "Announce interval",
|
||||
"propagation_node": "Propagation node address",
|
||||
"sync_interval": "Propagation sync interval",
|
||||
"loglevel": "Reticulum log level (0-7)"
|
||||
"sync_interval": "Propagation sync interval"
|
||||
},
|
||||
"data_description": {
|
||||
"enable_assist": "When enabled, incoming LXMF messages are answered by the Assist conversation agent and the reply is sent back over Reticulum.",
|
||||
"display_name": "Contact name announced to other Reticulum peers. Also used as the identity's name in Home Assistant.",
|
||||
"enable_assist": "When enabled, incoming LXMF messages are answered by the Assist conversation agent and the reply is sent back.",
|
||||
"allowed_identities": "One address per line (or comma separated). Only used when 'Reply to any sender' is off.",
|
||||
"default_recipient": "16-byte hex LXMF address that the notify.reticulum entity sends to.",
|
||||
"greeting": "Sent as an automatic reply to incoming messages only when 'Route incoming messages to Assist' is turned off. Leave blank to disable.",
|
||||
"announce_interval": "How often (seconds) to announce our address. 0 disables periodic announces.",
|
||||
"greeting": "Sent as an automatic reply to incoming messages only when 'Route incoming messages to Assist' is off. Leave blank to disable.",
|
||||
"default_recipient": "16-byte hex LXMF address that this identity's notify entity sends to.",
|
||||
"announce_interval": "How often (seconds) to announce this identity. 0 disables periodic announces.",
|
||||
"propagation_node": "Optional LXMF propagation node for store-and-forward delivery.",
|
||||
"sync_interval": "How often (seconds) to pull queued messages from the propagation node. 0 disables."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure LXMF identity",
|
||||
"data": {
|
||||
"display_name": "Name (announced to peers)",
|
||||
"enable_assist": "Route incoming messages to Assist",
|
||||
"assist_agent": "Assist agent",
|
||||
"assist_language": "Assist language",
|
||||
"allow_all_senders": "Reply to any sender",
|
||||
"allowed_identities": "Allowed sender addresses",
|
||||
"default_recipient": "Default recipient address",
|
||||
"greeting": "Auto-reply message (when Assist is off)",
|
||||
"delivery_method": "Default delivery method",
|
||||
"announce_interval": "Announce interval",
|
||||
"propagation_node": "Propagation node address",
|
||||
"sync_interval": "Propagation sync interval"
|
||||
},
|
||||
"data_description": {
|
||||
"display_name": "Contact name announced to other Reticulum peers. Changing it updates future announces.",
|
||||
"greeting": "Sent as an automatic reply to incoming messages only when 'Route incoming messages to Assist' is off. Leave blank to disable."
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"reconfigure_successful": "Identity updated."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -77,7 +119,8 @@
|
||||
},
|
||||
"button": {
|
||||
"announce": { "name": "Announce" },
|
||||
"sync": { "name": "Sync propagation node" }
|
||||
"sync": { "name": "Sync propagation node" },
|
||||
"regenerate": { "name": "Regenerate identity" }
|
||||
},
|
||||
"notify": {
|
||||
"message": { "name": "Send message" }
|
||||
@@ -109,17 +152,12 @@
|
||||
"name": "Destination",
|
||||
"description": "16-byte hex LXMF destination hash of the recipient."
|
||||
},
|
||||
"content": {
|
||||
"name": "Content",
|
||||
"description": "The message body."
|
||||
},
|
||||
"title": {
|
||||
"name": "Title",
|
||||
"description": "Optional message title / subject."
|
||||
},
|
||||
"method": {
|
||||
"name": "Method",
|
||||
"description": "Delivery method to use."
|
||||
"content": { "name": "Content", "description": "The message body." },
|
||||
"title": { "name": "Title", "description": "Optional message title / subject." },
|
||||
"method": { "name": "Method", "description": "Delivery method to use." },
|
||||
"identity": {
|
||||
"name": "Identity",
|
||||
"description": "Which identity to send from (name or LXMF address). Defaults to the first identity."
|
||||
},
|
||||
"fields": {
|
||||
"name": "Fields",
|
||||
@@ -129,7 +167,13 @@
|
||||
},
|
||||
"announce": {
|
||||
"name": "Announce",
|
||||
"description": "Announce this Home Assistant's LXMF destination on the network."
|
||||
"description": "Announce an identity's LXMF destination on the network.",
|
||||
"fields": {
|
||||
"identity": {
|
||||
"name": "Identity",
|
||||
"description": "Which identity to announce. Defaults to the first identity."
|
||||
}
|
||||
}
|
||||
},
|
||||
"request_path": {
|
||||
"name": "Request path",
|
||||
@@ -138,26 +182,38 @@
|
||||
"destination": {
|
||||
"name": "Destination",
|
||||
"description": "Destination hash to resolve a path to."
|
||||
},
|
||||
"identity": {
|
||||
"name": "Identity",
|
||||
"description": "Which identity to act through. Defaults to the first identity."
|
||||
}
|
||||
}
|
||||
},
|
||||
"set_propagation_node": {
|
||||
"name": "Set propagation node",
|
||||
"description": "Set the outbound LXMF propagation node.",
|
||||
"description": "Set the outbound LXMF propagation node for an identity.",
|
||||
"fields": {
|
||||
"destination": {
|
||||
"name": "Destination",
|
||||
"description": "Propagation node destination hash."
|
||||
},
|
||||
"identity": {
|
||||
"name": "Identity",
|
||||
"description": "Which identity to configure. Defaults to the first identity."
|
||||
}
|
||||
}
|
||||
},
|
||||
"sync_propagation": {
|
||||
"name": "Sync propagation node",
|
||||
"description": "Pull queued messages from the configured propagation node.",
|
||||
"description": "Pull queued messages from an identity's propagation node.",
|
||||
"fields": {
|
||||
"max_messages": {
|
||||
"name": "Max messages",
|
||||
"description": "Maximum number of messages to retrieve."
|
||||
},
|
||||
"identity": {
|
||||
"name": "Identity",
|
||||
"description": "Which identity to sync. Defaults to the first identity."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to Reticulum",
|
||||
"description": "Home Assistant will run a Reticulum instance and connect out to your Reticulum stack over TCP (the neighbouring machine must expose a TCPServerInterface).",
|
||||
"description": "Home Assistant will run a Reticulum instance and connect out to your Reticulum stack over TCP. A first LXMF identity is created automatically; add more later from the integration page.",
|
||||
"data": {
|
||||
"target_host": "Target host",
|
||||
"target_port": "Target port",
|
||||
"display_name": "Display name",
|
||||
"interface_name": "Interface name"
|
||||
"interface_name": "Interface name",
|
||||
"display_name": "First identity name"
|
||||
},
|
||||
"data_description": {
|
||||
"target_host": "Hostname or IP of the machine running the Reticulum TCP server.",
|
||||
"target_port": "TCP port of the Reticulum TCPServerInterface (default 4242).",
|
||||
"display_name": "Name announced to other Reticulum peers for this Home Assistant.",
|
||||
"interface_name": "Label used for this connection in the Reticulum config."
|
||||
"interface_name": "Label used for this connection in the Reticulum config.",
|
||||
"display_name": "Announce name of the first LXMF identity."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -23,36 +23,78 @@
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Reticulum is already configured.",
|
||||
"single_instance_allowed": "Only a single Reticulum instance is allowed."
|
||||
"single_instance_allowed": "Only a single Reticulum stack is allowed. Add more identities to the existing one."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Reticulum options",
|
||||
"title": "Reticulum stack options",
|
||||
"data": {
|
||||
"loglevel": "Reticulum log level (0-7)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"identity": {
|
||||
"initiate_flow": {
|
||||
"user": "Add identity",
|
||||
"reconfigure": "Reconfigure identity"
|
||||
},
|
||||
"entry_type": "LXMF identity",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Add LXMF identity",
|
||||
"data": {
|
||||
"display_name": "Name (announced to peers)",
|
||||
"enable_assist": "Route incoming messages to Assist",
|
||||
"assist_agent": "Assist agent (entity id / agent id, blank = default)",
|
||||
"assist_language": "Assist language (blank = system default)",
|
||||
"assist_agent": "Assist agent",
|
||||
"assist_language": "Assist language",
|
||||
"allow_all_senders": "Reply to any sender",
|
||||
"allowed_identities": "Allowed sender addresses",
|
||||
"default_recipient": "Default recipient address (for the notify entity)",
|
||||
"default_recipient": "Default recipient address",
|
||||
"greeting": "Auto-reply message (when Assist is off)",
|
||||
"delivery_method": "Default delivery method",
|
||||
"announce_interval": "Announce interval",
|
||||
"propagation_node": "Propagation node address",
|
||||
"sync_interval": "Propagation sync interval",
|
||||
"loglevel": "Reticulum log level (0-7)"
|
||||
"sync_interval": "Propagation sync interval"
|
||||
},
|
||||
"data_description": {
|
||||
"enable_assist": "When enabled, incoming LXMF messages are answered by the Assist conversation agent and the reply is sent back over Reticulum.",
|
||||
"display_name": "Contact name announced to other Reticulum peers. Also used as the identity's name in Home Assistant.",
|
||||
"enable_assist": "When enabled, incoming LXMF messages are answered by the Assist conversation agent and the reply is sent back.",
|
||||
"allowed_identities": "One address per line (or comma separated). Only used when 'Reply to any sender' is off.",
|
||||
"default_recipient": "16-byte hex LXMF address that the notify.reticulum entity sends to.",
|
||||
"greeting": "Sent as an automatic reply to incoming messages only when 'Route incoming messages to Assist' is turned off. Leave blank to disable.",
|
||||
"announce_interval": "How often (seconds) to announce our address. 0 disables periodic announces.",
|
||||
"greeting": "Sent as an automatic reply to incoming messages only when 'Route incoming messages to Assist' is off. Leave blank to disable.",
|
||||
"default_recipient": "16-byte hex LXMF address that this identity's notify entity sends to.",
|
||||
"announce_interval": "How often (seconds) to announce this identity. 0 disables periodic announces.",
|
||||
"propagation_node": "Optional LXMF propagation node for store-and-forward delivery.",
|
||||
"sync_interval": "How often (seconds) to pull queued messages from the propagation node. 0 disables."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure LXMF identity",
|
||||
"data": {
|
||||
"display_name": "Name (announced to peers)",
|
||||
"enable_assist": "Route incoming messages to Assist",
|
||||
"assist_agent": "Assist agent",
|
||||
"assist_language": "Assist language",
|
||||
"allow_all_senders": "Reply to any sender",
|
||||
"allowed_identities": "Allowed sender addresses",
|
||||
"default_recipient": "Default recipient address",
|
||||
"greeting": "Auto-reply message (when Assist is off)",
|
||||
"delivery_method": "Default delivery method",
|
||||
"announce_interval": "Announce interval",
|
||||
"propagation_node": "Propagation node address",
|
||||
"sync_interval": "Propagation sync interval"
|
||||
},
|
||||
"data_description": {
|
||||
"display_name": "Contact name announced to other Reticulum peers. Changing it updates future announces.",
|
||||
"greeting": "Sent as an automatic reply to incoming messages only when 'Route incoming messages to Assist' is off. Leave blank to disable."
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"reconfigure_successful": "Identity updated."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -77,7 +119,8 @@
|
||||
},
|
||||
"button": {
|
||||
"announce": { "name": "Announce" },
|
||||
"sync": { "name": "Sync propagation node" }
|
||||
"sync": { "name": "Sync propagation node" },
|
||||
"regenerate": { "name": "Regenerate identity" }
|
||||
},
|
||||
"notify": {
|
||||
"message": { "name": "Send message" }
|
||||
@@ -109,17 +152,12 @@
|
||||
"name": "Destination",
|
||||
"description": "16-byte hex LXMF destination hash of the recipient."
|
||||
},
|
||||
"content": {
|
||||
"name": "Content",
|
||||
"description": "The message body."
|
||||
},
|
||||
"title": {
|
||||
"name": "Title",
|
||||
"description": "Optional message title / subject."
|
||||
},
|
||||
"method": {
|
||||
"name": "Method",
|
||||
"description": "Delivery method to use."
|
||||
"content": { "name": "Content", "description": "The message body." },
|
||||
"title": { "name": "Title", "description": "Optional message title / subject." },
|
||||
"method": { "name": "Method", "description": "Delivery method to use." },
|
||||
"identity": {
|
||||
"name": "Identity",
|
||||
"description": "Which identity to send from (name or LXMF address). Defaults to the first identity."
|
||||
},
|
||||
"fields": {
|
||||
"name": "Fields",
|
||||
@@ -129,7 +167,13 @@
|
||||
},
|
||||
"announce": {
|
||||
"name": "Announce",
|
||||
"description": "Announce this Home Assistant's LXMF destination on the network."
|
||||
"description": "Announce an identity's LXMF destination on the network.",
|
||||
"fields": {
|
||||
"identity": {
|
||||
"name": "Identity",
|
||||
"description": "Which identity to announce. Defaults to the first identity."
|
||||
}
|
||||
}
|
||||
},
|
||||
"request_path": {
|
||||
"name": "Request path",
|
||||
@@ -138,26 +182,38 @@
|
||||
"destination": {
|
||||
"name": "Destination",
|
||||
"description": "Destination hash to resolve a path to."
|
||||
},
|
||||
"identity": {
|
||||
"name": "Identity",
|
||||
"description": "Which identity to act through. Defaults to the first identity."
|
||||
}
|
||||
}
|
||||
},
|
||||
"set_propagation_node": {
|
||||
"name": "Set propagation node",
|
||||
"description": "Set the outbound LXMF propagation node.",
|
||||
"description": "Set the outbound LXMF propagation node for an identity.",
|
||||
"fields": {
|
||||
"destination": {
|
||||
"name": "Destination",
|
||||
"description": "Propagation node destination hash."
|
||||
},
|
||||
"identity": {
|
||||
"name": "Identity",
|
||||
"description": "Which identity to configure. Defaults to the first identity."
|
||||
}
|
||||
}
|
||||
},
|
||||
"sync_propagation": {
|
||||
"name": "Sync propagation node",
|
||||
"description": "Pull queued messages from the configured propagation node.",
|
||||
"description": "Pull queued messages from an identity's propagation node.",
|
||||
"fields": {
|
||||
"max_messages": {
|
||||
"name": "Max messages",
|
||||
"description": "Maximum number of messages to retrieve."
|
||||
},
|
||||
"identity": {
|
||||
"name": "Identity",
|
||||
"description": "Which identity to sync. Defaults to the first identity."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Подключение к Reticulum",
|
||||
"description": "Home Assistant запустит экземпляр Reticulum и подключится к вашему стеку Reticulum по TCP (на соседней машине должен быть настроен TCPServerInterface).",
|
||||
"description": "Home Assistant запустит экземпляр Reticulum и подключится к вашему стеку по TCP. Первая LXMF-identity создаётся автоматически; остальные можно добавить позже на странице интеграции.",
|
||||
"data": {
|
||||
"target_host": "Хост",
|
||||
"target_port": "Порт",
|
||||
"display_name": "Отображаемое имя",
|
||||
"interface_name": "Название интерфейса"
|
||||
"interface_name": "Название интерфейса",
|
||||
"display_name": "Имя первой identity"
|
||||
},
|
||||
"data_description": {
|
||||
"target_host": "Имя хоста или IP-адрес машины с TCP-сервером Reticulum.",
|
||||
"target_port": "TCP-порт TCPServerInterface Reticulum (по умолчанию 4242).",
|
||||
"display_name": "Имя, анонсируемое другим узлам Reticulum для этого Home Assistant.",
|
||||
"interface_name": "Метка соединения в конфигурации Reticulum."
|
||||
"interface_name": "Метка соединения в конфигурации Reticulum.",
|
||||
"display_name": "Имя первой LXMF-identity, анонсируемое узлам."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -23,36 +23,78 @@
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Reticulum уже настроен.",
|
||||
"single_instance_allowed": "Допускается только один экземпляр Reticulum."
|
||||
"single_instance_allowed": "Допускается только один стек Reticulum. Добавляйте новые identity к существующему."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Параметры Reticulum",
|
||||
"title": "Параметры стека Reticulum",
|
||||
"data": {
|
||||
"loglevel": "Уровень логирования Reticulum (0-7)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"identity": {
|
||||
"initiate_flow": {
|
||||
"user": "Добавить identity",
|
||||
"reconfigure": "Изменить identity"
|
||||
},
|
||||
"entry_type": "LXMF identity",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Добавить LXMF identity",
|
||||
"data": {
|
||||
"display_name": "Имя (анонсируется узлам)",
|
||||
"enable_assist": "Направлять входящие сообщения в Assist",
|
||||
"assist_agent": "Агент Assist (id сущности/агента, пусто = по умолчанию)",
|
||||
"assist_language": "Язык Assist (пусто = язык системы)",
|
||||
"assist_agent": "Агент Assist",
|
||||
"assist_language": "Язык Assist",
|
||||
"allow_all_senders": "Отвечать любому отправителю",
|
||||
"allowed_identities": "Разрешённые адреса отправителей",
|
||||
"default_recipient": "Адрес получателя по умолчанию (для сущности notify)",
|
||||
"default_recipient": "Адрес получателя по умолчанию",
|
||||
"greeting": "Автоответ (когда Assist выключен)",
|
||||
"delivery_method": "Способ доставки по умолчанию",
|
||||
"announce_interval": "Интервал анонсов",
|
||||
"propagation_node": "Адрес узла ретрансляции (propagation node)",
|
||||
"sync_interval": "Интервал синхронизации с узлом ретрансляции",
|
||||
"loglevel": "Уровень логирования Reticulum (0-7)"
|
||||
"propagation_node": "Адрес узла ретрансляции",
|
||||
"sync_interval": "Интервал синхронизации с узлом ретрансляции"
|
||||
},
|
||||
"data_description": {
|
||||
"enable_assist": "Если включено, входящие сообщения LXMF обрабатываются агентом Assist, а ответ отправляется обратно через Reticulum.",
|
||||
"display_name": "Имя контакта, анонсируемое другим узлам Reticulum. Также используется как имя identity в Home Assistant.",
|
||||
"enable_assist": "Если включено, входящие сообщения LXMF обрабатываются агентом Assist, а ответ отправляется обратно.",
|
||||
"allowed_identities": "По одному адресу в строке (или через запятую). Используется только если «Отвечать любому отправителю» выключено.",
|
||||
"default_recipient": "16-байтовый hex-адрес LXMF, на который отправляет сущность notify.reticulum.",
|
||||
"greeting": "Отправляется автоматически в ответ на входящие сообщения только когда «Направлять входящие сообщения в Assist» выключено. Оставьте пустым, чтобы отключить.",
|
||||
"announce_interval": "Как часто (в секундах) анонсировать наш адрес. 0 отключает периодические анонсы.",
|
||||
"propagation_node": "Необязательный узел ретрансляции LXMF для доставки по принципу store-and-forward.",
|
||||
"default_recipient": "16-байтовый hex-адрес LXMF, на который отправляет сущность notify этой identity.",
|
||||
"announce_interval": "Как часто (в секундах) анонсировать эту identity. 0 отключает периодические анонсы.",
|
||||
"propagation_node": "Необязательный узел ретрансляции LXMF (store-and-forward).",
|
||||
"sync_interval": "Как часто (в секундах) забирать сообщения из очереди узла ретрансляции. 0 отключает."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Изменить LXMF identity",
|
||||
"data": {
|
||||
"display_name": "Имя (анонсируется узлам)",
|
||||
"enable_assist": "Направлять входящие сообщения в Assist",
|
||||
"assist_agent": "Агент Assist",
|
||||
"assist_language": "Язык Assist",
|
||||
"allow_all_senders": "Отвечать любому отправителю",
|
||||
"allowed_identities": "Разрешённые адреса отправителей",
|
||||
"default_recipient": "Адрес получателя по умолчанию",
|
||||
"greeting": "Автоответ (когда Assist выключен)",
|
||||
"delivery_method": "Способ доставки по умолчанию",
|
||||
"announce_interval": "Интервал анонсов",
|
||||
"propagation_node": "Адрес узла ретрансляции",
|
||||
"sync_interval": "Интервал синхронизации с узлом ретрансляции"
|
||||
},
|
||||
"data_description": {
|
||||
"display_name": "Имя контакта, анонсируемое другим узлам Reticulum. Изменение применится к будущим анонсам.",
|
||||
"greeting": "Отправляется автоматически в ответ на входящие сообщения только когда «Направлять входящие сообщения в Assist» выключено. Оставьте пустым, чтобы отключить."
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"reconfigure_successful": "Identity обновлена."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -77,7 +119,8 @@
|
||||
},
|
||||
"button": {
|
||||
"announce": { "name": "Анонсировать" },
|
||||
"sync": { "name": "Синхронизировать узел ретрансляции" }
|
||||
"sync": { "name": "Синхронизировать узел ретрансляции" },
|
||||
"regenerate": { "name": "Перегенерировать identity" }
|
||||
},
|
||||
"notify": {
|
||||
"message": { "name": "Отправить сообщение" }
|
||||
@@ -109,17 +152,12 @@
|
||||
"name": "Получатель",
|
||||
"description": "16-байтовый hex-хэш адреса LXMF получателя."
|
||||
},
|
||||
"content": {
|
||||
"name": "Текст",
|
||||
"description": "Тело сообщения."
|
||||
},
|
||||
"title": {
|
||||
"name": "Заголовок",
|
||||
"description": "Необязательный заголовок/тема сообщения."
|
||||
},
|
||||
"method": {
|
||||
"name": "Способ",
|
||||
"description": "Используемый способ доставки."
|
||||
"content": { "name": "Текст", "description": "Тело сообщения." },
|
||||
"title": { "name": "Заголовок", "description": "Необязательный заголовок/тема сообщения." },
|
||||
"method": { "name": "Способ", "description": "Используемый способ доставки." },
|
||||
"identity": {
|
||||
"name": "Identity",
|
||||
"description": "От какой identity отправлять (имя или адрес LXMF). По умолчанию — первая identity."
|
||||
},
|
||||
"fields": {
|
||||
"name": "Поля",
|
||||
@@ -129,7 +167,13 @@
|
||||
},
|
||||
"announce": {
|
||||
"name": "Анонсировать",
|
||||
"description": "Анонсировать адрес LXMF этого Home Assistant в сети."
|
||||
"description": "Анонсировать адрес LXMF identity в сети.",
|
||||
"fields": {
|
||||
"identity": {
|
||||
"name": "Identity",
|
||||
"description": "Какую identity анонсировать. По умолчанию — первая."
|
||||
}
|
||||
}
|
||||
},
|
||||
"request_path": {
|
||||
"name": "Запросить маршрут",
|
||||
@@ -138,26 +182,38 @@
|
||||
"destination": {
|
||||
"name": "Получатель",
|
||||
"description": "Хэш адреса, до которого нужно найти маршрут."
|
||||
},
|
||||
"identity": {
|
||||
"name": "Identity",
|
||||
"description": "От какой identity действовать. По умолчанию — первая."
|
||||
}
|
||||
}
|
||||
},
|
||||
"set_propagation_node": {
|
||||
"name": "Задать узел ретрансляции",
|
||||
"description": "Задать исходящий узел ретрансляции LXMF.",
|
||||
"description": "Задать исходящий узел ретрансляции LXMF для identity.",
|
||||
"fields": {
|
||||
"destination": {
|
||||
"name": "Получатель",
|
||||
"description": "Хэш адреса узла ретрансляции."
|
||||
},
|
||||
"identity": {
|
||||
"name": "Identity",
|
||||
"description": "Какую identity настроить. По умолчанию — первая."
|
||||
}
|
||||
}
|
||||
},
|
||||
"sync_propagation": {
|
||||
"name": "Синхронизировать узел ретрансляции",
|
||||
"description": "Забрать сообщения из очереди настроенного узла ретрансляции.",
|
||||
"description": "Забрать сообщения из очереди узла ретрансляции identity.",
|
||||
"fields": {
|
||||
"max_messages": {
|
||||
"name": "Макс. сообщений",
|
||||
"description": "Максимальное число сообщений для получения."
|
||||
},
|
||||
"identity": {
|
||||
"name": "Identity",
|
||||
"description": "Какую identity синхронизировать. По умолчанию — первая."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user