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.**
|
**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 host | Host/IP of the neighbouring Reticulum TCP server |
|
||||||
| Target port | Its `listen_port` (default `4242`) |
|
| 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 |
|
| 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.
|
### Identities (sub-entries)
|
||||||
Find your **LXMF address** on the *LXMF address* sensor, or in the log:
|
|
||||||
|
|
||||||
```
|
Each identity is an independent LXMF address with its own settings, its own
|
||||||
Reticulum ready. LXMF address: a1b2c3… (Home Assistant)
|
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
|
- an **assistant** identity (Assist enabled) that users chat with;
|
||||||
will answer.
|
- 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.
|
- **Route incoming messages to Assist** – turn the chat bridge on/off.
|
||||||
- **Assist agent / language** – pick the conversation agent from the dropdown.
|
- **Assist agent / language** – pick the conversation agent from the dropdown.
|
||||||
> **Want free-form chat?** The default *Home Assistant* agent only recognises
|
> **Want free-form chat?** The default *Home Assistant* agent only recognises
|
||||||
> device-control commands and will answer anything else with "Sorry, I
|
> device-control commands. To chat, install an LLM conversation integration
|
||||||
> couldn't understand that". To actually chat, install an LLM conversation
|
> (OpenAI, Google Generative AI, Anthropic, Ollama, …) and select it here.
|
||||||
> integration (OpenAI, Google Generative AI, Anthropic, Ollama, …), then
|
- **Reply to any sender** / **Allowed sender addresses** – restrict who is answered.
|
||||||
> select that agent here.
|
- **Default recipient** – address this identity's notify entity sends to.
|
||||||
- **Reply to any sender** / **Allowed sender addresses** – restrict who Assist
|
- **Auto-reply (when Assist is off)** – message sent automatically in reply to
|
||||||
will answer.
|
incoming messages only when Assist routing is off. Blank = disabled.
|
||||||
- **Default recipient** – address the `notify.reticulum_send_message` entity
|
- **Default delivery method**, **announce interval**, **propagation node** +
|
||||||
sends to (see Entities below).
|
**sync interval**.
|
||||||
- **Greeting / auto-reply** – message sent automatically in reply to incoming
|
|
||||||
messages **only when "Route incoming messages to Assist" is off** (so senders
|
Stack-level options (**⚙ → Configure**): **Reticulum log level** (`0`–`7`).
|
||||||
get an acknowledgement instead of silence). Blank = disabled.
|
|
||||||
- **Default delivery method** – `direct`, `opportunistic`, or `propagated`.
|
Find each identity's **LXMF address** on its *LXMF address* sensor, or in the
|
||||||
- **Announce interval** – periodic re-announce (seconds; `0` disables).
|
log (`Reticulum identity '…' ready: a1b2c3…`). Add it as a contact in
|
||||||
- **Propagation node** + **sync interval** – optional store-and-forward node so
|
Sideband/MeshChat and message it.
|
||||||
messages queue while HA is offline.
|
|
||||||
- **Reticulum log level** – `0`–`7`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Entities
|
## Entities
|
||||||
|
|
||||||
|
**Hub (stack) device — `Reticulum`:**
|
||||||
|
|
||||||
| Entity | Description |
|
| Entity | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `sensor.reticulum_lxmf_address` | This HA's LXMF address (share this with users) |
|
| `binary_sensor` Connected | Whether the TCP interface is online |
|
||||||
| `sensor.reticulum_messages_received` / `_sent` / `_failed` | Counters |
|
| `sensor` Known peers | Count + `peers` attribute (address, name, hops) |
|
||||||
| `sensor.reticulum_known_peers` | Count + `peers` attribute (address, name, hops) |
|
| `sensor` Received / Transmitted | Interface RX/TX byte counters |
|
||||||
| `sensor.reticulum_last_message` | Last inbound text + source/title attributes |
|
| `sensor` Bitrate | Interface bitrate |
|
||||||
| `sensor.reticulum_last_message_time` | Timestamp of last inbound message |
|
| `sensor` RSSI / SNR / Link quality | Physical-link telemetry (disabled by default) |
|
||||||
| `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 |
|
|
||||||
|
|
||||||
The telemetry sensors read from the outbound interface. RX/TX bytes and bitrate
|
**Per identity device (one per identity):**
|
||||||
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
|
| Entity | Description |
|
||||||
a plain TCP link, so they are disabled by default. Enable them from the device
|
|--------|-------------|
|
||||||
page if you run a physical interface.
|
| `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."
|
content: "The garage door is still open."
|
||||||
title: "Home Assistant"
|
title: "Home Assistant"
|
||||||
method: direct # direct | opportunistic | propagated
|
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`,
|
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
|
## Events
|
||||||
|
|
||||||
- `reticulum_message_received` — `{content, title, source, destination, timestamp, signature_validated, fields}`
|
- `reticulum_message_received` — `{content, title, source, destination, timestamp, signature_validated, fields, identity, identity_name, local_address}`
|
||||||
- `reticulum_message_delivered` — `{message_hash, destination}`
|
- `reticulum_message_delivered` — `{message_hash, destination, identity, identity_name, local_address}`
|
||||||
- `reticulum_message_failed` — `{message_hash, destination}`
|
- `reticulum_message_failed` — `{message_hash, destination, identity, identity_name, local_address}`
|
||||||
- `reticulum_announce_received` — `{destination, display_name, stamp_cost, hops}`
|
- `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
|
### Example automation — forward every inbound message to a mobile push
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -213,15 +230,15 @@ action:
|
|||||||
|
|
||||||
## Notes & limitations
|
## Notes & limitations
|
||||||
|
|
||||||
- Reticulum uses a **single process-wide instance**. Only one Reticulum config
|
- Reticulum uses a **single process-wide instance**, so only one Reticulum
|
||||||
entry is allowed, and because RNS/LXMF cannot be cleanly torn down inside a
|
config entry (the stack) is allowed; add multiple **identities** as sub-entries
|
||||||
running process, the running stack is reused across reloads. Changing the
|
under it. Because RNS/LXMF cannot be cleanly torn down inside a running
|
||||||
host/port (or recovering from a failed first start) therefore requires a
|
process, the running stack and each identity's router are reused across
|
||||||
**full Home Assistant restart**, not just an integration reload — a reload
|
reloads. Changing the host/port (or recovering from a failed first start)
|
||||||
reuses the already-running stack. Option changes that don't touch the stack
|
therefore requires a **full Home Assistant restart**, not just a reload.
|
||||||
apply on reload.
|
- Each identity is stored in `config/reticulum/identities/<subentry_id>/identity`
|
||||||
- The identity is stored in `config/reticulum/identity` — back it up to keep the
|
— back it up to keep the same address. **Regenerating** an identity overwrites
|
||||||
same address.
|
it with a new address.
|
||||||
- RNS and LXMF are standalone-daemon-style libraries — both install
|
- RNS and LXMF are standalone-daemon-style libraries — both install
|
||||||
SIGINT/SIGTERM handlers and `atexit` persistence in their constructors. To
|
SIGINT/SIGTERM handlers and `atexit` persistence in their constructors. To
|
||||||
embed them cleanly the integration suppresses those signal handlers during
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -8,7 +12,7 @@ from typing import Any
|
|||||||
|
|
||||||
import voluptuous as vol
|
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.const import EVENT_HOMEASSISTANT_STOP, Platform
|
||||||
from homeassistant.core import (
|
from homeassistant.core import (
|
||||||
HomeAssistant,
|
HomeAssistant,
|
||||||
@@ -24,6 +28,7 @@ from .const import (
|
|||||||
ATTR_CONTENT,
|
ATTR_CONTENT,
|
||||||
ATTR_DESTINATION,
|
ATTR_DESTINATION,
|
||||||
ATTR_FIELDS,
|
ATTR_FIELDS,
|
||||||
|
ATTR_IDENTITY,
|
||||||
ATTR_MAX_MESSAGES,
|
ATTR_MAX_MESSAGES,
|
||||||
ATTR_METHOD,
|
ATTR_METHOD,
|
||||||
ATTR_TITLE,
|
ATTR_TITLE,
|
||||||
@@ -55,8 +60,13 @@ from .const import (
|
|||||||
SERVICE_SET_PROPAGATION_NODE,
|
SERVICE_SET_PROPAGATION_NODE,
|
||||||
SERVICE_SYNC_PROPAGATION,
|
SERVICE_SYNC_PROPAGATION,
|
||||||
STORAGE_SUBDIR,
|
STORAGE_SUBDIR,
|
||||||
|
SUBENTRY_TYPE_IDENTITY,
|
||||||
|
)
|
||||||
|
from .reticulum_client import (
|
||||||
|
IdentityManager,
|
||||||
|
ReticulumError,
|
||||||
|
ReticulumStack,
|
||||||
)
|
)
|
||||||
from .reticulum_client import ReticulumError, ReticulumManager
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -67,86 +77,99 @@ PLATFORMS: list[Platform] = [
|
|||||||
Platform.SENSOR,
|
Platform.SENSOR,
|
||||||
]
|
]
|
||||||
|
|
||||||
# Interface-status poll: RNS has no push for link up/down, so poll cheaply.
|
|
||||||
INTERFACE_POLL = timedelta(seconds=15)
|
INTERFACE_POLL = timedelta(seconds=15)
|
||||||
|
|
||||||
|
|
||||||
class RuntimeData:
|
class RuntimeData:
|
||||||
"""Container stored on the config entry."""
|
"""Runtime objects for the hub entry."""
|
||||||
|
|
||||||
def __init__(self, manager: ReticulumManager) -> None:
|
def __init__(self, stack: ReticulumStack) -> None:
|
||||||
self.manager = manager
|
self.stack = stack
|
||||||
|
self.identities: dict[str, IdentityManager] = {}
|
||||||
self.unsubs: list[Any] = []
|
self.unsubs: list[Any] = []
|
||||||
# Maps a peer address -> the HA conversation_id we opened for it, so
|
# f"{subentry_id}:{source}" -> HA conversation_id
|
||||||
# each remote user keeps its own Assist conversation context.
|
|
||||||
self.conversation_ids: dict[str, str] = {}
|
self.conversation_ids: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up Reticulum from a config entry."""
|
"""Set up the Reticulum stack and its identities."""
|
||||||
options = entry.options
|
|
||||||
storage_dir = hass.config.path(STORAGE_SUBDIR)
|
storage_dir = hass.config.path(STORAGE_SUBDIR)
|
||||||
|
stack = ReticulumStack(
|
||||||
manager = ReticulumManager(
|
|
||||||
hass,
|
hass,
|
||||||
storage_dir,
|
storage_dir,
|
||||||
|
entry.entry_id,
|
||||||
target_host=entry.data[CONF_TARGET_HOST],
|
target_host=entry.data[CONF_TARGET_HOST],
|
||||||
target_port=entry.data[CONF_TARGET_PORT],
|
target_port=entry.data[CONF_TARGET_PORT],
|
||||||
interface_name=entry.data.get(CONF_INTERFACE_NAME, DEFAULT_INTERFACE_NAME),
|
interface_name=entry.data.get(CONF_INTERFACE_NAME, DEFAULT_INTERFACE_NAME),
|
||||||
display_name=entry.data.get(CONF_DISPLAY_NAME, DEFAULT_DISPLAY_NAME),
|
loglevel=int(entry.options.get(CONF_LOGLEVEL, DEFAULT_LOGLEVEL)),
|
||||||
loglevel=int(options.get(CONF_LOGLEVEL, DEFAULT_LOGLEVEL)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await manager.async_start()
|
await stack.async_start()
|
||||||
except Exception as err: # noqa: BLE001
|
except Exception as err: # noqa: BLE001
|
||||||
raise ConfigEntryNotReady(f"Could not start Reticulum: {err}") from err
|
raise ConfigEntryNotReady(f"Could not start Reticulum: {err}") from err
|
||||||
|
|
||||||
runtime = RuntimeData(manager)
|
runtime = RuntimeData(stack)
|
||||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime
|
||||||
|
|
||||||
# Wire the incoming-message pipeline (assist bridge + event).
|
# Start each identity subentry.
|
||||||
manager.incoming_handler = _make_incoming_handler(hass, entry, runtime)
|
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)
|
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)
|
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
|
# Hub interface-status poll.
|
||||||
# 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).
|
|
||||||
runtime.unsubs.append(
|
runtime.unsubs.append(
|
||||||
async_track_time_interval(
|
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:
|
async def _on_ha_stop(_event) -> None:
|
||||||
await manager.async_persist()
|
await stack.async_persist()
|
||||||
|
|
||||||
runtime.unsubs.append(
|
runtime.unsubs.append(
|
||||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_ha_stop)
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_ha_stop)
|
||||||
)
|
)
|
||||||
|
entry.async_on_unload(entry.add_update_listener(_async_entry_updated))
|
||||||
entry.async_on_unload(entry.add_update_listener(_async_options_updated))
|
|
||||||
_async_register_services(hass)
|
_async_register_services(hass)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
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)
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
runtime: RuntimeData | None = hass.data.get(DOMAIN, {}).get(entry.entry_id)
|
runtime: RuntimeData | None = hass.data.get(DOMAIN, {}).get(entry.entry_id)
|
||||||
if runtime is not None:
|
if runtime is not None:
|
||||||
for unsub in runtime.unsubs:
|
for unsub in runtime.unsubs:
|
||||||
unsub()
|
unsub()
|
||||||
await runtime.manager.async_stop()
|
for manager in runtime.identities.values():
|
||||||
|
await manager.async_stop()
|
||||||
|
await runtime.stack.async_stop()
|
||||||
if unload_ok:
|
if unload_ok:
|
||||||
hass.data[DOMAIN].pop(entry.entry_id, None)
|
hass.data[DOMAIN].pop(entry.entry_id, None)
|
||||||
if not hass.data[DOMAIN]:
|
if not hass.data[DOMAIN]:
|
||||||
@@ -154,25 +177,26 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return unload_ok
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
async def _async_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
"""Reload the entry so option changes take effect."""
|
"""Reload when options or subentries change."""
|
||||||
await hass.config_entries.async_reload(entry.entry_id)
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Periodic tasks
|
# Periodic scheduling
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
def _schedule_periodic(
|
def _schedule_identity(
|
||||||
hass: HomeAssistant, entry: ConfigEntry, runtime: RuntimeData
|
hass: HomeAssistant,
|
||||||
|
runtime: RuntimeData,
|
||||||
|
manager: IdentityManager,
|
||||||
|
subentry: ConfigSubentry,
|
||||||
) -> None:
|
) -> None:
|
||||||
manager = runtime.manager
|
data = subentry.data
|
||||||
announce_interval = entry.options.get(
|
announce_interval = data.get(CONF_ANNOUNCE_INTERVAL, DEFAULT_ANNOUNCE_INTERVAL)
|
||||||
CONF_ANNOUNCE_INTERVAL, DEFAULT_ANNOUNCE_INTERVAL
|
|
||||||
)
|
|
||||||
if announce_interval and announce_interval > 0:
|
if announce_interval and announce_interval > 0:
|
||||||
|
|
||||||
async def _do_announce(_now) -> None:
|
async def _do_announce(_now, _mgr=manager) -> None:
|
||||||
await _safe_announce(manager)
|
await _safe_announce(_mgr)
|
||||||
|
|
||||||
runtime.unsubs.append(
|
runtime.unsubs.append(
|
||||||
async_track_time_interval(
|
async_track_time_interval(
|
||||||
@@ -180,15 +204,15 @@ def _schedule_periodic(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
sync_interval = entry.options.get(CONF_SYNC_INTERVAL, DEFAULT_SYNC_INTERVAL)
|
propagation_node = data.get(CONF_PROPAGATION_NODE)
|
||||||
propagation_node = entry.options.get(CONF_PROPAGATION_NODE)
|
|
||||||
if propagation_node:
|
if propagation_node:
|
||||||
hass.async_create_task(_set_propagation(manager, 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:
|
if sync_interval and sync_interval > 0:
|
||||||
|
|
||||||
async def _do_sync(_now) -> None:
|
async def _do_sync(_now, _mgr=manager) -> None:
|
||||||
try:
|
try:
|
||||||
await manager.async_sync_propagation()
|
await _mgr.async_sync_propagation()
|
||||||
except ReticulumError as err:
|
except ReticulumError as err:
|
||||||
_LOGGER.warning("Propagation sync failed: %s", 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:
|
try:
|
||||||
await manager.async_set_propagation_node(node)
|
await manager.async_set_propagation_node(node)
|
||||||
except ReticulumError as err:
|
except ReticulumError as err:
|
||||||
_LOGGER.warning("Could not set propagation node: %s", 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:
|
try:
|
||||||
await manager.async_announce()
|
await manager.async_announce()
|
||||||
except ReticulumError as err:
|
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(
|
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:
|
async def _handle(payload: dict[str, Any]) -> None:
|
||||||
source = payload["source"]
|
source = payload["source"]
|
||||||
|
|
||||||
# Always surface the message as an event for automations.
|
|
||||||
hass.bus.async_fire(EVENT_MESSAGE_RECEIVED, payload)
|
hass.bus.async_fire(EVENT_MESSAGE_RECEIVED, payload)
|
||||||
|
|
||||||
options = entry.options
|
data = subentry.data
|
||||||
allow_all = options.get(CONF_ALLOW_ALL, True)
|
allow_all = data.get(CONF_ALLOW_ALL, True)
|
||||||
allowed = {
|
allowed = {
|
||||||
a.strip().lower()
|
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 a and a.strip()
|
||||||
}
|
}
|
||||||
if not allow_all and source.lower() not in allowed:
|
if not allow_all and source.lower() not in allowed:
|
||||||
_LOGGER.debug("Ignoring message from non-allowed sender %s", source)
|
_LOGGER.debug("Ignoring message from non-allowed sender %s", source)
|
||||||
return
|
return
|
||||||
|
|
||||||
# When messages are NOT routed to Assist, optionally auto-reply with the
|
# When not routing to Assist, optionally auto-reply with the greeting.
|
||||||
# greeting so senders get an acknowledgement instead of silence.
|
if not data.get(CONF_ENABLE_ASSIST, True):
|
||||||
if not options.get(CONF_ENABLE_ASSIST, True):
|
greeting = (data.get(CONF_GREETING) or "").strip()
|
||||||
greeting = (options.get(CONF_GREETING) or "").strip()
|
|
||||||
if greeting:
|
if greeting:
|
||||||
await _reply(runtime.manager, source, greeting, "Home Assistant")
|
await _reply(manager, source, greeting, "Home Assistant")
|
||||||
return
|
return
|
||||||
|
|
||||||
text = (payload.get("content") or "").strip()
|
text = (payload.get("content") or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
|
await _run_assist(hass, subentry, runtime, manager, source, text)
|
||||||
await _run_assist(hass, entry, runtime, source, text)
|
|
||||||
|
|
||||||
return _handle
|
return _handle
|
||||||
|
|
||||||
|
|
||||||
async def _run_assist(
|
async def _run_assist(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: ConfigEntry,
|
subentry: ConfigSubentry,
|
||||||
runtime: RuntimeData,
|
runtime: RuntimeData,
|
||||||
|
manager: IdentityManager,
|
||||||
source: str,
|
source: str,
|
||||||
text: str,
|
text: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Route a message through the Assist conversation pipeline and reply."""
|
|
||||||
from homeassistant.components import conversation # noqa: PLC0415
|
from homeassistant.components import conversation # noqa: PLC0415
|
||||||
from homeassistant.core import Context # noqa: PLC0415
|
from homeassistant.core import Context # noqa: PLC0415
|
||||||
|
|
||||||
options = entry.options
|
data = subentry.data
|
||||||
agent_id = options.get(CONF_ASSIST_AGENT) or None
|
agent_id = data.get(CONF_ASSIST_AGENT) or None
|
||||||
language = options.get(CONF_ASSIST_LANGUAGE) or hass.config.language
|
language = data.get(CONF_ASSIST_LANGUAGE) or hass.config.language
|
||||||
|
conv_key = f"{subentry.subentry_id}:{source}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await conversation.async_converse(
|
result = await conversation.async_converse(
|
||||||
hass=hass,
|
hass=hass,
|
||||||
text=text,
|
text=text,
|
||||||
conversation_id=runtime.conversation_ids.get(source),
|
conversation_id=runtime.conversation_ids.get(conv_key),
|
||||||
context=Context(),
|
context=Context(),
|
||||||
language=language,
|
language=language,
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
)
|
)
|
||||||
except Exception as err: # noqa: BLE001
|
except Exception as err: # noqa: BLE001
|
||||||
_LOGGER.exception("Assist conversation failed")
|
_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
|
return
|
||||||
|
|
||||||
# Remember the conversation so this peer keeps context.
|
|
||||||
conv_id = getattr(result, "conversation_id", None)
|
conv_id = getattr(result, "conversation_id", None)
|
||||||
if conv_id:
|
if conv_id:
|
||||||
runtime.conversation_ids[source] = conv_id
|
runtime.conversation_ids[conv_key] = conv_id
|
||||||
|
|
||||||
reply = _extract_speech(result)
|
reply = _extract_speech(result)
|
||||||
if reply:
|
if reply:
|
||||||
await _reply(runtime.manager, source, reply, "Assist")
|
await _reply(manager, source, reply, "Assist")
|
||||||
|
|
||||||
|
|
||||||
def _extract_speech(result: Any) -> str | None:
|
def _extract_speech(result: Any) -> str | None:
|
||||||
@@ -303,12 +327,12 @@ def _extract_speech(result: Any) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
async def _reply(
|
async def _reply(
|
||||||
manager: ReticulumManager, destination: str, content: str, title: str
|
manager: IdentityManager, destination: str, content: str, title: str
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
await manager.async_send_message(destination, content, title=title)
|
await manager.async_send_message(destination, content, title=title)
|
||||||
except ReticulumError as err:
|
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_TITLE, default=""): cv.string,
|
||||||
vol.Optional(ATTR_METHOD, default="direct"): vol.In(DELIVERY_METHODS),
|
vol.Optional(ATTR_METHOD, default="direct"): vol.In(DELIVERY_METHODS),
|
||||||
vol.Optional(ATTR_FIELDS): dict,
|
vol.Optional(ATTR_FIELDS): dict,
|
||||||
|
vol.Optional(ATTR_IDENTITY): cv.string,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
REQUEST_PATH_SCHEMA = vol.Schema({vol.Required(ATTR_DESTINATION): cv.string})
|
REQUEST_PATH_SCHEMA = vol.Schema(
|
||||||
SET_PROPAGATION_SCHEMA = vol.Schema({vol.Required(ATTR_DESTINATION): cv.string})
|
{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))}
|
|
||||||
)
|
)
|
||||||
|
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, {})
|
data: dict[str, RuntimeData] = hass.data.get(DOMAIN, {})
|
||||||
if not data:
|
managers: list[IdentityManager] = []
|
||||||
raise HomeAssistantError("Reticulum is not configured")
|
for runtime in data.values():
|
||||||
return next(iter(data.values())).manager
|
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:
|
def _async_register_services(hass: HomeAssistant) -> None:
|
||||||
@@ -342,7 +388,7 @@ def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
async def _send(call: ServiceCall) -> ServiceResponse:
|
async def _send(call: ServiceCall) -> ServiceResponse:
|
||||||
manager = _first_manager(hass)
|
manager = _resolve_manager(hass, call.data.get(ATTR_IDENTITY))
|
||||||
try:
|
try:
|
||||||
message_hash = await manager.async_send_message(
|
message_hash = await manager.async_send_message(
|
||||||
call.data[ATTR_DESTINATION],
|
call.data[ATTR_DESTINATION],
|
||||||
@@ -353,33 +399,33 @@ def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
)
|
)
|
||||||
except ReticulumError as err:
|
except ReticulumError as err:
|
||||||
raise HomeAssistantError(str(err)) from 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:
|
async def _announce(call: ServiceCall) -> None:
|
||||||
|
manager = _resolve_manager(hass, call.data.get(ATTR_IDENTITY))
|
||||||
try:
|
try:
|
||||||
await _first_manager(hass).async_announce()
|
await manager.async_announce()
|
||||||
except ReticulumError as err:
|
except ReticulumError as err:
|
||||||
raise HomeAssistantError(str(err)) from err
|
raise HomeAssistantError(str(err)) from err
|
||||||
|
|
||||||
async def _request_path(call: ServiceCall) -> None:
|
async def _request_path(call: ServiceCall) -> None:
|
||||||
|
manager = _resolve_manager(hass, call.data.get(ATTR_IDENTITY))
|
||||||
try:
|
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:
|
except ReticulumError as err:
|
||||||
raise HomeAssistantError(str(err)) from err
|
raise HomeAssistantError(str(err)) from err
|
||||||
|
|
||||||
async def _set_propagation(call: ServiceCall) -> None:
|
async def _set_propagation(call: ServiceCall) -> None:
|
||||||
|
manager = _resolve_manager(hass, call.data.get(ATTR_IDENTITY))
|
||||||
try:
|
try:
|
||||||
await _first_manager(hass).async_set_propagation_node(
|
await manager.async_set_propagation_node(call.data[ATTR_DESTINATION])
|
||||||
call.data[ATTR_DESTINATION]
|
|
||||||
)
|
|
||||||
except ReticulumError as err:
|
except ReticulumError as err:
|
||||||
raise HomeAssistantError(str(err)) from err
|
raise HomeAssistantError(str(err)) from err
|
||||||
|
|
||||||
async def _sync(call: ServiceCall) -> None:
|
async def _sync(call: ServiceCall) -> None:
|
||||||
|
manager = _resolve_manager(hass, call.data.get(ATTR_IDENTITY))
|
||||||
try:
|
try:
|
||||||
await _first_manager(hass).async_sync_propagation(
|
await manager.async_sync_propagation(call.data.get(ATTR_MAX_MESSAGES))
|
||||||
call.data.get(ATTR_MAX_MESSAGES)
|
|
||||||
)
|
|
||||||
except ReticulumError as err:
|
except ReticulumError as err:
|
||||||
raise HomeAssistantError(str(err)) from err
|
raise HomeAssistantError(str(err)) from err
|
||||||
|
|
||||||
@@ -390,7 +436,9 @@ def _async_register_services(hass: HomeAssistant) -> None:
|
|||||||
schema=SEND_MESSAGE_SCHEMA,
|
schema=SEND_MESSAGE_SCHEMA,
|
||||||
supports_response=SupportsResponse.OPTIONAL,
|
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(
|
hass.services.async_register(
|
||||||
DOMAIN, SERVICE_REQUEST_PATH, _request_path, schema=REQUEST_PATH_SCHEMA
|
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 homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .entity import ReticulumEntity
|
from .entity import ReticulumStackEntity
|
||||||
from .reticulum_client import ReticulumManager
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@@ -20,21 +19,21 @@ async def async_setup_entry(
|
|||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Reticulum connectivity binary sensor."""
|
"""Set up the hub connectivity binary sensor."""
|
||||||
manager: ReticulumManager = hass.data[DOMAIN][entry.entry_id].manager
|
runtime = hass.data[DOMAIN][entry.entry_id]
|
||||||
async_add_entities([ReticulumConnectivity(manager, entry.entry_id)])
|
async_add_entities([ReticulumConnectivity(runtime.stack, entry)])
|
||||||
|
|
||||||
|
|
||||||
class ReticulumConnectivity(ReticulumEntity, BinarySensorEntity):
|
class ReticulumConnectivity(ReticulumStackEntity, BinarySensorEntity):
|
||||||
"""Reports whether the outbound Reticulum interface is online."""
|
"""Whether the outbound Reticulum interface is online."""
|
||||||
|
|
||||||
_attr_translation_key = "connected"
|
_attr_translation_key = "connected"
|
||||||
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
|
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
|
||||||
|
|
||||||
def __init__(self, manager: ReticulumManager, entry_id: str) -> None:
|
def __init__(self, stack, entry) -> None:
|
||||||
super().__init__(manager, entry_id)
|
super().__init__(stack, entry)
|
||||||
self._attr_unique_id = f"{entry_id}_connected"
|
self._attr_unique_id = f"{entry.entry_id}_connected"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self) -> bool:
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from homeassistant.components.button import ButtonEntity
|
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN, SUBENTRY_TYPE_IDENTITY
|
||||||
from .entity import ReticulumEntity
|
from .entity import ReticulumIdentityEntity
|
||||||
from .reticulum_client import ReticulumError, ReticulumManager
|
from .reticulum_client import ReticulumError
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -21,25 +21,33 @@ async def async_setup_entry(
|
|||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up Reticulum buttons."""
|
"""Set up per-identity buttons."""
|
||||||
manager: ReticulumManager = hass.data[DOMAIN][entry.entry_id].manager
|
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(
|
async_add_entities(
|
||||||
[
|
[
|
||||||
ReticulumAnnounceButton(manager, entry.entry_id),
|
ReticulumAnnounceButton(manager, entry, subentry),
|
||||||
ReticulumSyncButton(manager, entry.entry_id),
|
ReticulumSyncButton(manager, entry, subentry),
|
||||||
]
|
ReticulumRegenerateButton(manager, entry, subentry),
|
||||||
|
],
|
||||||
|
config_subentry_id=subentry.subentry_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ReticulumAnnounceButton(ReticulumEntity, ButtonEntity):
|
class ReticulumAnnounceButton(ReticulumIdentityEntity, ButtonEntity):
|
||||||
"""Announce our LXMF destination on demand."""
|
"""Announce this identity's LXMF destination."""
|
||||||
|
|
||||||
_attr_translation_key = "announce"
|
_attr_translation_key = "announce"
|
||||||
_attr_icon = "mdi:bullhorn"
|
_attr_icon = "mdi:bullhorn"
|
||||||
|
|
||||||
def __init__(self, manager: ReticulumManager, entry_id: str) -> None:
|
def __init__(self, manager, entry, subentry) -> None:
|
||||||
super().__init__(manager, entry_id)
|
super().__init__(manager, entry, subentry)
|
||||||
self._attr_unique_id = f"{entry_id}_announce"
|
self._attr_unique_id = f"{subentry.subentry_id}_announce"
|
||||||
|
|
||||||
async def async_press(self) -> None:
|
async def async_press(self) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -48,18 +56,42 @@ class ReticulumAnnounceButton(ReticulumEntity, ButtonEntity):
|
|||||||
_LOGGER.warning("Announce failed: %s", err)
|
_LOGGER.warning("Announce failed: %s", err)
|
||||||
|
|
||||||
|
|
||||||
class ReticulumSyncButton(ReticulumEntity, ButtonEntity):
|
class ReticulumSyncButton(ReticulumIdentityEntity, ButtonEntity):
|
||||||
"""Pull queued messages from the configured propagation node."""
|
"""Pull queued messages from the propagation node."""
|
||||||
|
|
||||||
_attr_translation_key = "sync"
|
_attr_translation_key = "sync"
|
||||||
_attr_icon = "mdi:sync"
|
_attr_icon = "mdi:sync"
|
||||||
|
|
||||||
def __init__(self, manager: ReticulumManager, entry_id: str) -> None:
|
def __init__(self, manager, entry, subentry) -> None:
|
||||||
super().__init__(manager, entry_id)
|
super().__init__(manager, entry, subentry)
|
||||||
self._attr_unique_id = f"{entry_id}_sync"
|
self._attr_unique_id = f"{subentry.subentry_id}_sync"
|
||||||
|
|
||||||
async def async_press(self) -> None:
|
async def async_press(self) -> None:
|
||||||
try:
|
try:
|
||||||
await self._manager.async_sync_propagation()
|
await self._manager.async_sync_propagation()
|
||||||
except ReticulumError as err:
|
except ReticulumError as err:
|
||||||
_LOGGER.warning("Propagation sync failed: %s", 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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -11,7 +15,9 @@ from homeassistant.config_entries import (
|
|||||||
ConfigEntry,
|
ConfigEntry,
|
||||||
ConfigFlow,
|
ConfigFlow,
|
||||||
ConfigFlowResult,
|
ConfigFlowResult,
|
||||||
|
ConfigSubentryFlow,
|
||||||
OptionsFlow,
|
OptionsFlow,
|
||||||
|
SubentryFlowResult,
|
||||||
)
|
)
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
@@ -57,9 +63,14 @@ from .const import (
|
|||||||
DEFAULT_TARGET_PORT,
|
DEFAULT_TARGET_PORT,
|
||||||
DELIVERY_METHODS,
|
DELIVERY_METHODS,
|
||||||
DOMAIN,
|
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:
|
async def _test_connection(host: str, port: int) -> None:
|
||||||
"""Verify the Reticulum TCP server is reachable. Raises on failure."""
|
"""Verify the Reticulum TCP server is reachable. Raises on failure."""
|
||||||
try:
|
try:
|
||||||
@@ -75,36 +86,126 @@ async def _test_connection(host: str, port: int) -> None:
|
|||||||
raise CannotConnect(str(err)) from err
|
raise CannotConnect(str(err)) from err
|
||||||
|
|
||||||
|
|
||||||
class CannotConnect(Exception):
|
|
||||||
"""Error to indicate we cannot connect to the TCP server."""
|
|
||||||
|
|
||||||
|
|
||||||
STEP_USER_SCHEMA = vol.Schema(
|
STEP_USER_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(CONF_TARGET_HOST): cv.string,
|
vol.Required(CONF_TARGET_HOST): cv.string,
|
||||||
vol.Required(CONF_TARGET_PORT, default=DEFAULT_TARGET_PORT): vol.All(
|
vol.Required(CONF_TARGET_PORT, default=DEFAULT_TARGET_PORT): vol.All(
|
||||||
vol.Coerce(int), vol.Range(min=1, max=65535)
|
vol.Coerce(int), vol.Range(min=1, max=65535)
|
||||||
),
|
),
|
||||||
vol.Required(CONF_DISPLAY_NAME, default=DEFAULT_DISPLAY_NAME): cv.string,
|
|
||||||
vol.Required(
|
vol.Required(
|
||||||
CONF_INTERFACE_NAME, default=DEFAULT_INTERFACE_NAME
|
CONF_INTERFACE_NAME, default=DEFAULT_INTERFACE_NAME
|
||||||
): cv.string,
|
): 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):
|
class ReticulumConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow for Reticulum."""
|
"""Handle the Reticulum stack (hub) config flow."""
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
|
||||||
async def async_step_user(
|
async def async_step_user(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Handle the initial step."""
|
"""Configure the shared Reticulum stack and a first identity."""
|
||||||
self._async_abort_entries_match() # single_config_entry also enforces this
|
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
try:
|
try:
|
||||||
await _test_connection(
|
await _test_connection(
|
||||||
@@ -115,12 +216,24 @@ class ReticulumConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
else:
|
else:
|
||||||
await self.async_set_unique_id(DOMAIN)
|
await self.async_set_unique_id(DOMAIN)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
|
display_name = user_input[CONF_DISPLAY_NAME]
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=f"Reticulum ({user_input[CONF_DISPLAY_NAME]})",
|
title="Reticulum",
|
||||||
data=user_input,
|
data={
|
||||||
options=_default_options(),
|
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(
|
return self.async_show_form(
|
||||||
step_id="user", data_schema=STEP_USER_SCHEMA, errors=errors
|
step_id="user", data_schema=STEP_USER_SCHEMA, errors=errors
|
||||||
)
|
)
|
||||||
@@ -128,113 +241,27 @@ class ReticulumConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@callback
|
||||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
||||||
"""Get the options flow for this handler."""
|
|
||||||
return ReticulumOptionsFlow()
|
return ReticulumOptionsFlow()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
def _default_options() -> dict[str, Any]:
|
@callback
|
||||||
return {
|
def async_get_supported_subentry_types(
|
||||||
CONF_ENABLE_ASSIST: True,
|
cls, config_entry: ConfigEntry
|
||||||
CONF_ALLOW_ALL: True,
|
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||||
CONF_ALLOWED_IDENTITIES: [],
|
return {SUBENTRY_TYPE_IDENTITY: IdentitySubentryFlow}
|
||||||
CONF_ANNOUNCE_INTERVAL: DEFAULT_ANNOUNCE_INTERVAL,
|
|
||||||
CONF_SYNC_INTERVAL: DEFAULT_SYNC_INTERVAL,
|
|
||||||
CONF_DELIVERY_METHOD: DEFAULT_DELIVERY_METHOD,
|
|
||||||
CONF_LOGLEVEL: DEFAULT_LOGLEVEL,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ReticulumOptionsFlow(OptionsFlow):
|
class ReticulumOptionsFlow(OptionsFlow):
|
||||||
"""Handle Reticulum options."""
|
"""Hub-level options (stack settings)."""
|
||||||
|
|
||||||
async def async_step_init(
|
async def async_step_init(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Manage the options."""
|
|
||||||
if user_input is not None:
|
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)
|
return self.async_create_entry(data=user_input)
|
||||||
|
|
||||||
opts = self.config_entry.options
|
opts = self.config_entry.options
|
||||||
allowed = opts.get(CONF_ALLOWED_IDENTITIES, [])
|
|
||||||
allowed_str = ", ".join(allowed) if isinstance(allowed, list) else allowed
|
|
||||||
|
|
||||||
schema = vol.Schema(
|
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(
|
vol.Required(
|
||||||
CONF_LOGLEVEL, default=opts.get(CONF_LOGLEVEL, DEFAULT_LOGLEVEL)
|
CONF_LOGLEVEL, default=opts.get(CONF_LOGLEVEL, DEFAULT_LOGLEVEL)
|
||||||
): NumberSelector(
|
): NumberSelector(
|
||||||
@@ -245,3 +272,35 @@ class ReticulumOptionsFlow(OptionsFlow):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
return self.async_show_form(step_id="init", data_schema=schema)
|
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"]
|
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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
STORAGE_SUBDIR: Final = "reticulum"
|
STORAGE_SUBDIR: Final = "reticulum"
|
||||||
|
IDENTITIES_SUBDIR: Final = "identities"
|
||||||
IDENTITY_FILENAME: Final = "identity"
|
IDENTITY_FILENAME: Final = "identity"
|
||||||
CONFIG_FILENAME: Final = "config"
|
CONFIG_FILENAME: Final = "config"
|
||||||
ATTACHMENTS_SUBDIR: Final = "attachments"
|
ATTACHMENTS_SUBDIR: Final = "attachments"
|
||||||
@@ -54,7 +62,19 @@ ATTACHMENTS_SUBDIR: Final = "attachments"
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Dispatcher signals
|
# 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
|
# Events
|
||||||
@@ -79,6 +99,9 @@ ATTR_TITLE: Final = "title"
|
|||||||
ATTR_METHOD: Final = "method"
|
ATTR_METHOD: Final = "method"
|
||||||
ATTR_FIELDS: Final = "fields"
|
ATTR_FIELDS: Final = "fields"
|
||||||
ATTR_MAX_MESSAGES: Final = "max_messages"
|
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
|
# LXMF address geometry
|
||||||
|
|||||||
@@ -16,26 +16,41 @@ TO_REDACT = {CONF_DEFAULT_RECIPIENT, CONF_ALLOWED_IDENTITIES}
|
|||||||
async def async_get_config_entry_diagnostics(
|
async def async_get_config_entry_diagnostics(
|
||||||
hass: HomeAssistant, entry: ConfigEntry
|
hass: HomeAssistant, entry: ConfigEntry
|
||||||
) -> dict[str, Any]:
|
) -> 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]
|
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 {
|
return {
|
||||||
"entry": {
|
"entry": {"data": dict(entry.data), "options": dict(entry.options)},
|
||||||
"data": dict(entry.data),
|
"stack": {
|
||||||
"options": async_redact_data(dict(entry.options), TO_REDACT),
|
"started": stack.started,
|
||||||
},
|
"interface_online": stack.interface_online,
|
||||||
"state": {
|
"rxb": stack.tel_rxb,
|
||||||
"started": state.started,
|
"txb": stack.tel_txb,
|
||||||
"interface_online": state.interface_online,
|
"bitrate": stack.tel_bitrate,
|
||||||
"lxmf_address": state.lxmf_address,
|
"peer_count": len(stack.peers),
|
||||||
"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),
|
|
||||||
"peers": [
|
"peers": [
|
||||||
{"name": p.display_name, "hops": p.hops, "stamp_cost": p.stamp_cost}
|
{"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 __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
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 .const import DOMAIN, identity_signal, stack_signal
|
||||||
from .reticulum_client import ReticulumManager
|
from .reticulum_client import IdentityManager, ReticulumStack
|
||||||
|
|
||||||
|
|
||||||
class ReticulumEntity(Entity):
|
class ReticulumStackEntity(Entity):
|
||||||
"""Base class wiring entities to the manager state updates."""
|
"""Base for hub (stack-level) entities."""
|
||||||
|
|
||||||
_attr_should_poll = False
|
_attr_should_poll = False
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
def __init__(self, manager: ReticulumManager, entry_id: str) -> None:
|
def __init__(self, stack: ReticulumStack, entry: ConfigEntry) -> None:
|
||||||
self._manager = manager
|
self._stack = stack
|
||||||
self._entry_id = entry_id
|
self._entry = entry
|
||||||
self._attr_device_info = DeviceInfo(
|
self._attr_device_info = DeviceInfo(
|
||||||
identifiers={(DOMAIN, entry_id)},
|
identifiers={(DOMAIN, entry.entry_id)},
|
||||||
name="Reticulum",
|
name="Reticulum",
|
||||||
manufacturer="Reticulum Network Stack",
|
manufacturer="Reticulum Network Stack",
|
||||||
model="LXMF Peer",
|
model="RNS stack",
|
||||||
sw_version=manager.state.lxmf_address or None,
|
entry_type=DeviceEntryType.SERVICE,
|
||||||
configuration_url="https://reticulum.network/",
|
configuration_url="https://reticulum.network/",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Subscribe to state-update signals."""
|
|
||||||
self.async_on_remove(
|
self.async_on_remove(
|
||||||
async_dispatcher_connect(
|
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
|
@callback
|
||||||
def _handle_update(self) -> None:
|
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()
|
self.async_write_ha_state()
|
||||||
|
|||||||
@@ -12,5 +12,5 @@
|
|||||||
"loggers": ["RNS", "LXMF"],
|
"loggers": ["RNS", "LXMF"],
|
||||||
"requirements": ["rns>=0.9.0", "lxmf>=0.6.0"],
|
"requirements": ["rns>=0.9.0", "lxmf>=0.6.0"],
|
||||||
"single_config_entry": true,
|
"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
|
Each identity exposes a ``notify`` entity that sends an LXMF message from that
|
||||||
default recipient. For arbitrary destinations use the ``reticulum.send_message``
|
identity to its configured default recipient. For arbitrary destinations use
|
||||||
service instead.
|
the ``reticulum.send_message`` service.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -13,9 +13,15 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.exceptions import ServiceValidationError
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .const import CONF_DEFAULT_RECIPIENT, CONF_DELIVERY_METHOD, DEFAULT_DELIVERY_METHOD, DOMAIN
|
from .const import (
|
||||||
from .entity import ReticulumEntity
|
CONF_DEFAULT_RECIPIENT,
|
||||||
from .reticulum_client import ReticulumError, ReticulumManager
|
CONF_DELIVERY_METHOD,
|
||||||
|
DEFAULT_DELIVERY_METHOD,
|
||||||
|
DOMAIN,
|
||||||
|
SUBENTRY_TYPE_IDENTITY,
|
||||||
|
)
|
||||||
|
from .entity import ReticulumIdentityEntity
|
||||||
|
from .reticulum_client import ReticulumError
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@@ -23,32 +29,42 @@ async def async_setup_entry(
|
|||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Reticulum notify entity."""
|
"""Set up per-identity notify entities."""
|
||||||
manager: ReticulumManager = hass.data[DOMAIN][entry.entry_id].manager
|
runtime = hass.data[DOMAIN][entry.entry_id]
|
||||||
async_add_entities([ReticulumNotify(manager, entry)])
|
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):
|
class ReticulumNotify(ReticulumIdentityEntity, NotifyEntity):
|
||||||
"""Send LXMF messages to the default recipient."""
|
"""Send LXMF messages from this identity to its default recipient."""
|
||||||
|
|
||||||
_attr_translation_key = "message"
|
_attr_translation_key = "message"
|
||||||
_attr_icon = "mdi:message-fast"
|
_attr_icon = "mdi:message-fast"
|
||||||
_attr_supported_features = NotifyEntityFeature.TITLE
|
_attr_supported_features = NotifyEntityFeature.TITLE
|
||||||
|
|
||||||
def __init__(self, manager: ReticulumManager, entry: ConfigEntry) -> None:
|
def __init__(self, manager, entry, subentry) -> None:
|
||||||
super().__init__(manager, entry.entry_id)
|
super().__init__(manager, entry, subentry)
|
||||||
self._entry = entry
|
self._attr_unique_id = f"{subentry.subentry_id}_notify"
|
||||||
self._attr_unique_id = f"{entry.entry_id}_notify"
|
|
||||||
|
|
||||||
async def async_send_message(self, message: str, title: str | None = None) -> None:
|
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:
|
if not recipient:
|
||||||
raise ServiceValidationError(
|
raise ServiceValidationError(
|
||||||
"No default recipient configured. Set one in the Reticulum "
|
"No default recipient configured for this identity. Set one in "
|
||||||
"integration options, or use the reticulum.send_message service "
|
"the identity's options, or use the reticulum.send_message service "
|
||||||
"with an explicit destination."
|
"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:
|
try:
|
||||||
await self._manager.async_send_message(
|
await self._manager.async_send_message(
|
||||||
recipient, message, title=title or "", method=method
|
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.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN, SUBENTRY_TYPE_IDENTITY
|
||||||
from .entity import ReticulumEntity
|
from .entity import ReticulumIdentityEntity, ReticulumStackEntity
|
||||||
from .reticulum_client import ManagerState, ReticulumManager
|
from .reticulum_client import IdentityState, StackState
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class ReticulumSensorDescription(SensorEntityDescription):
|
class StackSensorDescription(SensorEntityDescription):
|
||||||
"""Describes a Reticulum sensor."""
|
"""Hub sensor."""
|
||||||
|
|
||||||
value_fn: Callable[[ManagerState], Any]
|
value_fn: Callable[[StackState], Any]
|
||||||
attr_fn: Callable[[ManagerState], dict[str, Any]] | None = None
|
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:
|
if state.last_message_time is None:
|
||||||
return None
|
return None
|
||||||
return datetime.fromtimestamp(state.last_message_time, tz=timezone.utc)
|
return datetime.fromtimestamp(state.last_message_time, tz=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
SENSORS: tuple[ReticulumSensorDescription, ...] = (
|
STACK_SENSORS: tuple[StackSensorDescription, ...] = (
|
||||||
ReticulumSensorDescription(
|
StackSensorDescription(
|
||||||
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(
|
|
||||||
key="peers",
|
key="peers",
|
||||||
translation_key="peers",
|
translation_key="peers",
|
||||||
icon="mdi:account-group",
|
icon="mdi:account-group",
|
||||||
@@ -92,50 +72,31 @@ SENSORS: tuple[ReticulumSensorDescription, ...] = (
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ReticulumSensorDescription(
|
StackSensorDescription(
|
||||||
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(
|
|
||||||
key="rx_bytes",
|
key="rx_bytes",
|
||||||
translation_key="rx_bytes",
|
translation_key="rx_bytes",
|
||||||
icon="mdi:download-network",
|
icon="mdi:download-network",
|
||||||
device_class=SensorDeviceClass.DATA_SIZE,
|
device_class=SensorDeviceClass.DATA_SIZE,
|
||||||
native_unit_of_measurement=UnitOfInformation.BYTES,
|
native_unit_of_measurement=UnitOfInformation.BYTES,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
|
||||||
suggested_unit_of_measurement=UnitOfInformation.KIBIBYTES,
|
suggested_unit_of_measurement=UnitOfInformation.KIBIBYTES,
|
||||||
suggested_display_precision=1,
|
suggested_display_precision=1,
|
||||||
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
value_fn=lambda s: s.tel_rxb,
|
value_fn=lambda s: s.tel_rxb,
|
||||||
),
|
),
|
||||||
ReticulumSensorDescription(
|
StackSensorDescription(
|
||||||
key="tx_bytes",
|
key="tx_bytes",
|
||||||
translation_key="tx_bytes",
|
translation_key="tx_bytes",
|
||||||
icon="mdi:upload-network",
|
icon="mdi:upload-network",
|
||||||
device_class=SensorDeviceClass.DATA_SIZE,
|
device_class=SensorDeviceClass.DATA_SIZE,
|
||||||
native_unit_of_measurement=UnitOfInformation.BYTES,
|
native_unit_of_measurement=UnitOfInformation.BYTES,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
|
||||||
suggested_unit_of_measurement=UnitOfInformation.KIBIBYTES,
|
suggested_unit_of_measurement=UnitOfInformation.KIBIBYTES,
|
||||||
suggested_display_precision=1,
|
suggested_display_precision=1,
|
||||||
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
value_fn=lambda s: s.tel_txb,
|
value_fn=lambda s: s.tel_txb,
|
||||||
),
|
),
|
||||||
ReticulumSensorDescription(
|
StackSensorDescription(
|
||||||
key="bitrate",
|
key="bitrate",
|
||||||
translation_key="bitrate",
|
translation_key="bitrate",
|
||||||
icon="mdi:speedometer",
|
icon="mdi:speedometer",
|
||||||
@@ -145,9 +106,7 @@ SENSORS: tuple[ReticulumSensorDescription, ...] = (
|
|||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
value_fn=lambda s: s.tel_bitrate,
|
value_fn=lambda s: s.tel_bitrate,
|
||||||
),
|
),
|
||||||
# RSSI / SNR / quality are only meaningful on physical interfaces
|
StackSensorDescription(
|
||||||
# (RNode/LoRa). Disabled by default; enable them if a physical link is used.
|
|
||||||
ReticulumSensorDescription(
|
|
||||||
key="rssi",
|
key="rssi",
|
||||||
translation_key="rssi",
|
translation_key="rssi",
|
||||||
icon="mdi:signal",
|
icon="mdi:signal",
|
||||||
@@ -158,7 +117,7 @@ SENSORS: tuple[ReticulumSensorDescription, ...] = (
|
|||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
value_fn=lambda s: s.tel_rssi,
|
value_fn=lambda s: s.tel_rssi,
|
||||||
),
|
),
|
||||||
ReticulumSensorDescription(
|
StackSensorDescription(
|
||||||
key="snr",
|
key="snr",
|
||||||
translation_key="snr",
|
translation_key="snr",
|
||||||
icon="mdi:signal-variant",
|
icon="mdi:signal-variant",
|
||||||
@@ -168,7 +127,7 @@ SENSORS: tuple[ReticulumSensorDescription, ...] = (
|
|||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
value_fn=lambda s: s.tel_snr,
|
value_fn=lambda s: s.tel_snr,
|
||||||
),
|
),
|
||||||
ReticulumSensorDescription(
|
StackSensorDescription(
|
||||||
key="link_quality",
|
key="link_quality",
|
||||||
translation_key="link_quality",
|
translation_key="link_quality",
|
||||||
icon="mdi:gauge",
|
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(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up Reticulum sensors."""
|
"""Set up hub + per-identity sensors."""
|
||||||
manager: ReticulumManager = hass.data[DOMAIN][entry.entry_id].manager
|
runtime = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
|
||||||
async_add_entities(
|
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):
|
class ReticulumStackSensor(ReticulumStackEntity, SensorEntity):
|
||||||
"""A Reticulum sensor."""
|
"""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__(
|
def __init__(
|
||||||
self,
|
self, manager, entry, subentry, description: IdentitySensorDescription
|
||||||
manager: ReticulumManager,
|
|
||||||
entry_id: str,
|
|
||||||
description: ReticulumSensorDescription,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(manager, entry_id)
|
super().__init__(manager, entry, subentry)
|
||||||
self.entity_description = description
|
self.entity_description = description
|
||||||
self._attr_unique_id = f"{entry_id}_{description.key}"
|
self._attr_unique_id = f"{subentry.subentry_id}_{description.key}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> Any:
|
def native_value(self) -> Any:
|
||||||
|
|||||||
@@ -26,13 +26,24 @@ send_message:
|
|||||||
- direct
|
- direct
|
||||||
- opportunistic
|
- opportunistic
|
||||||
- propagated
|
- propagated
|
||||||
|
identity:
|
||||||
|
required: false
|
||||||
|
example: "Home Assistant"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
fields:
|
fields:
|
||||||
required: false
|
required: false
|
||||||
example: '{ }'
|
example: "{ }"
|
||||||
selector:
|
selector:
|
||||||
object:
|
object:
|
||||||
|
|
||||||
announce:
|
announce:
|
||||||
|
fields:
|
||||||
|
identity:
|
||||||
|
required: false
|
||||||
|
example: "Home Assistant"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|
||||||
request_path:
|
request_path:
|
||||||
fields:
|
fields:
|
||||||
@@ -41,6 +52,10 @@ request_path:
|
|||||||
example: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
|
example: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
|
||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
|
identity:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|
||||||
set_propagation_node:
|
set_propagation_node:
|
||||||
fields:
|
fields:
|
||||||
@@ -49,6 +64,10 @@ set_propagation_node:
|
|||||||
example: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
|
example: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
|
||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
|
identity:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|
||||||
sync_propagation:
|
sync_propagation:
|
||||||
fields:
|
fields:
|
||||||
@@ -60,3 +79,7 @@ sync_propagation:
|
|||||||
min: 1
|
min: 1
|
||||||
max: 1000
|
max: 1000
|
||||||
mode: box
|
mode: box
|
||||||
|
identity:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|||||||
@@ -3,18 +3,18 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Connect to Reticulum",
|
"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": {
|
"data": {
|
||||||
"target_host": "Target host",
|
"target_host": "Target host",
|
||||||
"target_port": "Target port",
|
"target_port": "Target port",
|
||||||
"display_name": "Display name",
|
"interface_name": "Interface name",
|
||||||
"interface_name": "Interface name"
|
"display_name": "First identity name"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"target_host": "Hostname or IP of the machine running the Reticulum TCP server.",
|
"target_host": "Hostname or IP of the machine running the Reticulum TCP server.",
|
||||||
"target_port": "TCP port of the Reticulum TCPServerInterface (default 4242).",
|
"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": {
|
"abort": {
|
||||||
"already_configured": "Reticulum is already configured.",
|
"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": {
|
"options": {
|
||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"title": "Reticulum options",
|
"title": "Reticulum stack options",
|
||||||
"data": {
|
"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",
|
"enable_assist": "Route incoming messages to Assist",
|
||||||
"assist_agent": "Assist agent (entity id / agent id, blank = default)",
|
"assist_agent": "Assist agent",
|
||||||
"assist_language": "Assist language (blank = system default)",
|
"assist_language": "Assist language",
|
||||||
"allow_all_senders": "Reply to any sender",
|
"allow_all_senders": "Reply to any sender",
|
||||||
"allowed_identities": "Allowed sender addresses",
|
"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)",
|
"greeting": "Auto-reply message (when Assist is off)",
|
||||||
"delivery_method": "Default delivery method",
|
"delivery_method": "Default delivery method",
|
||||||
"announce_interval": "Announce interval",
|
"announce_interval": "Announce interval",
|
||||||
"propagation_node": "Propagation node address",
|
"propagation_node": "Propagation node address",
|
||||||
"sync_interval": "Propagation sync interval",
|
"sync_interval": "Propagation sync interval"
|
||||||
"loglevel": "Reticulum log level (0-7)"
|
|
||||||
},
|
},
|
||||||
"data_description": {
|
"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.",
|
"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 off. Leave blank to disable.",
|
||||||
"greeting": "Sent as an automatic reply to incoming messages only when 'Route incoming messages to Assist' is turned 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 our address. 0 disables periodic announces.",
|
"announce_interval": "How often (seconds) to announce this identity. 0 disables periodic announces.",
|
||||||
"propagation_node": "Optional LXMF propagation node for store-and-forward delivery.",
|
"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."
|
"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": {
|
"button": {
|
||||||
"announce": { "name": "Announce" },
|
"announce": { "name": "Announce" },
|
||||||
"sync": { "name": "Sync propagation node" }
|
"sync": { "name": "Sync propagation node" },
|
||||||
|
"regenerate": { "name": "Regenerate identity" }
|
||||||
},
|
},
|
||||||
"notify": {
|
"notify": {
|
||||||
"message": { "name": "Send message" }
|
"message": { "name": "Send message" }
|
||||||
@@ -109,17 +152,12 @@
|
|||||||
"name": "Destination",
|
"name": "Destination",
|
||||||
"description": "16-byte hex LXMF destination hash of the recipient."
|
"description": "16-byte hex LXMF destination hash of the recipient."
|
||||||
},
|
},
|
||||||
"content": {
|
"content": { "name": "Content", "description": "The message body." },
|
||||||
"name": "Content",
|
"title": { "name": "Title", "description": "Optional message title / subject." },
|
||||||
"description": "The message body."
|
"method": { "name": "Method", "description": "Delivery method to use." },
|
||||||
},
|
"identity": {
|
||||||
"title": {
|
"name": "Identity",
|
||||||
"name": "Title",
|
"description": "Which identity to send from (name or LXMF address). Defaults to the first identity."
|
||||||
"description": "Optional message title / subject."
|
|
||||||
},
|
|
||||||
"method": {
|
|
||||||
"name": "Method",
|
|
||||||
"description": "Delivery method to use."
|
|
||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"name": "Fields",
|
"name": "Fields",
|
||||||
@@ -129,7 +167,13 @@
|
|||||||
},
|
},
|
||||||
"announce": {
|
"announce": {
|
||||||
"name": "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": {
|
"request_path": {
|
||||||
"name": "Request path",
|
"name": "Request path",
|
||||||
@@ -138,26 +182,38 @@
|
|||||||
"destination": {
|
"destination": {
|
||||||
"name": "Destination",
|
"name": "Destination",
|
||||||
"description": "Destination hash to resolve a path to."
|
"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": {
|
"set_propagation_node": {
|
||||||
"name": "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": {
|
"fields": {
|
||||||
"destination": {
|
"destination": {
|
||||||
"name": "Destination",
|
"name": "Destination",
|
||||||
"description": "Propagation node destination hash."
|
"description": "Propagation node destination hash."
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"name": "Identity",
|
||||||
|
"description": "Which identity to configure. Defaults to the first identity."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sync_propagation": {
|
"sync_propagation": {
|
||||||
"name": "Sync propagation node",
|
"name": "Sync propagation node",
|
||||||
"description": "Pull queued messages from the configured propagation node.",
|
"description": "Pull queued messages from an identity's propagation node.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"max_messages": {
|
"max_messages": {
|
||||||
"name": "Max messages",
|
"name": "Max messages",
|
||||||
"description": "Maximum number of messages to retrieve."
|
"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": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Connect to Reticulum",
|
"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": {
|
"data": {
|
||||||
"target_host": "Target host",
|
"target_host": "Target host",
|
||||||
"target_port": "Target port",
|
"target_port": "Target port",
|
||||||
"display_name": "Display name",
|
"interface_name": "Interface name",
|
||||||
"interface_name": "Interface name"
|
"display_name": "First identity name"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"target_host": "Hostname or IP of the machine running the Reticulum TCP server.",
|
"target_host": "Hostname or IP of the machine running the Reticulum TCP server.",
|
||||||
"target_port": "TCP port of the Reticulum TCPServerInterface (default 4242).",
|
"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": {
|
"abort": {
|
||||||
"already_configured": "Reticulum is already configured.",
|
"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": {
|
"options": {
|
||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"title": "Reticulum options",
|
"title": "Reticulum stack options",
|
||||||
"data": {
|
"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",
|
"enable_assist": "Route incoming messages to Assist",
|
||||||
"assist_agent": "Assist agent (entity id / agent id, blank = default)",
|
"assist_agent": "Assist agent",
|
||||||
"assist_language": "Assist language (blank = system default)",
|
"assist_language": "Assist language",
|
||||||
"allow_all_senders": "Reply to any sender",
|
"allow_all_senders": "Reply to any sender",
|
||||||
"allowed_identities": "Allowed sender addresses",
|
"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)",
|
"greeting": "Auto-reply message (when Assist is off)",
|
||||||
"delivery_method": "Default delivery method",
|
"delivery_method": "Default delivery method",
|
||||||
"announce_interval": "Announce interval",
|
"announce_interval": "Announce interval",
|
||||||
"propagation_node": "Propagation node address",
|
"propagation_node": "Propagation node address",
|
||||||
"sync_interval": "Propagation sync interval",
|
"sync_interval": "Propagation sync interval"
|
||||||
"loglevel": "Reticulum log level (0-7)"
|
|
||||||
},
|
},
|
||||||
"data_description": {
|
"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.",
|
"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 off. Leave blank to disable.",
|
||||||
"greeting": "Sent as an automatic reply to incoming messages only when 'Route incoming messages to Assist' is turned 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 our address. 0 disables periodic announces.",
|
"announce_interval": "How often (seconds) to announce this identity. 0 disables periodic announces.",
|
||||||
"propagation_node": "Optional LXMF propagation node for store-and-forward delivery.",
|
"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."
|
"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": {
|
"button": {
|
||||||
"announce": { "name": "Announce" },
|
"announce": { "name": "Announce" },
|
||||||
"sync": { "name": "Sync propagation node" }
|
"sync": { "name": "Sync propagation node" },
|
||||||
|
"regenerate": { "name": "Regenerate identity" }
|
||||||
},
|
},
|
||||||
"notify": {
|
"notify": {
|
||||||
"message": { "name": "Send message" }
|
"message": { "name": "Send message" }
|
||||||
@@ -109,17 +152,12 @@
|
|||||||
"name": "Destination",
|
"name": "Destination",
|
||||||
"description": "16-byte hex LXMF destination hash of the recipient."
|
"description": "16-byte hex LXMF destination hash of the recipient."
|
||||||
},
|
},
|
||||||
"content": {
|
"content": { "name": "Content", "description": "The message body." },
|
||||||
"name": "Content",
|
"title": { "name": "Title", "description": "Optional message title / subject." },
|
||||||
"description": "The message body."
|
"method": { "name": "Method", "description": "Delivery method to use." },
|
||||||
},
|
"identity": {
|
||||||
"title": {
|
"name": "Identity",
|
||||||
"name": "Title",
|
"description": "Which identity to send from (name or LXMF address). Defaults to the first identity."
|
||||||
"description": "Optional message title / subject."
|
|
||||||
},
|
|
||||||
"method": {
|
|
||||||
"name": "Method",
|
|
||||||
"description": "Delivery method to use."
|
|
||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"name": "Fields",
|
"name": "Fields",
|
||||||
@@ -129,7 +167,13 @@
|
|||||||
},
|
},
|
||||||
"announce": {
|
"announce": {
|
||||||
"name": "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": {
|
"request_path": {
|
||||||
"name": "Request path",
|
"name": "Request path",
|
||||||
@@ -138,26 +182,38 @@
|
|||||||
"destination": {
|
"destination": {
|
||||||
"name": "Destination",
|
"name": "Destination",
|
||||||
"description": "Destination hash to resolve a path to."
|
"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": {
|
"set_propagation_node": {
|
||||||
"name": "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": {
|
"fields": {
|
||||||
"destination": {
|
"destination": {
|
||||||
"name": "Destination",
|
"name": "Destination",
|
||||||
"description": "Propagation node destination hash."
|
"description": "Propagation node destination hash."
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"name": "Identity",
|
||||||
|
"description": "Which identity to configure. Defaults to the first identity."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sync_propagation": {
|
"sync_propagation": {
|
||||||
"name": "Sync propagation node",
|
"name": "Sync propagation node",
|
||||||
"description": "Pull queued messages from the configured propagation node.",
|
"description": "Pull queued messages from an identity's propagation node.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"max_messages": {
|
"max_messages": {
|
||||||
"name": "Max messages",
|
"name": "Max messages",
|
||||||
"description": "Maximum number of messages to retrieve."
|
"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": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Подключение к Reticulum",
|
"title": "Подключение к Reticulum",
|
||||||
"description": "Home Assistant запустит экземпляр Reticulum и подключится к вашему стеку Reticulum по TCP (на соседней машине должен быть настроен TCPServerInterface).",
|
"description": "Home Assistant запустит экземпляр Reticulum и подключится к вашему стеку по TCP. Первая LXMF-identity создаётся автоматически; остальные можно добавить позже на странице интеграции.",
|
||||||
"data": {
|
"data": {
|
||||||
"target_host": "Хост",
|
"target_host": "Хост",
|
||||||
"target_port": "Порт",
|
"target_port": "Порт",
|
||||||
"display_name": "Отображаемое имя",
|
"interface_name": "Название интерфейса",
|
||||||
"interface_name": "Название интерфейса"
|
"display_name": "Имя первой identity"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"target_host": "Имя хоста или IP-адрес машины с TCP-сервером Reticulum.",
|
"target_host": "Имя хоста или IP-адрес машины с TCP-сервером Reticulum.",
|
||||||
"target_port": "TCP-порт TCPServerInterface Reticulum (по умолчанию 4242).",
|
"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": {
|
"abort": {
|
||||||
"already_configured": "Reticulum уже настроен.",
|
"already_configured": "Reticulum уже настроен.",
|
||||||
"single_instance_allowed": "Допускается только один экземпляр Reticulum."
|
"single_instance_allowed": "Допускается только один стек Reticulum. Добавляйте новые identity к существующему."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"title": "Параметры Reticulum",
|
"title": "Параметры стека Reticulum",
|
||||||
"data": {
|
"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",
|
"enable_assist": "Направлять входящие сообщения в Assist",
|
||||||
"assist_agent": "Агент Assist (id сущности/агента, пусто = по умолчанию)",
|
"assist_agent": "Агент Assist",
|
||||||
"assist_language": "Язык Assist (пусто = язык системы)",
|
"assist_language": "Язык Assist",
|
||||||
"allow_all_senders": "Отвечать любому отправителю",
|
"allow_all_senders": "Отвечать любому отправителю",
|
||||||
"allowed_identities": "Разрешённые адреса отправителей",
|
"allowed_identities": "Разрешённые адреса отправителей",
|
||||||
"default_recipient": "Адрес получателя по умолчанию (для сущности notify)",
|
"default_recipient": "Адрес получателя по умолчанию",
|
||||||
"greeting": "Автоответ (когда Assist выключен)",
|
"greeting": "Автоответ (когда Assist выключен)",
|
||||||
"delivery_method": "Способ доставки по умолчанию",
|
"delivery_method": "Способ доставки по умолчанию",
|
||||||
"announce_interval": "Интервал анонсов",
|
"announce_interval": "Интервал анонсов",
|
||||||
"propagation_node": "Адрес узла ретрансляции (propagation node)",
|
"propagation_node": "Адрес узла ретрансляции",
|
||||||
"sync_interval": "Интервал синхронизации с узлом ретрансляции",
|
"sync_interval": "Интервал синхронизации с узлом ретрансляции"
|
||||||
"loglevel": "Уровень логирования Reticulum (0-7)"
|
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"enable_assist": "Если включено, входящие сообщения LXMF обрабатываются агентом Assist, а ответ отправляется обратно через Reticulum.",
|
"display_name": "Имя контакта, анонсируемое другим узлам Reticulum. Также используется как имя identity в Home Assistant.",
|
||||||
|
"enable_assist": "Если включено, входящие сообщения LXMF обрабатываются агентом Assist, а ответ отправляется обратно.",
|
||||||
"allowed_identities": "По одному адресу в строке (или через запятую). Используется только если «Отвечать любому отправителю» выключено.",
|
"allowed_identities": "По одному адресу в строке (или через запятую). Используется только если «Отвечать любому отправителю» выключено.",
|
||||||
"default_recipient": "16-байтовый hex-адрес LXMF, на который отправляет сущность notify.reticulum.",
|
|
||||||
"greeting": "Отправляется автоматически в ответ на входящие сообщения только когда «Направлять входящие сообщения в Assist» выключено. Оставьте пустым, чтобы отключить.",
|
"greeting": "Отправляется автоматически в ответ на входящие сообщения только когда «Направлять входящие сообщения в Assist» выключено. Оставьте пустым, чтобы отключить.",
|
||||||
"announce_interval": "Как часто (в секундах) анонсировать наш адрес. 0 отключает периодические анонсы.",
|
"default_recipient": "16-байтовый hex-адрес LXMF, на который отправляет сущность notify этой identity.",
|
||||||
"propagation_node": "Необязательный узел ретрансляции LXMF для доставки по принципу store-and-forward.",
|
"announce_interval": "Как часто (в секундах) анонсировать эту identity. 0 отключает периодические анонсы.",
|
||||||
|
"propagation_node": "Необязательный узел ретрансляции LXMF (store-and-forward).",
|
||||||
"sync_interval": "Как часто (в секундах) забирать сообщения из очереди узла ретрансляции. 0 отключает."
|
"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": {
|
"button": {
|
||||||
"announce": { "name": "Анонсировать" },
|
"announce": { "name": "Анонсировать" },
|
||||||
"sync": { "name": "Синхронизировать узел ретрансляции" }
|
"sync": { "name": "Синхронизировать узел ретрансляции" },
|
||||||
|
"regenerate": { "name": "Перегенерировать identity" }
|
||||||
},
|
},
|
||||||
"notify": {
|
"notify": {
|
||||||
"message": { "name": "Отправить сообщение" }
|
"message": { "name": "Отправить сообщение" }
|
||||||
@@ -109,17 +152,12 @@
|
|||||||
"name": "Получатель",
|
"name": "Получатель",
|
||||||
"description": "16-байтовый hex-хэш адреса LXMF получателя."
|
"description": "16-байтовый hex-хэш адреса LXMF получателя."
|
||||||
},
|
},
|
||||||
"content": {
|
"content": { "name": "Текст", "description": "Тело сообщения." },
|
||||||
"name": "Текст",
|
"title": { "name": "Заголовок", "description": "Необязательный заголовок/тема сообщения." },
|
||||||
"description": "Тело сообщения."
|
"method": { "name": "Способ", "description": "Используемый способ доставки." },
|
||||||
},
|
"identity": {
|
||||||
"title": {
|
"name": "Identity",
|
||||||
"name": "Заголовок",
|
"description": "От какой identity отправлять (имя или адрес LXMF). По умолчанию — первая identity."
|
||||||
"description": "Необязательный заголовок/тема сообщения."
|
|
||||||
},
|
|
||||||
"method": {
|
|
||||||
"name": "Способ",
|
|
||||||
"description": "Используемый способ доставки."
|
|
||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"name": "Поля",
|
"name": "Поля",
|
||||||
@@ -129,7 +167,13 @@
|
|||||||
},
|
},
|
||||||
"announce": {
|
"announce": {
|
||||||
"name": "Анонсировать",
|
"name": "Анонсировать",
|
||||||
"description": "Анонсировать адрес LXMF этого Home Assistant в сети."
|
"description": "Анонсировать адрес LXMF identity в сети.",
|
||||||
|
"fields": {
|
||||||
|
"identity": {
|
||||||
|
"name": "Identity",
|
||||||
|
"description": "Какую identity анонсировать. По умолчанию — первая."
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"request_path": {
|
"request_path": {
|
||||||
"name": "Запросить маршрут",
|
"name": "Запросить маршрут",
|
||||||
@@ -138,26 +182,38 @@
|
|||||||
"destination": {
|
"destination": {
|
||||||
"name": "Получатель",
|
"name": "Получатель",
|
||||||
"description": "Хэш адреса, до которого нужно найти маршрут."
|
"description": "Хэш адреса, до которого нужно найти маршрут."
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"name": "Identity",
|
||||||
|
"description": "От какой identity действовать. По умолчанию — первая."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"set_propagation_node": {
|
"set_propagation_node": {
|
||||||
"name": "Задать узел ретрансляции",
|
"name": "Задать узел ретрансляции",
|
||||||
"description": "Задать исходящий узел ретрансляции LXMF.",
|
"description": "Задать исходящий узел ретрансляции LXMF для identity.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"destination": {
|
"destination": {
|
||||||
"name": "Получатель",
|
"name": "Получатель",
|
||||||
"description": "Хэш адреса узла ретрансляции."
|
"description": "Хэш адреса узла ретрансляции."
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"name": "Identity",
|
||||||
|
"description": "Какую identity настроить. По умолчанию — первая."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sync_propagation": {
|
"sync_propagation": {
|
||||||
"name": "Синхронизировать узел ретрансляции",
|
"name": "Синхронизировать узел ретрансляции",
|
||||||
"description": "Забрать сообщения из очереди настроенного узла ретрансляции.",
|
"description": "Забрать сообщения из очереди узла ретрансляции identity.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"max_messages": {
|
"max_messages": {
|
||||||
"name": "Макс. сообщений",
|
"name": "Макс. сообщений",
|
||||||
"description": "Максимальное число сообщений для получения."
|
"description": "Максимальное число сообщений для получения."
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"name": "Identity",
|
||||||
|
"description": "Какую identity синхронизировать. По умолчанию — первая."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user