Add Reticulum/LXMF integration for Home Assistant
Custom integration connecting Home Assistant to a Reticulum network over a TCPClientInterface and exchanging LXMF messages, with an Assist conversation bridge, notify entity, sensors (incl. interface telemetry), buttons, services, bus events and device triggers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.claude/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
*.egg-info/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
219
README.md
219
README.md
@@ -1,2 +1,219 @@
|
|||||||
# ha-reticulum
|
# Reticulum for Home Assistant
|
||||||
|
|
||||||
|
A Home Assistant custom integration that connects Home Assistant to a
|
||||||
|
[Reticulum](https://reticulum.network/) network over TCP and exchanges
|
||||||
|
[LXMF](https://github.com/markqvist/LXMF) messages with users.
|
||||||
|
|
||||||
|
The headline feature: **a user can chat with the Home Assistant Assist agent
|
||||||
|
over Reticulum**. Any LXMF client (Sideband, MeshChat, Nomad Network) can send a
|
||||||
|
message to Home Assistant's address; the text is routed through the Assist
|
||||||
|
conversation pipeline and the answer is sent straight back — over LoRa, packet
|
||||||
|
radio, I2P, TCP, or anything else Reticulum runs on.
|
||||||
|
|
||||||
|
It also exposes a `notify` entity, a rich service API, sensors, and bus events
|
||||||
|
so LXMF messaging can drive and be driven by automations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it fits together
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────┐ TCP ┌─────────────────────────┐
|
||||||
|
│ Home Assistant (k8s) │ TCPClientInterface│ Neighbouring machine │
|
||||||
|
│ │ ─────────────────► │ Reticulum instance │
|
||||||
|
│ reticulum integration │ │ (TCPServerInterface) │
|
||||||
|
│ • runs its own RNS │ ◄───────────────── │ + any other interfaces │
|
||||||
|
│ • LXMF delivery dest. │ │ (LoRa / RNode / I2P …) │
|
||||||
|
│ • Assist bridge │ └─────────────────────────┘
|
||||||
|
└────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Home Assistant runs a **standalone, non-shared** Reticulum instance inside its
|
||||||
|
own process and reaches the wider network through the single `TCPClientInterface`
|
||||||
|
pointing at the neighbouring stack. No shared-instance socket is used, which is
|
||||||
|
what you want inside a container/pod.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Home Assistant 2024.11 or newer.
|
||||||
|
- A reachable Reticulum instance on another machine with a `TCPServerInterface`
|
||||||
|
enabled. Example `~/.reticulum/config` on that machine:
|
||||||
|
|
||||||
|
```
|
||||||
|
[interfaces]
|
||||||
|
[[TCP Server Interface]]
|
||||||
|
type = TCPServerInterface
|
||||||
|
enabled = yes
|
||||||
|
listen_ip = 0.0.0.0
|
||||||
|
listen_port = 4242
|
||||||
|
```
|
||||||
|
|
||||||
|
In Kubernetes, expose that port to the HA pod (Service / NodePort / host
|
||||||
|
networking as appropriate). Do **not** enable `kiss_framing` on either side.
|
||||||
|
|
||||||
|
The integration declares its Python dependencies (`rns`, `lxmf`) in
|
||||||
|
`manifest.json`; Home Assistant installs them automatically on first setup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### HACS (custom repository)
|
||||||
|
1. HACS → ⋮ → **Custom repositories**.
|
||||||
|
2. Add this repository URL, category **Integration**.
|
||||||
|
3. Install **Reticulum**, then restart Home Assistant.
|
||||||
|
|
||||||
|
### Manual
|
||||||
|
Copy `custom_components/reticulum` into your Home Assistant `config/custom_components/`
|
||||||
|
directory and restart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
**Settings → Devices & Services → Add Integration → Reticulum.**
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| Target host | Host/IP of the neighbouring Reticulum TCP server |
|
||||||
|
| Target port | Its `listen_port` (default `4242`) |
|
||||||
|
| Display name | Name announced to other peers (e.g. `Home Assistant`) |
|
||||||
|
| Interface name | Label for the connection in the generated RNS config |
|
||||||
|
|
||||||
|
On first start the integration generates a stable identity and announces it.
|
||||||
|
Find your **LXMF address** on the *LXMF address* sensor, or in the log:
|
||||||
|
|
||||||
|
```
|
||||||
|
Reticulum ready. LXMF address: a1b2c3… (Home Assistant)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add that address as a contact in Sideband/MeshChat and send a message — Assist
|
||||||
|
will answer.
|
||||||
|
|
||||||
|
### Options (⚙ → Configure)
|
||||||
|
|
||||||
|
- **Route incoming messages to Assist** – turn the chat bridge on/off.
|
||||||
|
- **Assist agent / language** – pick a specific conversation agent, or leave
|
||||||
|
blank for the default pipeline.
|
||||||
|
- **Reply to any sender** / **Allowed sender addresses** – restrict who Assist
|
||||||
|
will answer.
|
||||||
|
- **Default recipient** – address used by the `notify.reticulum` entity.
|
||||||
|
- **Default delivery method** – `direct`, `opportunistic`, or `propagated`.
|
||||||
|
- **Announce interval** – periodic re-announce (seconds; `0` disables).
|
||||||
|
- **Propagation node** + **sync interval** – optional store-and-forward node so
|
||||||
|
messages queue while HA is offline.
|
||||||
|
- **Reticulum log level** – `0`–`7`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
| Entity | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `sensor.reticulum_lxmf_address` | This HA's LXMF address (share this with users) |
|
||||||
|
| `sensor.reticulum_messages_received` / `_sent` / `_failed` | Counters |
|
||||||
|
| `sensor.reticulum_known_peers` | Count + `peers` attribute (address, name, hops) |
|
||||||
|
| `sensor.reticulum_last_message` | Last inbound text + source/title attributes |
|
||||||
|
| `sensor.reticulum_last_message_time` | Timestamp of last inbound message |
|
||||||
|
| `sensor.reticulum_received` / `_transmitted` | Interface RX/TX byte counters |
|
||||||
|
| `sensor.reticulum_bitrate` | Interface bitrate |
|
||||||
|
| `sensor.reticulum_rssi` / `_snr` / `_link_quality` | Physical-link telemetry (disabled by default) |
|
||||||
|
| `binary_sensor.reticulum_connected` | Whether the TCP interface is online |
|
||||||
|
| `button.reticulum_announce` | Announce now |
|
||||||
|
| `button.reticulum_sync_propagation_node` | Pull queued messages now |
|
||||||
|
| `notify.reticulum_message` | Send a message to the default recipient |
|
||||||
|
|
||||||
|
The telemetry sensors read from the outbound interface. RX/TX bytes and bitrate
|
||||||
|
work for the TCP link. **RSSI, SNR and link quality only carry data when the
|
||||||
|
underlying interface is a physical one (RNode/LoRa)** — they stay unavailable on
|
||||||
|
a plain TCP link, so they are disabled by default. Enable them from the device
|
||||||
|
page if you run a physical interface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
### `reticulum.send_message`
|
||||||
|
Send to any destination. Returns the message hash.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
action: reticulum.send_message
|
||||||
|
data:
|
||||||
|
destination: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
|
||||||
|
content: "The garage door is still open."
|
||||||
|
title: "Home Assistant"
|
||||||
|
method: direct # direct | opportunistic | propagated
|
||||||
|
```
|
||||||
|
|
||||||
|
Other services: `reticulum.announce`, `reticulum.request_path`,
|
||||||
|
`reticulum.set_propagation_node`, `reticulum.sync_propagation`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
- `reticulum_message_received` — `{content, title, source, destination, timestamp, signature_validated, fields}`
|
||||||
|
- `reticulum_message_delivered` — `{message_hash, destination}`
|
||||||
|
- `reticulum_message_failed` — `{message_hash, destination}`
|
||||||
|
- `reticulum_announce_received` — `{destination, display_name, stamp_cost, hops}`
|
||||||
|
|
||||||
|
### Example automation — forward every inbound message to a mobile push
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
alias: Reticulum → phone
|
||||||
|
trigger:
|
||||||
|
- platform: event
|
||||||
|
event_type: reticulum_message_received
|
||||||
|
action:
|
||||||
|
- action: notify.mobile_app_phone
|
||||||
|
data:
|
||||||
|
title: "Reticulum: {{ trigger.event.data.source[:8] }}"
|
||||||
|
message: "{{ trigger.event.data.content }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Device triggers (automation UI)
|
||||||
|
|
||||||
|
Instead of raw event triggers you can pick these from the automation editor
|
||||||
|
under the Reticulum device ("When… → Reticulum → …"):
|
||||||
|
|
||||||
|
- **Message received**
|
||||||
|
- **Message delivered**
|
||||||
|
- **Message delivery failed**
|
||||||
|
- **Announce received (peer heard)**
|
||||||
|
|
||||||
|
Each wraps the matching bus event, so `trigger.event.data` carries the same
|
||||||
|
payload described above.
|
||||||
|
|
||||||
|
### Example automation — alert over Reticulum when a door opens
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
alias: Door alert over Reticulum
|
||||||
|
trigger:
|
||||||
|
- platform: state
|
||||||
|
entity_id: binary_sensor.front_door
|
||||||
|
to: "on"
|
||||||
|
action:
|
||||||
|
- action: reticulum.send_message
|
||||||
|
data:
|
||||||
|
destination: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
|
||||||
|
content: "Front door opened at {{ now().strftime('%H:%M') }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes & limitations
|
||||||
|
|
||||||
|
- Reticulum uses a **single process-wide instance**. Only one Reticulum config
|
||||||
|
entry is allowed, and because RNS/LXMF cannot be cleanly torn down inside a
|
||||||
|
running process, changing the host/port or fully restarting the stack requires
|
||||||
|
restarting Home Assistant. Option changes that don't touch the stack apply on
|
||||||
|
reload.
|
||||||
|
- The identity is stored in `config/reticulum/identity` — back it up to keep the
|
||||||
|
same address.
|
||||||
|
- Assist replies use the standard conversation pipeline, so whichever agent you
|
||||||
|
select (built-in intents, a local LLM, a cloud LLM, …) is what answers.
|
||||||
|
|
||||||
|
## License
|
||||||
|
MIT
|
||||||
|
|||||||
400
custom_components/reticulum/__init__.py
Normal file
400
custom_components/reticulum/__init__.py
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
"""The Reticulum integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
from homeassistant.core import (
|
||||||
|
HomeAssistant,
|
||||||
|
ServiceCall,
|
||||||
|
ServiceResponse,
|
||||||
|
SupportsResponse,
|
||||||
|
)
|
||||||
|
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
from homeassistant.helpers.event import async_track_time_interval
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
ATTR_CONTENT,
|
||||||
|
ATTR_DESTINATION,
|
||||||
|
ATTR_FIELDS,
|
||||||
|
ATTR_MAX_MESSAGES,
|
||||||
|
ATTR_METHOD,
|
||||||
|
ATTR_TITLE,
|
||||||
|
CONF_ALLOW_ALL,
|
||||||
|
CONF_ALLOWED_IDENTITIES,
|
||||||
|
CONF_ANNOUNCE_INTERVAL,
|
||||||
|
CONF_ASSIST_AGENT,
|
||||||
|
CONF_ASSIST_LANGUAGE,
|
||||||
|
CONF_DISPLAY_NAME,
|
||||||
|
CONF_ENABLE_ASSIST,
|
||||||
|
CONF_GREETING,
|
||||||
|
CONF_INTERFACE_NAME,
|
||||||
|
CONF_LOGLEVEL,
|
||||||
|
CONF_PROPAGATION_NODE,
|
||||||
|
CONF_SYNC_INTERVAL,
|
||||||
|
CONF_TARGET_HOST,
|
||||||
|
CONF_TARGET_PORT,
|
||||||
|
DEFAULT_ANNOUNCE_INTERVAL,
|
||||||
|
DEFAULT_DISPLAY_NAME,
|
||||||
|
DEFAULT_INTERFACE_NAME,
|
||||||
|
DEFAULT_LOGLEVEL,
|
||||||
|
DEFAULT_SYNC_INTERVAL,
|
||||||
|
DELIVERY_METHODS,
|
||||||
|
DOMAIN,
|
||||||
|
EVENT_MESSAGE_RECEIVED,
|
||||||
|
SERVICE_ANNOUNCE,
|
||||||
|
SERVICE_REQUEST_PATH,
|
||||||
|
SERVICE_SEND_MESSAGE,
|
||||||
|
SERVICE_SET_PROPAGATION_NODE,
|
||||||
|
SERVICE_SYNC_PROPAGATION,
|
||||||
|
STORAGE_SUBDIR,
|
||||||
|
)
|
||||||
|
from .reticulum_client import ReticulumError, ReticulumManager
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PLATFORMS: list[Platform] = [
|
||||||
|
Platform.BINARY_SENSOR,
|
||||||
|
Platform.BUTTON,
|
||||||
|
Platform.NOTIFY,
|
||||||
|
Platform.SENSOR,
|
||||||
|
]
|
||||||
|
|
||||||
|
# Interface-status poll: RNS has no push for link up/down, so poll cheaply.
|
||||||
|
INTERFACE_POLL = timedelta(seconds=15)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeData:
|
||||||
|
"""Container stored on the config entry."""
|
||||||
|
|
||||||
|
def __init__(self, manager: ReticulumManager) -> None:
|
||||||
|
self.manager = manager
|
||||||
|
self.unsubs: list[Any] = []
|
||||||
|
# Maps a peer address -> the HA conversation_id we opened for it, so
|
||||||
|
# each remote user keeps its own Assist conversation context.
|
||||||
|
self.conversation_ids: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up Reticulum from a config entry."""
|
||||||
|
options = entry.options
|
||||||
|
storage_dir = hass.config.path(STORAGE_SUBDIR)
|
||||||
|
|
||||||
|
manager = ReticulumManager(
|
||||||
|
hass,
|
||||||
|
storage_dir,
|
||||||
|
target_host=entry.data[CONF_TARGET_HOST],
|
||||||
|
target_port=entry.data[CONF_TARGET_PORT],
|
||||||
|
interface_name=entry.data.get(CONF_INTERFACE_NAME, DEFAULT_INTERFACE_NAME),
|
||||||
|
display_name=entry.data.get(CONF_DISPLAY_NAME, DEFAULT_DISPLAY_NAME),
|
||||||
|
loglevel=int(options.get(CONF_LOGLEVEL, DEFAULT_LOGLEVEL)),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await manager.async_start()
|
||||||
|
except Exception as err: # noqa: BLE001
|
||||||
|
raise ConfigEntryNotReady(f"Could not start Reticulum: {err}") from err
|
||||||
|
|
||||||
|
runtime = RuntimeData(manager)
|
||||||
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime
|
||||||
|
|
||||||
|
# Wire the incoming-message pipeline (assist bridge + event).
|
||||||
|
manager.incoming_handler = _make_incoming_handler(hass, entry, runtime)
|
||||||
|
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
# Announce on startup so peers learn our address, then periodically.
|
||||||
|
await _safe_announce(manager)
|
||||||
|
_schedule_periodic(hass, entry, runtime)
|
||||||
|
|
||||||
|
# Poll interface status.
|
||||||
|
runtime.unsubs.append(
|
||||||
|
async_track_time_interval(
|
||||||
|
hass, lambda _now: manager.refresh_interface_status(), INTERFACE_POLL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
entry.async_on_unload(entry.add_update_listener(_async_options_updated))
|
||||||
|
_async_register_services(hass)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
runtime: RuntimeData | None = hass.data.get(DOMAIN, {}).get(entry.entry_id)
|
||||||
|
if runtime is not None:
|
||||||
|
for unsub in runtime.unsubs:
|
||||||
|
unsub()
|
||||||
|
await runtime.manager.async_stop()
|
||||||
|
if unload_ok:
|
||||||
|
hass.data[DOMAIN].pop(entry.entry_id, None)
|
||||||
|
if not hass.data[DOMAIN]:
|
||||||
|
_async_unregister_services(hass)
|
||||||
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
"""Reload the entry so option changes take effect."""
|
||||||
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Periodic tasks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _schedule_periodic(
|
||||||
|
hass: HomeAssistant, entry: ConfigEntry, runtime: RuntimeData
|
||||||
|
) -> None:
|
||||||
|
manager = runtime.manager
|
||||||
|
announce_interval = entry.options.get(
|
||||||
|
CONF_ANNOUNCE_INTERVAL, DEFAULT_ANNOUNCE_INTERVAL
|
||||||
|
)
|
||||||
|
if announce_interval and announce_interval > 0:
|
||||||
|
|
||||||
|
async def _do_announce(_now) -> None:
|
||||||
|
await _safe_announce(manager)
|
||||||
|
|
||||||
|
runtime.unsubs.append(
|
||||||
|
async_track_time_interval(
|
||||||
|
hass, _do_announce, timedelta(seconds=announce_interval)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
sync_interval = entry.options.get(CONF_SYNC_INTERVAL, DEFAULT_SYNC_INTERVAL)
|
||||||
|
propagation_node = entry.options.get(CONF_PROPAGATION_NODE)
|
||||||
|
if propagation_node:
|
||||||
|
hass.async_create_task(_set_propagation(manager, propagation_node))
|
||||||
|
if sync_interval and sync_interval > 0:
|
||||||
|
|
||||||
|
async def _do_sync(_now) -> None:
|
||||||
|
try:
|
||||||
|
await manager.async_sync_propagation()
|
||||||
|
except ReticulumError as err:
|
||||||
|
_LOGGER.warning("Propagation sync failed: %s", err)
|
||||||
|
|
||||||
|
runtime.unsubs.append(
|
||||||
|
async_track_time_interval(
|
||||||
|
hass, _do_sync, timedelta(seconds=sync_interval)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _set_propagation(manager: ReticulumManager, node: str) -> None:
|
||||||
|
try:
|
||||||
|
await manager.async_set_propagation_node(node)
|
||||||
|
except ReticulumError as err:
|
||||||
|
_LOGGER.warning("Could not set propagation node: %s", err)
|
||||||
|
|
||||||
|
|
||||||
|
async def _safe_announce(manager: ReticulumManager) -> None:
|
||||||
|
try:
|
||||||
|
await manager.async_announce()
|
||||||
|
except ReticulumError as err:
|
||||||
|
_LOGGER.debug("Announce skipped: %s", err)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Incoming message pipeline
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _make_incoming_handler(
|
||||||
|
hass: HomeAssistant, entry: ConfigEntry, runtime: RuntimeData
|
||||||
|
):
|
||||||
|
async def _handle(payload: dict[str, Any]) -> None:
|
||||||
|
source = payload["source"]
|
||||||
|
|
||||||
|
# Always surface the message as an event for automations.
|
||||||
|
hass.bus.async_fire(EVENT_MESSAGE_RECEIVED, payload)
|
||||||
|
|
||||||
|
options = entry.options
|
||||||
|
allow_all = options.get(CONF_ALLOW_ALL, True)
|
||||||
|
allowed = {
|
||||||
|
a.strip().lower()
|
||||||
|
for a in options.get(CONF_ALLOWED_IDENTITIES, [])
|
||||||
|
if a and a.strip()
|
||||||
|
}
|
||||||
|
if not allow_all and source.lower() not in allowed:
|
||||||
|
_LOGGER.debug("Ignoring message from non-allowed sender %s", source)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not options.get(CONF_ENABLE_ASSIST, True):
|
||||||
|
return
|
||||||
|
|
||||||
|
text = (payload.get("content") or "").strip()
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
|
||||||
|
await _run_assist(hass, entry, runtime, source, text)
|
||||||
|
|
||||||
|
return _handle
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_assist(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
runtime: RuntimeData,
|
||||||
|
source: str,
|
||||||
|
text: str,
|
||||||
|
) -> None:
|
||||||
|
"""Route a message through the Assist conversation pipeline and reply."""
|
||||||
|
from homeassistant.components import conversation # noqa: PLC0415
|
||||||
|
from homeassistant.core import Context # noqa: PLC0415
|
||||||
|
|
||||||
|
options = entry.options
|
||||||
|
agent_id = options.get(CONF_ASSIST_AGENT) or None
|
||||||
|
language = options.get(CONF_ASSIST_LANGUAGE) or hass.config.language
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await conversation.async_converse(
|
||||||
|
hass=hass,
|
||||||
|
text=text,
|
||||||
|
conversation_id=runtime.conversation_ids.get(source),
|
||||||
|
context=Context(),
|
||||||
|
language=language,
|
||||||
|
agent_id=agent_id,
|
||||||
|
)
|
||||||
|
except Exception as err: # noqa: BLE001
|
||||||
|
_LOGGER.exception("Assist conversation failed")
|
||||||
|
await _reply(runtime.manager, source, f"⚠️ Assist error: {err}", "Error")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remember the conversation so this peer keeps context.
|
||||||
|
conv_id = getattr(result, "conversation_id", None)
|
||||||
|
if conv_id:
|
||||||
|
runtime.conversation_ids[source] = conv_id
|
||||||
|
|
||||||
|
reply = _extract_speech(result)
|
||||||
|
if reply:
|
||||||
|
await _reply(runtime.manager, source, reply, "Assist")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_speech(result: Any) -> str | None:
|
||||||
|
try:
|
||||||
|
speech = result.response.speech
|
||||||
|
if isinstance(speech, dict):
|
||||||
|
return speech.get("plain", {}).get("speech")
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _reply(
|
||||||
|
manager: ReticulumManager, destination: str, content: str, title: str
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
await manager.async_send_message(destination, content, title=title)
|
||||||
|
except ReticulumError as err:
|
||||||
|
_LOGGER.warning("Could not send Assist reply to %s: %s", destination, err)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Services
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SEND_MESSAGE_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(ATTR_DESTINATION): cv.string,
|
||||||
|
vol.Required(ATTR_CONTENT): cv.string,
|
||||||
|
vol.Optional(ATTR_TITLE, default=""): cv.string,
|
||||||
|
vol.Optional(ATTR_METHOD, default="direct"): vol.In(DELIVERY_METHODS),
|
||||||
|
vol.Optional(ATTR_FIELDS): dict,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
REQUEST_PATH_SCHEMA = vol.Schema({vol.Required(ATTR_DESTINATION): cv.string})
|
||||||
|
SET_PROPAGATION_SCHEMA = vol.Schema({vol.Required(ATTR_DESTINATION): cv.string})
|
||||||
|
SYNC_SCHEMA = vol.Schema(
|
||||||
|
{vol.Optional(ATTR_MAX_MESSAGES): vol.All(vol.Coerce(int), vol.Range(min=1))}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _first_manager(hass: HomeAssistant) -> ReticulumManager:
|
||||||
|
data: dict[str, RuntimeData] = hass.data.get(DOMAIN, {})
|
||||||
|
if not data:
|
||||||
|
raise HomeAssistantError("Reticulum is not configured")
|
||||||
|
return next(iter(data.values())).manager
|
||||||
|
|
||||||
|
|
||||||
|
def _async_register_services(hass: HomeAssistant) -> None:
|
||||||
|
if hass.services.has_service(DOMAIN, SERVICE_SEND_MESSAGE):
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _send(call: ServiceCall) -> ServiceResponse:
|
||||||
|
manager = _first_manager(hass)
|
||||||
|
try:
|
||||||
|
message_hash = await manager.async_send_message(
|
||||||
|
call.data[ATTR_DESTINATION],
|
||||||
|
call.data[ATTR_CONTENT],
|
||||||
|
title=call.data.get(ATTR_TITLE, ""),
|
||||||
|
method=call.data.get(ATTR_METHOD, "direct"),
|
||||||
|
fields=call.data.get(ATTR_FIELDS),
|
||||||
|
)
|
||||||
|
except ReticulumError as err:
|
||||||
|
raise HomeAssistantError(str(err)) from err
|
||||||
|
return {"message_hash": message_hash}
|
||||||
|
|
||||||
|
async def _announce(call: ServiceCall) -> None:
|
||||||
|
try:
|
||||||
|
await _first_manager(hass).async_announce()
|
||||||
|
except ReticulumError as err:
|
||||||
|
raise HomeAssistantError(str(err)) from err
|
||||||
|
|
||||||
|
async def _request_path(call: ServiceCall) -> None:
|
||||||
|
try:
|
||||||
|
await _first_manager(hass).async_request_path(call.data[ATTR_DESTINATION])
|
||||||
|
except ReticulumError as err:
|
||||||
|
raise HomeAssistantError(str(err)) from err
|
||||||
|
|
||||||
|
async def _set_propagation(call: ServiceCall) -> None:
|
||||||
|
try:
|
||||||
|
await _first_manager(hass).async_set_propagation_node(
|
||||||
|
call.data[ATTR_DESTINATION]
|
||||||
|
)
|
||||||
|
except ReticulumError as err:
|
||||||
|
raise HomeAssistantError(str(err)) from err
|
||||||
|
|
||||||
|
async def _sync(call: ServiceCall) -> None:
|
||||||
|
try:
|
||||||
|
await _first_manager(hass).async_sync_propagation(
|
||||||
|
call.data.get(ATTR_MAX_MESSAGES)
|
||||||
|
)
|
||||||
|
except ReticulumError as err:
|
||||||
|
raise HomeAssistantError(str(err)) from err
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_SEND_MESSAGE,
|
||||||
|
_send,
|
||||||
|
schema=SEND_MESSAGE_SCHEMA,
|
||||||
|
supports_response=SupportsResponse.OPTIONAL,
|
||||||
|
)
|
||||||
|
hass.services.async_register(DOMAIN, SERVICE_ANNOUNCE, _announce)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN, SERVICE_REQUEST_PATH, _request_path, schema=REQUEST_PATH_SCHEMA
|
||||||
|
)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_SET_PROPAGATION_NODE,
|
||||||
|
_set_propagation,
|
||||||
|
schema=SET_PROPAGATION_SCHEMA,
|
||||||
|
)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN, SERVICE_SYNC_PROPAGATION, _sync, schema=SYNC_SCHEMA
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _async_unregister_services(hass: HomeAssistant) -> None:
|
||||||
|
for service in (
|
||||||
|
SERVICE_SEND_MESSAGE,
|
||||||
|
SERVICE_ANNOUNCE,
|
||||||
|
SERVICE_REQUEST_PATH,
|
||||||
|
SERVICE_SET_PROPAGATION_NODE,
|
||||||
|
SERVICE_SYNC_PROPAGATION,
|
||||||
|
):
|
||||||
|
if hass.services.has_service(DOMAIN, service):
|
||||||
|
hass.services.async_remove(DOMAIN, service)
|
||||||
40
custom_components/reticulum/binary_sensor.py
Normal file
40
custom_components/reticulum/binary_sensor.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""Binary sensor platform for Reticulum."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.components.binary_sensor import (
|
||||||
|
BinarySensorDeviceClass,
|
||||||
|
BinarySensorEntity,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .entity import ReticulumEntity
|
||||||
|
from .reticulum_client import ReticulumManager
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up the Reticulum connectivity binary sensor."""
|
||||||
|
manager: ReticulumManager = hass.data[DOMAIN][entry.entry_id].manager
|
||||||
|
async_add_entities([ReticulumConnectivity(manager, entry.entry_id)])
|
||||||
|
|
||||||
|
|
||||||
|
class ReticulumConnectivity(ReticulumEntity, BinarySensorEntity):
|
||||||
|
"""Reports whether the outbound Reticulum interface is online."""
|
||||||
|
|
||||||
|
_attr_translation_key = "connected"
|
||||||
|
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
|
||||||
|
|
||||||
|
def __init__(self, manager: ReticulumManager, entry_id: str) -> None:
|
||||||
|
super().__init__(manager, entry_id)
|
||||||
|
self._attr_unique_id = f"{entry_id}_connected"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
return self._manager.state.interface_online
|
||||||
65
custom_components/reticulum/button.py
Normal file
65
custom_components/reticulum/button.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""Button platform for Reticulum."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.components.button import ButtonEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .entity import ReticulumEntity
|
||||||
|
from .reticulum_client import ReticulumError, ReticulumManager
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Reticulum buttons."""
|
||||||
|
manager: ReticulumManager = hass.data[DOMAIN][entry.entry_id].manager
|
||||||
|
async_add_entities(
|
||||||
|
[
|
||||||
|
ReticulumAnnounceButton(manager, entry.entry_id),
|
||||||
|
ReticulumSyncButton(manager, entry.entry_id),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReticulumAnnounceButton(ReticulumEntity, ButtonEntity):
|
||||||
|
"""Announce our LXMF destination on demand."""
|
||||||
|
|
||||||
|
_attr_translation_key = "announce"
|
||||||
|
_attr_icon = "mdi:bullhorn"
|
||||||
|
|
||||||
|
def __init__(self, manager: ReticulumManager, entry_id: str) -> None:
|
||||||
|
super().__init__(manager, entry_id)
|
||||||
|
self._attr_unique_id = f"{entry_id}_announce"
|
||||||
|
|
||||||
|
async def async_press(self) -> None:
|
||||||
|
try:
|
||||||
|
await self._manager.async_announce()
|
||||||
|
except ReticulumError as err:
|
||||||
|
_LOGGER.warning("Announce failed: %s", err)
|
||||||
|
|
||||||
|
|
||||||
|
class ReticulumSyncButton(ReticulumEntity, ButtonEntity):
|
||||||
|
"""Pull queued messages from the configured propagation node."""
|
||||||
|
|
||||||
|
_attr_translation_key = "sync"
|
||||||
|
_attr_icon = "mdi:sync"
|
||||||
|
|
||||||
|
def __init__(self, manager: ReticulumManager, entry_id: str) -> None:
|
||||||
|
super().__init__(manager, entry_id)
|
||||||
|
self._attr_unique_id = f"{entry_id}_sync"
|
||||||
|
|
||||||
|
async def async_press(self) -> None:
|
||||||
|
try:
|
||||||
|
await self._manager.async_sync_propagation()
|
||||||
|
except ReticulumError as err:
|
||||||
|
_LOGGER.warning("Propagation sync failed: %s", err)
|
||||||
245
custom_components/reticulum/config_flow.py
Normal file
245
custom_components/reticulum/config_flow.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
"""Config flow for the Reticulum integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import (
|
||||||
|
ConfigEntry,
|
||||||
|
ConfigFlow,
|
||||||
|
ConfigFlowResult,
|
||||||
|
OptionsFlow,
|
||||||
|
)
|
||||||
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
from homeassistant.helpers.selector import (
|
||||||
|
BooleanSelector,
|
||||||
|
NumberSelector,
|
||||||
|
NumberSelectorConfig,
|
||||||
|
NumberSelectorMode,
|
||||||
|
SelectSelector,
|
||||||
|
SelectSelectorConfig,
|
||||||
|
SelectSelectorMode,
|
||||||
|
TextSelector,
|
||||||
|
TextSelectorConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
CONF_ALLOW_ALL,
|
||||||
|
CONF_ALLOWED_IDENTITIES,
|
||||||
|
CONF_ANNOUNCE_INTERVAL,
|
||||||
|
CONF_ASSIST_AGENT,
|
||||||
|
CONF_ASSIST_LANGUAGE,
|
||||||
|
CONF_DEFAULT_RECIPIENT,
|
||||||
|
CONF_DELIVERY_METHOD,
|
||||||
|
CONF_DISPLAY_NAME,
|
||||||
|
CONF_ENABLE_ASSIST,
|
||||||
|
CONF_GREETING,
|
||||||
|
CONF_INTERFACE_NAME,
|
||||||
|
CONF_LOGLEVEL,
|
||||||
|
CONF_PROPAGATION_NODE,
|
||||||
|
CONF_SYNC_INTERVAL,
|
||||||
|
CONF_TARGET_HOST,
|
||||||
|
CONF_TARGET_PORT,
|
||||||
|
DEFAULT_ANNOUNCE_INTERVAL,
|
||||||
|
DEFAULT_DELIVERY_METHOD,
|
||||||
|
DEFAULT_DISPLAY_NAME,
|
||||||
|
DEFAULT_INTERFACE_NAME,
|
||||||
|
DEFAULT_LOGLEVEL,
|
||||||
|
DEFAULT_SYNC_INTERVAL,
|
||||||
|
DEFAULT_TARGET_PORT,
|
||||||
|
DELIVERY_METHODS,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _test_connection(host: str, port: int) -> None:
|
||||||
|
"""Verify the Reticulum TCP server is reachable. Raises on failure."""
|
||||||
|
try:
|
||||||
|
_, writer = await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(host, port), timeout=10
|
||||||
|
)
|
||||||
|
writer.close()
|
||||||
|
try:
|
||||||
|
await writer.wait_closed()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
except (OSError, asyncio.TimeoutError) as err:
|
||||||
|
raise CannotConnect(str(err)) from err
|
||||||
|
|
||||||
|
|
||||||
|
class CannotConnect(Exception):
|
||||||
|
"""Error to indicate we cannot connect to the TCP server."""
|
||||||
|
|
||||||
|
|
||||||
|
STEP_USER_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_TARGET_HOST): cv.string,
|
||||||
|
vol.Required(CONF_TARGET_PORT, default=DEFAULT_TARGET_PORT): vol.All(
|
||||||
|
vol.Coerce(int), vol.Range(min=1, max=65535)
|
||||||
|
),
|
||||||
|
vol.Required(CONF_DISPLAY_NAME, default=DEFAULT_DISPLAY_NAME): cv.string,
|
||||||
|
vol.Required(
|
||||||
|
CONF_INTERFACE_NAME, default=DEFAULT_INTERFACE_NAME
|
||||||
|
): cv.string,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReticulumConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Reticulum."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle the initial step."""
|
||||||
|
self._async_abort_entries_match() # single_config_entry also enforces this
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
try:
|
||||||
|
await _test_connection(
|
||||||
|
user_input[CONF_TARGET_HOST], user_input[CONF_TARGET_PORT]
|
||||||
|
)
|
||||||
|
except CannotConnect:
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
else:
|
||||||
|
await self.async_set_unique_id(DOMAIN)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=f"Reticulum ({user_input[CONF_DISPLAY_NAME]})",
|
||||||
|
data=user_input,
|
||||||
|
options=_default_options(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user", data_schema=STEP_USER_SCHEMA, errors=errors
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@callback
|
||||||
|
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
||||||
|
"""Get the options flow for this handler."""
|
||||||
|
return ReticulumOptionsFlow()
|
||||||
|
|
||||||
|
|
||||||
|
def _default_options() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
CONF_ENABLE_ASSIST: True,
|
||||||
|
CONF_ALLOW_ALL: True,
|
||||||
|
CONF_ALLOWED_IDENTITIES: [],
|
||||||
|
CONF_ANNOUNCE_INTERVAL: DEFAULT_ANNOUNCE_INTERVAL,
|
||||||
|
CONF_SYNC_INTERVAL: DEFAULT_SYNC_INTERVAL,
|
||||||
|
CONF_DELIVERY_METHOD: DEFAULT_DELIVERY_METHOD,
|
||||||
|
CONF_LOGLEVEL: DEFAULT_LOGLEVEL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ReticulumOptionsFlow(OptionsFlow):
|
||||||
|
"""Handle Reticulum options."""
|
||||||
|
|
||||||
|
async def async_step_init(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Manage the options."""
|
||||||
|
if user_input is not None:
|
||||||
|
# Normalise the comma/newline separated allow-list into a list.
|
||||||
|
raw = user_input.get(CONF_ALLOWED_IDENTITIES, "")
|
||||||
|
if isinstance(raw, str):
|
||||||
|
user_input[CONF_ALLOWED_IDENTITIES] = [
|
||||||
|
item.strip()
|
||||||
|
for item in raw.replace("\n", ",").split(",")
|
||||||
|
if item.strip()
|
||||||
|
]
|
||||||
|
return self.async_create_entry(data=user_input)
|
||||||
|
|
||||||
|
opts = self.config_entry.options
|
||||||
|
allowed = opts.get(CONF_ALLOWED_IDENTITIES, [])
|
||||||
|
allowed_str = ", ".join(allowed) if isinstance(allowed, list) else allowed
|
||||||
|
|
||||||
|
schema = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(
|
||||||
|
CONF_ENABLE_ASSIST,
|
||||||
|
default=opts.get(CONF_ENABLE_ASSIST, True),
|
||||||
|
): BooleanSelector(),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_ASSIST_AGENT,
|
||||||
|
description={
|
||||||
|
"suggested_value": opts.get(CONF_ASSIST_AGENT, "")
|
||||||
|
},
|
||||||
|
): TextSelector(),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_ASSIST_LANGUAGE,
|
||||||
|
description={
|
||||||
|
"suggested_value": opts.get(CONF_ASSIST_LANGUAGE, "")
|
||||||
|
},
|
||||||
|
): TextSelector(),
|
||||||
|
vol.Required(
|
||||||
|
CONF_ALLOW_ALL, default=opts.get(CONF_ALLOW_ALL, True)
|
||||||
|
): BooleanSelector(),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_ALLOWED_IDENTITIES,
|
||||||
|
description={"suggested_value": allowed_str},
|
||||||
|
): TextSelector(TextSelectorConfig(multiline=True)),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_DEFAULT_RECIPIENT,
|
||||||
|
description={
|
||||||
|
"suggested_value": opts.get(CONF_DEFAULT_RECIPIENT, "")
|
||||||
|
},
|
||||||
|
): TextSelector(),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_GREETING,
|
||||||
|
description={"suggested_value": opts.get(CONF_GREETING, "")},
|
||||||
|
): TextSelector(TextSelectorConfig(multiline=True)),
|
||||||
|
vol.Required(
|
||||||
|
CONF_DELIVERY_METHOD,
|
||||||
|
default=opts.get(CONF_DELIVERY_METHOD, DEFAULT_DELIVERY_METHOD),
|
||||||
|
): SelectSelector(
|
||||||
|
SelectSelectorConfig(
|
||||||
|
options=DELIVERY_METHODS,
|
||||||
|
translation_key="delivery_method",
|
||||||
|
mode=SelectSelectorMode.DROPDOWN,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
vol.Required(
|
||||||
|
CONF_ANNOUNCE_INTERVAL,
|
||||||
|
default=opts.get(
|
||||||
|
CONF_ANNOUNCE_INTERVAL, DEFAULT_ANNOUNCE_INTERVAL
|
||||||
|
),
|
||||||
|
): NumberSelector(
|
||||||
|
NumberSelectorConfig(
|
||||||
|
min=0, max=86400, step=60, mode=NumberSelectorMode.BOX,
|
||||||
|
unit_of_measurement="s",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_PROPAGATION_NODE,
|
||||||
|
description={
|
||||||
|
"suggested_value": opts.get(CONF_PROPAGATION_NODE, "")
|
||||||
|
},
|
||||||
|
): TextSelector(),
|
||||||
|
vol.Required(
|
||||||
|
CONF_SYNC_INTERVAL,
|
||||||
|
default=opts.get(CONF_SYNC_INTERVAL, DEFAULT_SYNC_INTERVAL),
|
||||||
|
): NumberSelector(
|
||||||
|
NumberSelectorConfig(
|
||||||
|
min=0, max=86400, step=60, mode=NumberSelectorMode.BOX,
|
||||||
|
unit_of_measurement="s",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
vol.Required(
|
||||||
|
CONF_LOGLEVEL, default=opts.get(CONF_LOGLEVEL, DEFAULT_LOGLEVEL)
|
||||||
|
): NumberSelector(
|
||||||
|
NumberSelectorConfig(
|
||||||
|
min=0, max=7, step=1, mode=NumberSelectorMode.SLIDER
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self.async_show_form(step_id="init", data_schema=schema)
|
||||||
87
custom_components/reticulum/const.py
Normal file
87
custom_components/reticulum/const.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""Constants for the Reticulum integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
DOMAIN: Final = "reticulum"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration keys
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
CONF_TARGET_HOST: Final = "target_host"
|
||||||
|
CONF_TARGET_PORT: Final = "target_port"
|
||||||
|
CONF_INTERFACE_NAME: Final = "interface_name"
|
||||||
|
CONF_DISPLAY_NAME: Final = "display_name"
|
||||||
|
|
||||||
|
# Options
|
||||||
|
CONF_ENABLE_ASSIST: Final = "enable_assist"
|
||||||
|
CONF_ASSIST_AGENT: Final = "assist_agent"
|
||||||
|
CONF_ASSIST_LANGUAGE: Final = "assist_language"
|
||||||
|
CONF_ALLOWED_IDENTITIES: Final = "allowed_identities"
|
||||||
|
CONF_ALLOW_ALL: Final = "allow_all_senders"
|
||||||
|
CONF_DEFAULT_RECIPIENT: Final = "default_recipient"
|
||||||
|
CONF_ANNOUNCE_INTERVAL: Final = "announce_interval"
|
||||||
|
CONF_PROPAGATION_NODE: Final = "propagation_node"
|
||||||
|
CONF_SYNC_INTERVAL: Final = "sync_interval"
|
||||||
|
CONF_DELIVERY_METHOD: Final = "delivery_method"
|
||||||
|
CONF_STAMP_COST: Final = "stamp_cost"
|
||||||
|
CONF_LOGLEVEL: Final = "loglevel"
|
||||||
|
CONF_GREETING: Final = "greeting"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Defaults
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
DEFAULT_INTERFACE_NAME: Final = "HA TCP Client"
|
||||||
|
DEFAULT_DISPLAY_NAME: Final = "Home Assistant"
|
||||||
|
DEFAULT_TARGET_PORT: Final = 4242
|
||||||
|
DEFAULT_ANNOUNCE_INTERVAL: Final = 1800 # seconds; 0 disables periodic announce
|
||||||
|
DEFAULT_SYNC_INTERVAL: Final = 0 # seconds; 0 disables propagation-node sync
|
||||||
|
DEFAULT_DELIVERY_METHOD: Final = "direct"
|
||||||
|
DEFAULT_STAMP_COST: Final = 0
|
||||||
|
DEFAULT_LOGLEVEL: Final = 3 # RNS.LOG_NOTICE
|
||||||
|
|
||||||
|
DELIVERY_METHODS: Final = ["direct", "opportunistic", "propagated"]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Storage
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
STORAGE_SUBDIR: Final = "reticulum"
|
||||||
|
IDENTITY_FILENAME: Final = "identity"
|
||||||
|
CONFIG_FILENAME: Final = "config"
|
||||||
|
ATTACHMENTS_SUBDIR: Final = "attachments"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dispatcher signals
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SIGNAL_STATE_UPDATED: Final = f"{DOMAIN}_state_updated"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Events
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
EVENT_MESSAGE_RECEIVED: Final = f"{DOMAIN}_message_received"
|
||||||
|
EVENT_MESSAGE_DELIVERED: Final = f"{DOMAIN}_message_delivered"
|
||||||
|
EVENT_MESSAGE_FAILED: Final = f"{DOMAIN}_message_failed"
|
||||||
|
EVENT_ANNOUNCE_RECEIVED: Final = f"{DOMAIN}_announce_received"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Services
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SERVICE_SEND_MESSAGE: Final = "send_message"
|
||||||
|
SERVICE_ANNOUNCE: Final = "announce"
|
||||||
|
SERVICE_REQUEST_PATH: Final = "request_path"
|
||||||
|
SERVICE_SYNC_PROPAGATION: Final = "sync_propagation"
|
||||||
|
SERVICE_SET_PROPAGATION_NODE: Final = "set_propagation_node"
|
||||||
|
|
||||||
|
ATTR_DESTINATION: Final = "destination"
|
||||||
|
ATTR_CONTENT: Final = "content"
|
||||||
|
ATTR_TITLE: Final = "title"
|
||||||
|
ATTR_METHOD: Final = "method"
|
||||||
|
ATTR_FIELDS: Final = "fields"
|
||||||
|
ATTR_MAX_MESSAGES: Final = "max_messages"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# LXMF address geometry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reticulum truncated destination hashes are 16 bytes -> 32 hex chars.
|
||||||
|
DEST_HASH_LEN: Final = 32
|
||||||
82
custom_components/reticulum/device_trigger.py
Normal file
82
custom_components/reticulum/device_trigger.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""Device triggers for Reticulum.
|
||||||
|
|
||||||
|
Exposes the integration's bus events as device triggers so they can be picked
|
||||||
|
from the automation UI ("Do something when a Reticulum device …").
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA
|
||||||
|
from homeassistant.components.homeassistant.triggers import event as event_trigger
|
||||||
|
from homeassistant.const import (
|
||||||
|
CONF_DEVICE_ID,
|
||||||
|
CONF_DOMAIN,
|
||||||
|
CONF_PLATFORM,
|
||||||
|
CONF_TYPE,
|
||||||
|
)
|
||||||
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
||||||
|
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||||
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
DOMAIN,
|
||||||
|
EVENT_ANNOUNCE_RECEIVED,
|
||||||
|
EVENT_MESSAGE_DELIVERED,
|
||||||
|
EVENT_MESSAGE_FAILED,
|
||||||
|
EVENT_MESSAGE_RECEIVED,
|
||||||
|
)
|
||||||
|
|
||||||
|
TRIGGER_MESSAGE_RECEIVED = "message_received"
|
||||||
|
TRIGGER_MESSAGE_DELIVERED = "message_delivered"
|
||||||
|
TRIGGER_MESSAGE_FAILED = "message_failed"
|
||||||
|
TRIGGER_ANNOUNCE_RECEIVED = "announce_received"
|
||||||
|
|
||||||
|
_EVENT_FOR_TYPE: dict[str, str] = {
|
||||||
|
TRIGGER_MESSAGE_RECEIVED: EVENT_MESSAGE_RECEIVED,
|
||||||
|
TRIGGER_MESSAGE_DELIVERED: EVENT_MESSAGE_DELIVERED,
|
||||||
|
TRIGGER_MESSAGE_FAILED: EVENT_MESSAGE_FAILED,
|
||||||
|
TRIGGER_ANNOUNCE_RECEIVED: EVENT_ANNOUNCE_RECEIVED,
|
||||||
|
}
|
||||||
|
|
||||||
|
TRIGGER_TYPES = set(_EVENT_FOR_TYPE)
|
||||||
|
|
||||||
|
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_get_triggers(
|
||||||
|
hass: HomeAssistant, device_id: str
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""List device triggers for a Reticulum device."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
CONF_PLATFORM: "device",
|
||||||
|
CONF_DOMAIN: DOMAIN,
|
||||||
|
CONF_DEVICE_ID: device_id,
|
||||||
|
CONF_TYPE: trigger_type,
|
||||||
|
}
|
||||||
|
for trigger_type in TRIGGER_TYPES
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_attach_trigger(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config: ConfigType,
|
||||||
|
action: TriggerActionType,
|
||||||
|
trigger_info: TriggerInfo,
|
||||||
|
) -> CALLBACK_TYPE:
|
||||||
|
"""Attach a device trigger by wrapping the corresponding bus event."""
|
||||||
|
event_config = event_trigger.TRIGGER_SCHEMA(
|
||||||
|
{
|
||||||
|
event_trigger.CONF_PLATFORM: "event",
|
||||||
|
event_trigger.CONF_EVENT_TYPE: _EVENT_FOR_TYPE[config[CONF_TYPE]],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return await event_trigger.async_attach_trigger(
|
||||||
|
hass, event_config, action, trigger_info, platform_type="device"
|
||||||
|
)
|
||||||
41
custom_components/reticulum/diagnostics.py
Normal file
41
custom_components/reticulum/diagnostics.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""Diagnostics support for Reticulum."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.diagnostics import async_redact_data
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .const import CONF_ALLOWED_IDENTITIES, CONF_DEFAULT_RECIPIENT, DOMAIN
|
||||||
|
|
||||||
|
TO_REDACT = {CONF_DEFAULT_RECIPIENT, CONF_ALLOWED_IDENTITIES}
|
||||||
|
|
||||||
|
|
||||||
|
async def async_get_config_entry_diagnostics(
|
||||||
|
hass: HomeAssistant, entry: ConfigEntry
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return diagnostics for a config entry."""
|
||||||
|
runtime = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
state = runtime.manager.state
|
||||||
|
return {
|
||||||
|
"entry": {
|
||||||
|
"data": dict(entry.data),
|
||||||
|
"options": async_redact_data(dict(entry.options), TO_REDACT),
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"started": state.started,
|
||||||
|
"interface_online": state.interface_online,
|
||||||
|
"lxmf_address": state.lxmf_address,
|
||||||
|
"display_name": state.display_name,
|
||||||
|
"messages_received": state.messages_received,
|
||||||
|
"messages_sent": state.messages_sent,
|
||||||
|
"messages_failed": state.messages_failed,
|
||||||
|
"peer_count": len(state.peers),
|
||||||
|
"peers": [
|
||||||
|
{"name": p.display_name, "hops": p.hops, "stamp_cost": p.stamp_cost}
|
||||||
|
for p in state.peers.values()
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
39
custom_components/reticulum/entity.py
Normal file
39
custom_components/reticulum/entity.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Base entity for the Reticulum integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo, Entity
|
||||||
|
|
||||||
|
from .const import DOMAIN, SIGNAL_STATE_UPDATED
|
||||||
|
from .reticulum_client import ReticulumManager
|
||||||
|
|
||||||
|
|
||||||
|
class ReticulumEntity(Entity):
|
||||||
|
"""Base class wiring entities to the manager state updates."""
|
||||||
|
|
||||||
|
_attr_should_poll = False
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(self, manager: ReticulumManager, entry_id: str) -> None:
|
||||||
|
self._manager = manager
|
||||||
|
self._entry_id = entry_id
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, entry_id)},
|
||||||
|
name="Reticulum",
|
||||||
|
manufacturer="Reticulum Network Stack",
|
||||||
|
model="LXMF Peer",
|
||||||
|
sw_version=manager.state.lxmf_address or None,
|
||||||
|
configuration_url="https://reticulum.network/",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
"""Subscribe to state-update signals."""
|
||||||
|
self.async_on_remove(
|
||||||
|
async_dispatcher_connect(
|
||||||
|
self.hass, SIGNAL_STATE_UPDATED, self._handle_update
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_update(self) -> None:
|
||||||
|
self.async_write_ha_state()
|
||||||
16
custom_components/reticulum/manifest.json
Normal file
16
custom_components/reticulum/manifest.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"domain": "reticulum",
|
||||||
|
"name": "Reticulum",
|
||||||
|
"codeowners": [],
|
||||||
|
"config_flow": true,
|
||||||
|
"dependencies": [],
|
||||||
|
"after_dependencies": ["conversation"],
|
||||||
|
"documentation": "https://github.com/tewris/ha-reticulum",
|
||||||
|
"integration_type": "hub",
|
||||||
|
"iot_class": "local_push",
|
||||||
|
"issue_tracker": "https://github.com/tewris/ha-reticulum/issues",
|
||||||
|
"loggers": ["RNS", "LXMF"],
|
||||||
|
"requirements": ["rns>=0.9.0", "lxmf>=0.6.0"],
|
||||||
|
"single_config_entry": true,
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
57
custom_components/reticulum/notify.py
Normal file
57
custom_components/reticulum/notify.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Notify platform for Reticulum.
|
||||||
|
|
||||||
|
Exposes a ``notify`` entity that sends an LXMF message to the configured
|
||||||
|
default recipient. For arbitrary destinations use the ``reticulum.send_message``
|
||||||
|
service instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.components.notify import NotifyEntity, NotifyEntityFeature
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .const import CONF_DEFAULT_RECIPIENT, CONF_DELIVERY_METHOD, DEFAULT_DELIVERY_METHOD, DOMAIN
|
||||||
|
from .entity import ReticulumEntity
|
||||||
|
from .reticulum_client import ReticulumError, ReticulumManager
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up the Reticulum notify entity."""
|
||||||
|
manager: ReticulumManager = hass.data[DOMAIN][entry.entry_id].manager
|
||||||
|
async_add_entities([ReticulumNotify(manager, entry)])
|
||||||
|
|
||||||
|
|
||||||
|
class ReticulumNotify(ReticulumEntity, NotifyEntity):
|
||||||
|
"""Send LXMF messages to the default recipient."""
|
||||||
|
|
||||||
|
_attr_translation_key = "message"
|
||||||
|
_attr_icon = "mdi:message-fast"
|
||||||
|
_attr_supported_features = NotifyEntityFeature.TITLE
|
||||||
|
|
||||||
|
def __init__(self, manager: ReticulumManager, entry: ConfigEntry) -> None:
|
||||||
|
super().__init__(manager, entry.entry_id)
|
||||||
|
self._entry = entry
|
||||||
|
self._attr_unique_id = f"{entry.entry_id}_notify"
|
||||||
|
|
||||||
|
async def async_send_message(self, message: str, title: str | None = None) -> None:
|
||||||
|
recipient = self._entry.options.get(CONF_DEFAULT_RECIPIENT)
|
||||||
|
if not recipient:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
"No default recipient configured. Set one in the Reticulum "
|
||||||
|
"integration options, or use the reticulum.send_message service "
|
||||||
|
"with an explicit destination."
|
||||||
|
)
|
||||||
|
method = self._entry.options.get(CONF_DELIVERY_METHOD, DEFAULT_DELIVERY_METHOD)
|
||||||
|
try:
|
||||||
|
await self._manager.async_send_message(
|
||||||
|
recipient, message, title=title or "", method=method
|
||||||
|
)
|
||||||
|
except ReticulumError as err:
|
||||||
|
raise ServiceValidationError(str(err)) from err
|
||||||
647
custom_components/reticulum/reticulum_client.py
Normal file
647
custom_components/reticulum/reticulum_client.py
Normal file
@@ -0,0 +1,647 @@
|
|||||||
|
"""Reticulum / LXMF client manager for Home Assistant.
|
||||||
|
|
||||||
|
This module isolates all interaction with the (blocking, thread-based)
|
||||||
|
Reticulum Network Stack (``RNS``) and the LXMF messaging layer, and bridges
|
||||||
|
their callbacks (which fire on RNS-owned threads) back onto the Home Assistant
|
||||||
|
event loop.
|
||||||
|
|
||||||
|
The neighbouring machine runs a full Reticulum instance exposing a
|
||||||
|
``TCPServerInterface``. We connect out to it with a ``TCPClientInterface`` and
|
||||||
|
run a standalone (non-shared) RNS instance inside the Home Assistant process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
ATTACHMENTS_SUBDIR,
|
||||||
|
CONFIG_FILENAME,
|
||||||
|
DEST_HASH_LEN,
|
||||||
|
IDENTITY_FILENAME,
|
||||||
|
SIGNAL_STATE_UPDATED,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import LXMF
|
||||||
|
import RNS
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# A process may only ever hold a single RNS.Reticulum instance. Home Assistant
|
||||||
|
# is a single process, so we guard against re-initialisation across config-entry
|
||||||
|
# reloads by caching the running instance module-side.
|
||||||
|
_RNS_INSTANCE: Any = None
|
||||||
|
|
||||||
|
# How long (seconds) to wait for a path/identity to resolve before giving up on
|
||||||
|
# an outbound message.
|
||||||
|
PATH_RESOLVE_TIMEOUT = 15.0
|
||||||
|
|
||||||
|
# Ring buffer size for recently seen peers / messages surfaced as attributes.
|
||||||
|
MAX_TRACKED_PEERS = 200
|
||||||
|
MAX_RECENT_MESSAGES = 25
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Peer:
|
||||||
|
"""A Reticulum LXMF peer we have heard announce."""
|
||||||
|
|
||||||
|
destination_hash: str
|
||||||
|
display_name: str | None
|
||||||
|
stamp_cost: int | None
|
||||||
|
last_heard: float
|
||||||
|
hops: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ManagerState:
|
||||||
|
"""Mutable state surfaced to entities and diagnostics."""
|
||||||
|
|
||||||
|
started: bool = False
|
||||||
|
interface_online: bool = False
|
||||||
|
lxmf_address: str | None = None
|
||||||
|
display_name: str | None = None
|
||||||
|
messages_received: int = 0
|
||||||
|
messages_sent: int = 0
|
||||||
|
messages_failed: int = 0
|
||||||
|
# Interface telemetry (populated by refresh_interface_status).
|
||||||
|
tel_rxb: int | None = None
|
||||||
|
tel_txb: int | None = None
|
||||||
|
tel_bitrate: float | None = None
|
||||||
|
tel_rssi: float | None = None
|
||||||
|
tel_snr: float | None = None
|
||||||
|
tel_quality: float | None = None
|
||||||
|
last_message: str | None = None
|
||||||
|
last_message_source: str | None = None
|
||||||
|
last_message_title: str | None = None
|
||||||
|
last_message_time: float | None = None
|
||||||
|
peers: dict[str, Peer] = field(default_factory=dict)
|
||||||
|
recent_messages: deque = field(default_factory=lambda: deque(maxlen=MAX_RECENT_MESSAGES))
|
||||||
|
|
||||||
|
|
||||||
|
class ReticulumError(Exception):
|
||||||
|
"""Raised for recoverable Reticulum operations (surfaced to the UI)."""
|
||||||
|
|
||||||
|
|
||||||
|
class ReticulumManager:
|
||||||
|
"""Owns the RNS instance, the LXMF router and all bridging logic."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
storage_dir: str,
|
||||||
|
*,
|
||||||
|
target_host: str,
|
||||||
|
target_port: int,
|
||||||
|
interface_name: str,
|
||||||
|
display_name: str,
|
||||||
|
loglevel: int,
|
||||||
|
) -> None:
|
||||||
|
self.hass = hass
|
||||||
|
self.storage_dir = storage_dir
|
||||||
|
self.target_host = target_host
|
||||||
|
self.target_port = target_port
|
||||||
|
self.interface_name = interface_name
|
||||||
|
self.display_name = display_name
|
||||||
|
self.loglevel = loglevel
|
||||||
|
|
||||||
|
self.state = ManagerState(display_name=display_name)
|
||||||
|
|
||||||
|
# Populated in _start (executor).
|
||||||
|
self._rns: Any = None
|
||||||
|
self._router: Any = None
|
||||||
|
self._identity: Any = None
|
||||||
|
self._local_destination: Any = None
|
||||||
|
self._announce_handler: Any = None
|
||||||
|
|
||||||
|
# Incoming-message handler installed by __init__.py once options are
|
||||||
|
# known (assist bridge, allow-list, …).
|
||||||
|
self.incoming_handler: Callable[[dict[str, Any]], Any] | None = None
|
||||||
|
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
async def async_start(self) -> None:
|
||||||
|
"""Initialise RNS + LXMF (runs the blocking parts in an executor)."""
|
||||||
|
async with self._lock:
|
||||||
|
await self.hass.async_add_executor_job(self._start)
|
||||||
|
self._push_state()
|
||||||
|
|
||||||
|
def _start(self) -> None:
|
||||||
|
"""Blocking init. Runs in the executor thread."""
|
||||||
|
global _RNS_INSTANCE # noqa: PLW0603
|
||||||
|
import LXMF # noqa: PLC0415
|
||||||
|
import RNS # noqa: PLC0415
|
||||||
|
|
||||||
|
os.makedirs(self.storage_dir, exist_ok=True)
|
||||||
|
os.makedirs(os.path.join(self.storage_dir, ATTACHMENTS_SUBDIR), exist_ok=True)
|
||||||
|
self._write_config_file()
|
||||||
|
|
||||||
|
if _RNS_INSTANCE is None:
|
||||||
|
_LOGGER.debug("Initialising Reticulum instance at %s", self.storage_dir)
|
||||||
|
_RNS_INSTANCE = RNS.Reticulum(
|
||||||
|
configdir=self.storage_dir, loglevel=self.loglevel
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_LOGGER.debug("Reusing existing Reticulum instance")
|
||||||
|
self._rns = _RNS_INSTANCE
|
||||||
|
|
||||||
|
# Stable identity so our LXMF address survives restarts.
|
||||||
|
identity_path = os.path.join(self.storage_dir, IDENTITY_FILENAME)
|
||||||
|
if os.path.isfile(identity_path):
|
||||||
|
self._identity = RNS.Identity.from_file(identity_path)
|
||||||
|
if self._identity is None:
|
||||||
|
self._identity = RNS.Identity()
|
||||||
|
self._identity.to_file(identity_path)
|
||||||
|
|
||||||
|
self._router = LXMF.LXMRouter(
|
||||||
|
storagepath=os.path.join(self.storage_dir, "lxmf"),
|
||||||
|
)
|
||||||
|
self._local_destination = self._router.register_delivery_identity(
|
||||||
|
self._identity, display_name=self.display_name
|
||||||
|
)
|
||||||
|
self._router.register_delivery_callback(self._delivery_callback)
|
||||||
|
|
||||||
|
# Discover peers via their LXMF delivery announces.
|
||||||
|
self._announce_handler = _AnnounceHandler(self)
|
||||||
|
RNS.Transport.register_announce_handler(self._announce_handler)
|
||||||
|
|
||||||
|
self.state.lxmf_address = RNS.hexrep(
|
||||||
|
self._local_destination.hash, delimit=False
|
||||||
|
)
|
||||||
|
self.state.started = True
|
||||||
|
_LOGGER.info(
|
||||||
|
"Reticulum ready. LXMF address: %s (%s)",
|
||||||
|
self.state.lxmf_address,
|
||||||
|
self.display_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _write_config_file(self) -> None:
|
||||||
|
"""Write a minimal RNS config with only our TCP client interface."""
|
||||||
|
config_path = os.path.join(self.storage_dir, CONFIG_FILENAME)
|
||||||
|
contents = (
|
||||||
|
"[reticulum]\n"
|
||||||
|
" enable_transport = False\n"
|
||||||
|
" share_instance = No\n"
|
||||||
|
" panic_on_interface_error = No\n\n"
|
||||||
|
"[logging]\n"
|
||||||
|
f" loglevel = {self.loglevel}\n\n"
|
||||||
|
"[interfaces]\n"
|
||||||
|
f" [[{self.interface_name}]]\n"
|
||||||
|
" type = TCPClientInterface\n"
|
||||||
|
" enabled = yes\n"
|
||||||
|
f" target_host = {self.target_host}\n"
|
||||||
|
f" target_port = {self.target_port}\n"
|
||||||
|
)
|
||||||
|
with open(config_path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(contents)
|
||||||
|
|
||||||
|
async def async_stop(self) -> None:
|
||||||
|
"""Detach our callbacks. The RNS instance itself lives for the process.
|
||||||
|
|
||||||
|
RNS/LXMF do not support a clean per-entry teardown inside a running
|
||||||
|
process, so a full stack restart requires restarting Home Assistant.
|
||||||
|
We at least stop delivering into a torn-down entry.
|
||||||
|
"""
|
||||||
|
self.incoming_handler = None
|
||||||
|
if self._router is not None:
|
||||||
|
try:
|
||||||
|
self._router.register_delivery_callback(lambda _msg: None)
|
||||||
|
except Exception: # noqa: BLE001 - best effort
|
||||||
|
_LOGGER.debug("Could not reset delivery callback", exc_info=True)
|
||||||
|
if self._announce_handler is not None:
|
||||||
|
try:
|
||||||
|
import RNS # noqa: PLC0415
|
||||||
|
|
||||||
|
RNS.Transport.deregister_announce_handler(self._announce_handler)
|
||||||
|
except Exception: # noqa: BLE001 - best effort / older RNS
|
||||||
|
_LOGGER.debug("Could not deregister announce handler", exc_info=True)
|
||||||
|
self.state.started = False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Outbound
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
async def async_send_message(
|
||||||
|
self,
|
||||||
|
destination: str,
|
||||||
|
content: str,
|
||||||
|
title: str = "",
|
||||||
|
method: str = "direct",
|
||||||
|
fields: dict | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Send an LXMF message. Returns the message hash (hex)."""
|
||||||
|
dest_hash = self._normalise_hash(destination)
|
||||||
|
return await self.hass.async_add_executor_job(
|
||||||
|
self._send, dest_hash, content, title, method, fields
|
||||||
|
)
|
||||||
|
|
||||||
|
def _send(
|
||||||
|
self,
|
||||||
|
dest_hash: bytes,
|
||||||
|
content: str,
|
||||||
|
title: str,
|
||||||
|
method: str,
|
||||||
|
fields: dict | None,
|
||||||
|
) -> str:
|
||||||
|
import LXMF # noqa: PLC0415
|
||||||
|
import RNS # noqa: PLC0415
|
||||||
|
|
||||||
|
recipient_identity = self._resolve_identity(dest_hash)
|
||||||
|
if recipient_identity is None:
|
||||||
|
raise ReticulumError(
|
||||||
|
f"Could not resolve a path/identity for "
|
||||||
|
f"{RNS.prettyhexrep(dest_hash)} within {PATH_RESOLVE_TIMEOUT:.0f}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
dest = RNS.Destination(
|
||||||
|
recipient_identity,
|
||||||
|
RNS.Destination.OUT,
|
||||||
|
RNS.Destination.SINGLE,
|
||||||
|
"lxmf",
|
||||||
|
"delivery",
|
||||||
|
)
|
||||||
|
desired = {
|
||||||
|
"direct": LXMF.LXMessage.DIRECT,
|
||||||
|
"opportunistic": LXMF.LXMessage.OPPORTUNISTIC,
|
||||||
|
"propagated": LXMF.LXMessage.PROPAGATED,
|
||||||
|
}.get(method, LXMF.LXMessage.DIRECT)
|
||||||
|
|
||||||
|
lxm = LXMF.LXMessage(
|
||||||
|
dest,
|
||||||
|
self._local_destination,
|
||||||
|
content,
|
||||||
|
title,
|
||||||
|
desired_method=desired,
|
||||||
|
)
|
||||||
|
if fields:
|
||||||
|
lxm.fields = fields
|
||||||
|
|
||||||
|
lxm.register_delivery_callback(self._outbound_delivered)
|
||||||
|
lxm.register_failed_callback(self._outbound_failed)
|
||||||
|
|
||||||
|
self._router.handle_outbound(lxm)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Queued LXMF message to %s via %s", RNS.prettyhexrep(dest_hash), method
|
||||||
|
)
|
||||||
|
return RNS.hexrep(lxm.hash, delimit=False)
|
||||||
|
|
||||||
|
def _resolve_identity(self, dest_hash: bytes) -> Any:
|
||||||
|
"""Recall (and if needed, request a path for) a destination identity."""
|
||||||
|
import RNS # noqa: PLC0415
|
||||||
|
|
||||||
|
identity = RNS.Identity.recall(dest_hash)
|
||||||
|
if identity is not None:
|
||||||
|
return identity
|
||||||
|
|
||||||
|
if not RNS.Transport.has_path(dest_hash):
|
||||||
|
RNS.Transport.request_path(dest_hash)
|
||||||
|
|
||||||
|
deadline = time.monotonic() + PATH_RESOLVE_TIMEOUT
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
identity = RNS.Identity.recall(dest_hash)
|
||||||
|
if identity is not None:
|
||||||
|
return identity
|
||||||
|
time.sleep(0.25)
|
||||||
|
return RNS.Identity.recall(dest_hash)
|
||||||
|
|
||||||
|
async def async_announce(self) -> None:
|
||||||
|
"""Announce our LXMF delivery destination on the network."""
|
||||||
|
if self._router is None or self._local_destination is None:
|
||||||
|
raise ReticulumError("Reticulum is not started")
|
||||||
|
await self.hass.async_add_executor_job(
|
||||||
|
self._router.announce, self._local_destination.hash
|
||||||
|
)
|
||||||
|
_LOGGER.debug("Announced LXMF destination %s", self.state.lxmf_address)
|
||||||
|
|
||||||
|
async def async_request_path(self, destination: str) -> None:
|
||||||
|
"""Request a network path to a destination."""
|
||||||
|
dest_hash = self._normalise_hash(destination)
|
||||||
|
|
||||||
|
def _request() -> None:
|
||||||
|
import RNS # noqa: PLC0415
|
||||||
|
|
||||||
|
RNS.Transport.request_path(dest_hash)
|
||||||
|
|
||||||
|
await self.hass.async_add_executor_job(_request)
|
||||||
|
|
||||||
|
async def async_set_propagation_node(self, destination: str) -> None:
|
||||||
|
"""Set the outbound LXMF propagation (store-and-forward) node."""
|
||||||
|
dest_hash = self._normalise_hash(destination)
|
||||||
|
await self.hass.async_add_executor_job(
|
||||||
|
self._router.set_outbound_propagation_node, dest_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_sync_propagation(self, max_messages: int | None = None) -> None:
|
||||||
|
"""Pull any queued messages from the configured propagation node."""
|
||||||
|
|
||||||
|
def _sync() -> None:
|
||||||
|
if max_messages is None:
|
||||||
|
self._router.request_messages_from_propagation_node(self._identity)
|
||||||
|
else:
|
||||||
|
self._router.request_messages_from_propagation_node(
|
||||||
|
self._identity, max_messages
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.hass.async_add_executor_job(_sync)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Inbound (RNS thread -> HA loop bridging)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _delivery_callback(self, message: Any) -> None:
|
||||||
|
"""LXMF delivery callback. Runs on an RNS-owned thread."""
|
||||||
|
import RNS # noqa: PLC0415
|
||||||
|
|
||||||
|
try:
|
||||||
|
source_hash = RNS.hexrep(message.source_hash, delimit=False)
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"content": message.content_as_string() or "",
|
||||||
|
"title": message.title_as_string() or "",
|
||||||
|
"source": source_hash,
|
||||||
|
"destination": RNS.hexrep(message.destination_hash, delimit=False),
|
||||||
|
"timestamp": getattr(message, "timestamp", None),
|
||||||
|
"signature_validated": getattr(message, "signature_validated", None),
|
||||||
|
"fields": dict(getattr(message, "fields", {}) or {}),
|
||||||
|
}
|
||||||
|
except Exception: # noqa: BLE001 - never let a bad message kill the thread
|
||||||
|
_LOGGER.exception("Failed to parse inbound LXMF message")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Hop back onto the event loop thread.
|
||||||
|
self.hass.loop.call_soon_threadsafe(self._dispatch_incoming, payload)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _dispatch_incoming(self, payload: dict[str, Any]) -> None:
|
||||||
|
"""Runs on the event loop: update state, hand off to handler."""
|
||||||
|
self.state.messages_received += 1
|
||||||
|
self.state.last_message = payload["content"]
|
||||||
|
self.state.last_message_source = payload["source"]
|
||||||
|
self.state.last_message_title = payload["title"]
|
||||||
|
self.state.last_message_time = payload.get("timestamp") or time.time()
|
||||||
|
self.state.recent_messages.appendleft(
|
||||||
|
{
|
||||||
|
"source": payload["source"],
|
||||||
|
"title": payload["title"],
|
||||||
|
"content": payload["content"],
|
||||||
|
"time": self.state.last_message_time,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._push_state()
|
||||||
|
|
||||||
|
if self.incoming_handler is not None:
|
||||||
|
self.hass.async_create_task(
|
||||||
|
_maybe_await(self.incoming_handler, payload),
|
||||||
|
name="reticulum_incoming",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Outbound delivery status (RNS thread)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _outbound_delivered(self, message: Any) -> None:
|
||||||
|
self.hass.loop.call_soon_threadsafe(self._on_outbound_delivered, message)
|
||||||
|
|
||||||
|
def _outbound_failed(self, message: Any) -> None:
|
||||||
|
self.hass.loop.call_soon_threadsafe(self._on_outbound_failed, message)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _on_outbound_delivered(self, message: Any) -> None:
|
||||||
|
import RNS # noqa: PLC0415
|
||||||
|
|
||||||
|
self.state.messages_sent += 1
|
||||||
|
self._push_state()
|
||||||
|
_fire_event(
|
||||||
|
self.hass,
|
||||||
|
"delivered",
|
||||||
|
{
|
||||||
|
"message_hash": RNS.hexrep(message.hash, delimit=False),
|
||||||
|
"destination": RNS.hexrep(message.destination_hash, delimit=False),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _on_outbound_failed(self, message: Any) -> None:
|
||||||
|
import RNS # noqa: PLC0415
|
||||||
|
|
||||||
|
self.state.messages_failed += 1
|
||||||
|
self._push_state()
|
||||||
|
_fire_event(
|
||||||
|
self.hass,
|
||||||
|
"failed",
|
||||||
|
{
|
||||||
|
"message_hash": RNS.hexrep(message.hash, delimit=False),
|
||||||
|
"destination": RNS.hexrep(message.destination_hash, delimit=False),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Peer tracking (called from announce handler on RNS thread)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _register_peer(
|
||||||
|
self,
|
||||||
|
destination_hash: str,
|
||||||
|
display_name: str | None,
|
||||||
|
stamp_cost: int | None,
|
||||||
|
hops: int | None,
|
||||||
|
) -> None:
|
||||||
|
self.hass.loop.call_soon_threadsafe(
|
||||||
|
self._store_peer, destination_hash, display_name, stamp_cost, hops
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _store_peer(
|
||||||
|
self,
|
||||||
|
destination_hash: str,
|
||||||
|
display_name: str | None,
|
||||||
|
stamp_cost: int | None,
|
||||||
|
hops: int | None,
|
||||||
|
) -> None:
|
||||||
|
peer = Peer(
|
||||||
|
destination_hash=destination_hash,
|
||||||
|
display_name=display_name,
|
||||||
|
stamp_cost=stamp_cost,
|
||||||
|
last_heard=time.time(),
|
||||||
|
hops=hops,
|
||||||
|
)
|
||||||
|
self.state.peers[destination_hash] = peer
|
||||||
|
# Keep the peer table bounded.
|
||||||
|
if len(self.state.peers) > MAX_TRACKED_PEERS:
|
||||||
|
oldest = min(self.state.peers.values(), key=lambda p: p.last_heard)
|
||||||
|
self.state.peers.pop(oldest.destination_hash, None)
|
||||||
|
self._push_state()
|
||||||
|
_fire_event(
|
||||||
|
self.hass,
|
||||||
|
"announce",
|
||||||
|
{
|
||||||
|
"destination": destination_hash,
|
||||||
|
"display_name": display_name,
|
||||||
|
"stamp_cost": stamp_cost,
|
||||||
|
"hops": hops,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def refresh_interface_status(self) -> None:
|
||||||
|
"""Recompute interface online status and collect telemetry.
|
||||||
|
|
||||||
|
Called on the event loop from a periodic timer. RX/TX byte counters and
|
||||||
|
bitrate are available for the TCP client interface; RSSI/SNR/quality are
|
||||||
|
only populated when the underlying interface is a physical one (e.g. an
|
||||||
|
RNode/LoRa interface) and stay ``None`` for a plain TCP link.
|
||||||
|
"""
|
||||||
|
if self._rns is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
online = False
|
||||||
|
rxb = txb = bitrate = rssi = snr = quality = None
|
||||||
|
try:
|
||||||
|
import RNS # noqa: PLC0415
|
||||||
|
|
||||||
|
target = None
|
||||||
|
interfaces = list(RNS.Transport.interfaces)
|
||||||
|
for iface in interfaces:
|
||||||
|
name = getattr(iface, "name", "") or str(iface)
|
||||||
|
if self.interface_name in name:
|
||||||
|
target = iface
|
||||||
|
break
|
||||||
|
if target is None and interfaces:
|
||||||
|
target = interfaces[0]
|
||||||
|
|
||||||
|
if target is not None:
|
||||||
|
online = bool(getattr(target, "online", False))
|
||||||
|
rxb = getattr(target, "rxb", None)
|
||||||
|
txb = getattr(target, "txb", None)
|
||||||
|
bitrate = getattr(target, "bitrate", None)
|
||||||
|
rssi = getattr(target, "rssi", None)
|
||||||
|
snr = getattr(target, "snr", None)
|
||||||
|
quality = getattr(target, "q", None)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
online = False
|
||||||
|
|
||||||
|
snapshot = (online, rxb, txb, bitrate, rssi, snr, quality)
|
||||||
|
current = (
|
||||||
|
self.state.interface_online,
|
||||||
|
self.state.tel_rxb,
|
||||||
|
self.state.tel_txb,
|
||||||
|
self.state.tel_bitrate,
|
||||||
|
self.state.tel_rssi,
|
||||||
|
self.state.tel_snr,
|
||||||
|
self.state.tel_quality,
|
||||||
|
)
|
||||||
|
if snapshot == current:
|
||||||
|
return
|
||||||
|
|
||||||
|
(
|
||||||
|
self.state.interface_online,
|
||||||
|
self.state.tel_rxb,
|
||||||
|
self.state.tel_txb,
|
||||||
|
self.state.tel_bitrate,
|
||||||
|
self.state.tel_rssi,
|
||||||
|
self.state.tel_snr,
|
||||||
|
self.state.tel_quality,
|
||||||
|
) = snapshot
|
||||||
|
self._push_state()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _push_state(self) -> None:
|
||||||
|
async_dispatcher_send(self.hass, SIGNAL_STATE_UPDATED)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalise_hash(value: str) -> bytes:
|
||||||
|
cleaned = value.strip().lower().replace(":", "").replace(" ", "")
|
||||||
|
if cleaned.startswith("0x"):
|
||||||
|
cleaned = cleaned[2:]
|
||||||
|
if len(cleaned) != DEST_HASH_LEN:
|
||||||
|
raise ReticulumError(
|
||||||
|
f"Invalid Reticulum address '{value}': expected {DEST_HASH_LEN} "
|
||||||
|
f"hex characters (a 16-byte destination hash)"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return bytes.fromhex(cleaned)
|
||||||
|
except ValueError as err:
|
||||||
|
raise ReticulumError(f"Invalid Reticulum address '{value}'") from err
|
||||||
|
|
||||||
|
|
||||||
|
class _AnnounceHandler:
|
||||||
|
"""RNS announce handler filtered to the LXMF delivery aspect."""
|
||||||
|
|
||||||
|
aspect_filter = "lxmf.delivery"
|
||||||
|
|
||||||
|
def __init__(self, manager: ReticulumManager) -> None:
|
||||||
|
self._manager = manager
|
||||||
|
# LXMF requests receipt of the full announce data path.
|
||||||
|
self.receive_path_responses = False
|
||||||
|
|
||||||
|
def received_announce(
|
||||||
|
self,
|
||||||
|
destination_hash: bytes,
|
||||||
|
announced_identity: Any,
|
||||||
|
app_data: Any,
|
||||||
|
) -> None:
|
||||||
|
# NOTE: RNS dispatches this callback by *exact* parameter count and
|
||||||
|
# passes arguments by keyword. The 3-parameter form (destination_hash,
|
||||||
|
# announced_identity, app_data) is supported by every RNS version; the
|
||||||
|
# optional announce_packet_hash / is_path_response are unused here.
|
||||||
|
import RNS # noqa: PLC0415
|
||||||
|
|
||||||
|
display_name: str | None = None
|
||||||
|
stamp_cost: int | None = None
|
||||||
|
try:
|
||||||
|
import LXMF # noqa: PLC0415
|
||||||
|
|
||||||
|
if app_data is not None:
|
||||||
|
display_name = LXMF.display_name_from_app_data(app_data)
|
||||||
|
stamp_cost = LXMF.stamp_cost_from_app_data(app_data)
|
||||||
|
except Exception: # noqa: BLE001 - app_data helpers vary by version
|
||||||
|
if isinstance(app_data, (bytes, bytearray)):
|
||||||
|
try:
|
||||||
|
display_name = app_data.decode("utf-8", errors="replace")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
display_name = None
|
||||||
|
|
||||||
|
hops = None
|
||||||
|
try:
|
||||||
|
hops = RNS.Transport.hops_to(destination_hash)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
hops = None
|
||||||
|
|
||||||
|
self._manager._register_peer(
|
||||||
|
RNS.hexrep(destination_hash, delimit=False),
|
||||||
|
display_name,
|
||||||
|
stamp_cost,
|
||||||
|
hops,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fire_event(hass: HomeAssistant, kind: str, data: dict[str, Any]) -> None:
|
||||||
|
"""Fire one of the reticulum_* bus events (imported lazily to avoid cycle)."""
|
||||||
|
from .const import ( # noqa: PLC0415
|
||||||
|
EVENT_ANNOUNCE_RECEIVED,
|
||||||
|
EVENT_MESSAGE_DELIVERED,
|
||||||
|
EVENT_MESSAGE_FAILED,
|
||||||
|
)
|
||||||
|
|
||||||
|
mapping = {
|
||||||
|
"delivered": EVENT_MESSAGE_DELIVERED,
|
||||||
|
"failed": EVENT_MESSAGE_FAILED,
|
||||||
|
"announce": EVENT_ANNOUNCE_RECEIVED,
|
||||||
|
}
|
||||||
|
hass.bus.async_fire(mapping[kind], data)
|
||||||
|
|
||||||
|
|
||||||
|
async def _maybe_await(func: Callable[[dict[str, Any]], Any], payload: dict[str, Any]) -> None:
|
||||||
|
"""Call ``func`` supporting both coroutine and plain callables."""
|
||||||
|
result = func(payload)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
219
custom_components/reticulum/sensor.py
Normal file
219
custom_components/reticulum/sensor.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
"""Sensor platform for Reticulum."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import (
|
||||||
|
SensorDeviceClass,
|
||||||
|
SensorEntity,
|
||||||
|
SensorEntityDescription,
|
||||||
|
SensorStateClass,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import (
|
||||||
|
PERCENTAGE,
|
||||||
|
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||||
|
EntityCategory,
|
||||||
|
UnitOfDataRate,
|
||||||
|
UnitOfInformation,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .entity import ReticulumEntity
|
||||||
|
from .reticulum_client import ManagerState, ReticulumManager
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class ReticulumSensorDescription(SensorEntityDescription):
|
||||||
|
"""Describes a Reticulum sensor."""
|
||||||
|
|
||||||
|
value_fn: Callable[[ManagerState], Any]
|
||||||
|
attr_fn: Callable[[ManagerState], dict[str, Any]] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _last_message_time(state: ManagerState) -> datetime | None:
|
||||||
|
if state.last_message_time is None:
|
||||||
|
return None
|
||||||
|
return datetime.fromtimestamp(state.last_message_time, tz=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
SENSORS: tuple[ReticulumSensorDescription, ...] = (
|
||||||
|
ReticulumSensorDescription(
|
||||||
|
key="lxmf_address",
|
||||||
|
translation_key="lxmf_address",
|
||||||
|
icon="mdi:identifier",
|
||||||
|
entity_category=None,
|
||||||
|
value_fn=lambda s: s.lxmf_address,
|
||||||
|
),
|
||||||
|
ReticulumSensorDescription(
|
||||||
|
key="messages_received",
|
||||||
|
translation_key="messages_received",
|
||||||
|
icon="mdi:inbox-arrow-down",
|
||||||
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
|
value_fn=lambda s: s.messages_received,
|
||||||
|
),
|
||||||
|
ReticulumSensorDescription(
|
||||||
|
key="messages_sent",
|
||||||
|
translation_key="messages_sent",
|
||||||
|
icon="mdi:inbox-arrow-up",
|
||||||
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
|
value_fn=lambda s: s.messages_sent,
|
||||||
|
),
|
||||||
|
ReticulumSensorDescription(
|
||||||
|
key="messages_failed",
|
||||||
|
translation_key="messages_failed",
|
||||||
|
icon="mdi:message-alert",
|
||||||
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
|
value_fn=lambda s: s.messages_failed,
|
||||||
|
),
|
||||||
|
ReticulumSensorDescription(
|
||||||
|
key="peers",
|
||||||
|
translation_key="peers",
|
||||||
|
icon="mdi:account-group",
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
value_fn=lambda s: len(s.peers),
|
||||||
|
attr_fn=lambda s: {
|
||||||
|
"peers": [
|
||||||
|
{
|
||||||
|
"address": p.destination_hash,
|
||||||
|
"name": p.display_name,
|
||||||
|
"hops": p.hops,
|
||||||
|
"stamp_cost": p.stamp_cost,
|
||||||
|
}
|
||||||
|
for p in sorted(
|
||||||
|
s.peers.values(), key=lambda x: x.last_heard, reverse=True
|
||||||
|
)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ReticulumSensorDescription(
|
||||||
|
key="last_message",
|
||||||
|
translation_key="last_message",
|
||||||
|
icon="mdi:message-text",
|
||||||
|
value_fn=lambda s: (s.last_message or "")[:255] or None,
|
||||||
|
attr_fn=lambda s: {
|
||||||
|
"source": s.last_message_source,
|
||||||
|
"title": s.last_message_title,
|
||||||
|
"full_content": s.last_message,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ReticulumSensorDescription(
|
||||||
|
key="last_message_time",
|
||||||
|
translation_key="last_message_time",
|
||||||
|
icon="mdi:clock-outline",
|
||||||
|
device_class=SensorDeviceClass.TIMESTAMP,
|
||||||
|
value_fn=_last_message_time,
|
||||||
|
),
|
||||||
|
# --- Interface telemetry -------------------------------------------------
|
||||||
|
ReticulumSensorDescription(
|
||||||
|
key="rx_bytes",
|
||||||
|
translation_key="rx_bytes",
|
||||||
|
icon="mdi:download-network",
|
||||||
|
device_class=SensorDeviceClass.DATA_SIZE,
|
||||||
|
native_unit_of_measurement=UnitOfInformation.BYTES,
|
||||||
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
|
suggested_unit_of_measurement=UnitOfInformation.KIBIBYTES,
|
||||||
|
suggested_display_precision=1,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
value_fn=lambda s: s.tel_rxb,
|
||||||
|
),
|
||||||
|
ReticulumSensorDescription(
|
||||||
|
key="tx_bytes",
|
||||||
|
translation_key="tx_bytes",
|
||||||
|
icon="mdi:upload-network",
|
||||||
|
device_class=SensorDeviceClass.DATA_SIZE,
|
||||||
|
native_unit_of_measurement=UnitOfInformation.BYTES,
|
||||||
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
|
suggested_unit_of_measurement=UnitOfInformation.KIBIBYTES,
|
||||||
|
suggested_display_precision=1,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
value_fn=lambda s: s.tel_txb,
|
||||||
|
),
|
||||||
|
ReticulumSensorDescription(
|
||||||
|
key="bitrate",
|
||||||
|
translation_key="bitrate",
|
||||||
|
icon="mdi:speedometer",
|
||||||
|
device_class=SensorDeviceClass.DATA_RATE,
|
||||||
|
native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
value_fn=lambda s: s.tel_bitrate,
|
||||||
|
),
|
||||||
|
# RSSI / SNR / quality are only meaningful on physical interfaces
|
||||||
|
# (RNode/LoRa). Disabled by default; enable them if a physical link is used.
|
||||||
|
ReticulumSensorDescription(
|
||||||
|
key="rssi",
|
||||||
|
translation_key="rssi",
|
||||||
|
icon="mdi:signal",
|
||||||
|
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||||
|
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value_fn=lambda s: s.tel_rssi,
|
||||||
|
),
|
||||||
|
ReticulumSensorDescription(
|
||||||
|
key="snr",
|
||||||
|
translation_key="snr",
|
||||||
|
icon="mdi:signal-variant",
|
||||||
|
native_unit_of_measurement="dB",
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value_fn=lambda s: s.tel_snr,
|
||||||
|
),
|
||||||
|
ReticulumSensorDescription(
|
||||||
|
key="link_quality",
|
||||||
|
translation_key="link_quality",
|
||||||
|
icon="mdi:gauge",
|
||||||
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value_fn=lambda s: s.tel_quality,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Reticulum sensors."""
|
||||||
|
manager: ReticulumManager = hass.data[DOMAIN][entry.entry_id].manager
|
||||||
|
async_add_entities(
|
||||||
|
ReticulumSensor(manager, entry.entry_id, desc) for desc in SENSORS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReticulumSensor(ReticulumEntity, SensorEntity):
|
||||||
|
"""A Reticulum sensor."""
|
||||||
|
|
||||||
|
entity_description: ReticulumSensorDescription
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
manager: ReticulumManager,
|
||||||
|
entry_id: str,
|
||||||
|
description: ReticulumSensorDescription,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(manager, entry_id)
|
||||||
|
self.entity_description = description
|
||||||
|
self._attr_unique_id = f"{entry_id}_{description.key}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> Any:
|
||||||
|
return self.entity_description.value_fn(self._manager.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._manager.state)
|
||||||
62
custom_components/reticulum/services.yaml
Normal file
62
custom_components/reticulum/services.yaml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
send_message:
|
||||||
|
fields:
|
||||||
|
destination:
|
||||||
|
required: true
|
||||||
|
example: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
content:
|
||||||
|
required: true
|
||||||
|
example: "Hello from Home Assistant"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
multiline: true
|
||||||
|
title:
|
||||||
|
required: false
|
||||||
|
example: "Notification"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
method:
|
||||||
|
required: false
|
||||||
|
default: direct
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
translation_key: delivery_method
|
||||||
|
options:
|
||||||
|
- direct
|
||||||
|
- opportunistic
|
||||||
|
- propagated
|
||||||
|
fields:
|
||||||
|
required: false
|
||||||
|
example: '{ }'
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
|
||||||
|
announce:
|
||||||
|
|
||||||
|
request_path:
|
||||||
|
fields:
|
||||||
|
destination:
|
||||||
|
required: true
|
||||||
|
example: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|
||||||
|
set_propagation_node:
|
||||||
|
fields:
|
||||||
|
destination:
|
||||||
|
required: true
|
||||||
|
example: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|
||||||
|
sync_propagation:
|
||||||
|
fields:
|
||||||
|
max_messages:
|
||||||
|
required: false
|
||||||
|
example: 10
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
min: 1
|
||||||
|
max: 1000
|
||||||
|
mode: box
|
||||||
164
custom_components/reticulum/strings.json
Normal file
164
custom_components/reticulum/strings.json
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Connect to Reticulum",
|
||||||
|
"description": "Home Assistant will run a Reticulum instance and connect out to your Reticulum stack over TCP (the neighbouring machine must expose a TCPServerInterface).",
|
||||||
|
"data": {
|
||||||
|
"target_host": "Target host",
|
||||||
|
"target_port": "Target port",
|
||||||
|
"display_name": "Display name",
|
||||||
|
"interface_name": "Interface name"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"target_host": "Hostname or IP of the machine running the Reticulum TCP server.",
|
||||||
|
"target_port": "TCP port of the Reticulum TCPServerInterface (default 4242).",
|
||||||
|
"display_name": "Name announced to other Reticulum peers for this Home Assistant.",
|
||||||
|
"interface_name": "Label used for this connection in the Reticulum config."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Could not reach the Reticulum TCP server at that host and port."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Reticulum is already configured.",
|
||||||
|
"single_instance_allowed": "Only a single Reticulum instance is allowed."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"title": "Reticulum options",
|
||||||
|
"data": {
|
||||||
|
"enable_assist": "Route incoming messages to Assist",
|
||||||
|
"assist_agent": "Assist agent (entity id / agent id, blank = default)",
|
||||||
|
"assist_language": "Assist language (blank = system default)",
|
||||||
|
"allow_all_senders": "Reply to any sender",
|
||||||
|
"allowed_identities": "Allowed sender addresses",
|
||||||
|
"default_recipient": "Default recipient address (for the notify entity)",
|
||||||
|
"greeting": "Greeting message",
|
||||||
|
"delivery_method": "Default delivery method",
|
||||||
|
"announce_interval": "Announce interval",
|
||||||
|
"propagation_node": "Propagation node address",
|
||||||
|
"sync_interval": "Propagation sync interval",
|
||||||
|
"loglevel": "Reticulum log level (0-7)"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"enable_assist": "When enabled, incoming LXMF messages are answered by the Assist conversation agent and the reply is sent back over Reticulum.",
|
||||||
|
"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.",
|
||||||
|
"announce_interval": "How often (seconds) to announce our address. 0 disables periodic announces.",
|
||||||
|
"propagation_node": "Optional LXMF propagation node for store-and-forward delivery.",
|
||||||
|
"sync_interval": "How often (seconds) to pull queued messages from the propagation node. 0 disables."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"sensor": {
|
||||||
|
"lxmf_address": { "name": "LXMF address" },
|
||||||
|
"messages_received": { "name": "Messages received" },
|
||||||
|
"messages_sent": { "name": "Messages sent" },
|
||||||
|
"messages_failed": { "name": "Messages failed" },
|
||||||
|
"peers": { "name": "Known peers" },
|
||||||
|
"last_message": { "name": "Last message" },
|
||||||
|
"last_message_time": { "name": "Last message time" },
|
||||||
|
"rx_bytes": { "name": "Received" },
|
||||||
|
"tx_bytes": { "name": "Transmitted" },
|
||||||
|
"bitrate": { "name": "Bitrate" },
|
||||||
|
"rssi": { "name": "RSSI" },
|
||||||
|
"snr": { "name": "SNR" },
|
||||||
|
"link_quality": { "name": "Link quality" }
|
||||||
|
},
|
||||||
|
"binary_sensor": {
|
||||||
|
"connected": { "name": "Connected" }
|
||||||
|
},
|
||||||
|
"button": {
|
||||||
|
"announce": { "name": "Announce" },
|
||||||
|
"sync": { "name": "Sync propagation node" }
|
||||||
|
},
|
||||||
|
"notify": {
|
||||||
|
"message": { "name": "Message" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"delivery_method": {
|
||||||
|
"options": {
|
||||||
|
"direct": "Direct (establish a link)",
|
||||||
|
"opportunistic": "Opportunistic (single packet)",
|
||||||
|
"propagated": "Propagated (store and forward)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"device_automation": {
|
||||||
|
"trigger_type": {
|
||||||
|
"message_received": "Message received",
|
||||||
|
"message_delivered": "Message delivered",
|
||||||
|
"message_failed": "Message delivery failed",
|
||||||
|
"announce_received": "Announce received (peer heard)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"send_message": {
|
||||||
|
"name": "Send message",
|
||||||
|
"description": "Send an LXMF message to a Reticulum destination.",
|
||||||
|
"fields": {
|
||||||
|
"destination": {
|
||||||
|
"name": "Destination",
|
||||||
|
"description": "16-byte hex LXMF destination hash of the recipient."
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "Content",
|
||||||
|
"description": "The message body."
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "Title",
|
||||||
|
"description": "Optional message title / subject."
|
||||||
|
},
|
||||||
|
"method": {
|
||||||
|
"name": "Method",
|
||||||
|
"description": "Delivery method to use."
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"name": "Fields",
|
||||||
|
"description": "Optional advanced LXMF fields dictionary."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"announce": {
|
||||||
|
"name": "Announce",
|
||||||
|
"description": "Announce this Home Assistant's LXMF destination on the network."
|
||||||
|
},
|
||||||
|
"request_path": {
|
||||||
|
"name": "Request path",
|
||||||
|
"description": "Ask the network for a path to a destination.",
|
||||||
|
"fields": {
|
||||||
|
"destination": {
|
||||||
|
"name": "Destination",
|
||||||
|
"description": "Destination hash to resolve a path to."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"set_propagation_node": {
|
||||||
|
"name": "Set propagation node",
|
||||||
|
"description": "Set the outbound LXMF propagation node.",
|
||||||
|
"fields": {
|
||||||
|
"destination": {
|
||||||
|
"name": "Destination",
|
||||||
|
"description": "Propagation node destination hash."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sync_propagation": {
|
||||||
|
"name": "Sync propagation node",
|
||||||
|
"description": "Pull queued messages from the configured propagation node.",
|
||||||
|
"fields": {
|
||||||
|
"max_messages": {
|
||||||
|
"name": "Max messages",
|
||||||
|
"description": "Maximum number of messages to retrieve."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
164
custom_components/reticulum/translations/en.json
Normal file
164
custom_components/reticulum/translations/en.json
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Connect to Reticulum",
|
||||||
|
"description": "Home Assistant will run a Reticulum instance and connect out to your Reticulum stack over TCP (the neighbouring machine must expose a TCPServerInterface).",
|
||||||
|
"data": {
|
||||||
|
"target_host": "Target host",
|
||||||
|
"target_port": "Target port",
|
||||||
|
"display_name": "Display name",
|
||||||
|
"interface_name": "Interface name"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"target_host": "Hostname or IP of the machine running the Reticulum TCP server.",
|
||||||
|
"target_port": "TCP port of the Reticulum TCPServerInterface (default 4242).",
|
||||||
|
"display_name": "Name announced to other Reticulum peers for this Home Assistant.",
|
||||||
|
"interface_name": "Label used for this connection in the Reticulum config."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Could not reach the Reticulum TCP server at that host and port."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Reticulum is already configured.",
|
||||||
|
"single_instance_allowed": "Only a single Reticulum instance is allowed."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"title": "Reticulum options",
|
||||||
|
"data": {
|
||||||
|
"enable_assist": "Route incoming messages to Assist",
|
||||||
|
"assist_agent": "Assist agent (entity id / agent id, blank = default)",
|
||||||
|
"assist_language": "Assist language (blank = system default)",
|
||||||
|
"allow_all_senders": "Reply to any sender",
|
||||||
|
"allowed_identities": "Allowed sender addresses",
|
||||||
|
"default_recipient": "Default recipient address (for the notify entity)",
|
||||||
|
"greeting": "Greeting message",
|
||||||
|
"delivery_method": "Default delivery method",
|
||||||
|
"announce_interval": "Announce interval",
|
||||||
|
"propagation_node": "Propagation node address",
|
||||||
|
"sync_interval": "Propagation sync interval",
|
||||||
|
"loglevel": "Reticulum log level (0-7)"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"enable_assist": "When enabled, incoming LXMF messages are answered by the Assist conversation agent and the reply is sent back over Reticulum.",
|
||||||
|
"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.",
|
||||||
|
"announce_interval": "How often (seconds) to announce our address. 0 disables periodic announces.",
|
||||||
|
"propagation_node": "Optional LXMF propagation node for store-and-forward delivery.",
|
||||||
|
"sync_interval": "How often (seconds) to pull queued messages from the propagation node. 0 disables."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"sensor": {
|
||||||
|
"lxmf_address": { "name": "LXMF address" },
|
||||||
|
"messages_received": { "name": "Messages received" },
|
||||||
|
"messages_sent": { "name": "Messages sent" },
|
||||||
|
"messages_failed": { "name": "Messages failed" },
|
||||||
|
"peers": { "name": "Known peers" },
|
||||||
|
"last_message": { "name": "Last message" },
|
||||||
|
"last_message_time": { "name": "Last message time" },
|
||||||
|
"rx_bytes": { "name": "Received" },
|
||||||
|
"tx_bytes": { "name": "Transmitted" },
|
||||||
|
"bitrate": { "name": "Bitrate" },
|
||||||
|
"rssi": { "name": "RSSI" },
|
||||||
|
"snr": { "name": "SNR" },
|
||||||
|
"link_quality": { "name": "Link quality" }
|
||||||
|
},
|
||||||
|
"binary_sensor": {
|
||||||
|
"connected": { "name": "Connected" }
|
||||||
|
},
|
||||||
|
"button": {
|
||||||
|
"announce": { "name": "Announce" },
|
||||||
|
"sync": { "name": "Sync propagation node" }
|
||||||
|
},
|
||||||
|
"notify": {
|
||||||
|
"message": { "name": "Message" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"delivery_method": {
|
||||||
|
"options": {
|
||||||
|
"direct": "Direct (establish a link)",
|
||||||
|
"opportunistic": "Opportunistic (single packet)",
|
||||||
|
"propagated": "Propagated (store and forward)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"device_automation": {
|
||||||
|
"trigger_type": {
|
||||||
|
"message_received": "Message received",
|
||||||
|
"message_delivered": "Message delivered",
|
||||||
|
"message_failed": "Message delivery failed",
|
||||||
|
"announce_received": "Announce received (peer heard)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"send_message": {
|
||||||
|
"name": "Send message",
|
||||||
|
"description": "Send an LXMF message to a Reticulum destination.",
|
||||||
|
"fields": {
|
||||||
|
"destination": {
|
||||||
|
"name": "Destination",
|
||||||
|
"description": "16-byte hex LXMF destination hash of the recipient."
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "Content",
|
||||||
|
"description": "The message body."
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "Title",
|
||||||
|
"description": "Optional message title / subject."
|
||||||
|
},
|
||||||
|
"method": {
|
||||||
|
"name": "Method",
|
||||||
|
"description": "Delivery method to use."
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"name": "Fields",
|
||||||
|
"description": "Optional advanced LXMF fields dictionary."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"announce": {
|
||||||
|
"name": "Announce",
|
||||||
|
"description": "Announce this Home Assistant's LXMF destination on the network."
|
||||||
|
},
|
||||||
|
"request_path": {
|
||||||
|
"name": "Request path",
|
||||||
|
"description": "Ask the network for a path to a destination.",
|
||||||
|
"fields": {
|
||||||
|
"destination": {
|
||||||
|
"name": "Destination",
|
||||||
|
"description": "Destination hash to resolve a path to."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"set_propagation_node": {
|
||||||
|
"name": "Set propagation node",
|
||||||
|
"description": "Set the outbound LXMF propagation node.",
|
||||||
|
"fields": {
|
||||||
|
"destination": {
|
||||||
|
"name": "Destination",
|
||||||
|
"description": "Propagation node destination hash."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sync_propagation": {
|
||||||
|
"name": "Sync propagation node",
|
||||||
|
"description": "Pull queued messages from the configured propagation node.",
|
||||||
|
"fields": {
|
||||||
|
"max_messages": {
|
||||||
|
"name": "Max messages",
|
||||||
|
"description": "Maximum number of messages to retrieve."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
164
custom_components/reticulum/translations/ru.json
Normal file
164
custom_components/reticulum/translations/ru.json
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Подключение к Reticulum",
|
||||||
|
"description": "Home Assistant запустит экземпляр Reticulum и подключится к вашему стеку Reticulum по TCP (на соседней машине должен быть настроен TCPServerInterface).",
|
||||||
|
"data": {
|
||||||
|
"target_host": "Хост",
|
||||||
|
"target_port": "Порт",
|
||||||
|
"display_name": "Отображаемое имя",
|
||||||
|
"interface_name": "Название интерфейса"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"target_host": "Имя хоста или IP-адрес машины с TCP-сервером Reticulum.",
|
||||||
|
"target_port": "TCP-порт TCPServerInterface Reticulum (по умолчанию 4242).",
|
||||||
|
"display_name": "Имя, анонсируемое другим узлам Reticulum для этого Home Assistant.",
|
||||||
|
"interface_name": "Метка соединения в конфигурации Reticulum."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Не удалось подключиться к TCP-серверу Reticulum по указанному адресу и порту."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Reticulum уже настроен.",
|
||||||
|
"single_instance_allowed": "Допускается только один экземпляр Reticulum."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"title": "Параметры Reticulum",
|
||||||
|
"data": {
|
||||||
|
"enable_assist": "Направлять входящие сообщения в Assist",
|
||||||
|
"assist_agent": "Агент Assist (id сущности/агента, пусто = по умолчанию)",
|
||||||
|
"assist_language": "Язык Assist (пусто = язык системы)",
|
||||||
|
"allow_all_senders": "Отвечать любому отправителю",
|
||||||
|
"allowed_identities": "Разрешённые адреса отправителей",
|
||||||
|
"default_recipient": "Адрес получателя по умолчанию (для сущности notify)",
|
||||||
|
"greeting": "Приветственное сообщение",
|
||||||
|
"delivery_method": "Способ доставки по умолчанию",
|
||||||
|
"announce_interval": "Интервал анонсов",
|
||||||
|
"propagation_node": "Адрес узла ретрансляции (propagation node)",
|
||||||
|
"sync_interval": "Интервал синхронизации с узлом ретрансляции",
|
||||||
|
"loglevel": "Уровень логирования Reticulum (0-7)"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"enable_assist": "Если включено, входящие сообщения LXMF обрабатываются агентом Assist, а ответ отправляется обратно через Reticulum.",
|
||||||
|
"allowed_identities": "По одному адресу в строке (или через запятую). Используется только если «Отвечать любому отправителю» выключено.",
|
||||||
|
"default_recipient": "16-байтовый hex-адрес LXMF, на который отправляет сущность notify.reticulum.",
|
||||||
|
"announce_interval": "Как часто (в секундах) анонсировать наш адрес. 0 отключает периодические анонсы.",
|
||||||
|
"propagation_node": "Необязательный узел ретрансляции LXMF для доставки по принципу store-and-forward.",
|
||||||
|
"sync_interval": "Как часто (в секундах) забирать сообщения из очереди узла ретрансляции. 0 отключает."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"sensor": {
|
||||||
|
"lxmf_address": { "name": "Адрес LXMF" },
|
||||||
|
"messages_received": { "name": "Сообщений получено" },
|
||||||
|
"messages_sent": { "name": "Сообщений отправлено" },
|
||||||
|
"messages_failed": { "name": "Сообщений не доставлено" },
|
||||||
|
"peers": { "name": "Известные узлы" },
|
||||||
|
"last_message": { "name": "Последнее сообщение" },
|
||||||
|
"last_message_time": { "name": "Время последнего сообщения" },
|
||||||
|
"rx_bytes": { "name": "Принято" },
|
||||||
|
"tx_bytes": { "name": "Передано" },
|
||||||
|
"bitrate": { "name": "Битрейт" },
|
||||||
|
"rssi": { "name": "RSSI" },
|
||||||
|
"snr": { "name": "SNR" },
|
||||||
|
"link_quality": { "name": "Качество канала" }
|
||||||
|
},
|
||||||
|
"binary_sensor": {
|
||||||
|
"connected": { "name": "Подключено" }
|
||||||
|
},
|
||||||
|
"button": {
|
||||||
|
"announce": { "name": "Анонсировать" },
|
||||||
|
"sync": { "name": "Синхронизировать узел ретрансляции" }
|
||||||
|
},
|
||||||
|
"notify": {
|
||||||
|
"message": { "name": "Сообщение" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"delivery_method": {
|
||||||
|
"options": {
|
||||||
|
"direct": "Прямая (установить канал)",
|
||||||
|
"opportunistic": "Оппортунистическая (один пакет)",
|
||||||
|
"propagated": "Через ретрансляцию (store-and-forward)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"device_automation": {
|
||||||
|
"trigger_type": {
|
||||||
|
"message_received": "Получено сообщение",
|
||||||
|
"message_delivered": "Сообщение доставлено",
|
||||||
|
"message_failed": "Сбой доставки сообщения",
|
||||||
|
"announce_received": "Получен анонс (услышан узел)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"send_message": {
|
||||||
|
"name": "Отправить сообщение",
|
||||||
|
"description": "Отправить сообщение LXMF узлу Reticulum.",
|
||||||
|
"fields": {
|
||||||
|
"destination": {
|
||||||
|
"name": "Получатель",
|
||||||
|
"description": "16-байтовый hex-хэш адреса LXMF получателя."
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "Текст",
|
||||||
|
"description": "Тело сообщения."
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "Заголовок",
|
||||||
|
"description": "Необязательный заголовок/тема сообщения."
|
||||||
|
},
|
||||||
|
"method": {
|
||||||
|
"name": "Способ",
|
||||||
|
"description": "Используемый способ доставки."
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"name": "Поля",
|
||||||
|
"description": "Необязательный словарь расширенных полей LXMF."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"announce": {
|
||||||
|
"name": "Анонсировать",
|
||||||
|
"description": "Анонсировать адрес LXMF этого Home Assistant в сети."
|
||||||
|
},
|
||||||
|
"request_path": {
|
||||||
|
"name": "Запросить маршрут",
|
||||||
|
"description": "Запросить у сети маршрут до узла.",
|
||||||
|
"fields": {
|
||||||
|
"destination": {
|
||||||
|
"name": "Получатель",
|
||||||
|
"description": "Хэш адреса, до которого нужно найти маршрут."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"set_propagation_node": {
|
||||||
|
"name": "Задать узел ретрансляции",
|
||||||
|
"description": "Задать исходящий узел ретрансляции LXMF.",
|
||||||
|
"fields": {
|
||||||
|
"destination": {
|
||||||
|
"name": "Получатель",
|
||||||
|
"description": "Хэш адреса узла ретрансляции."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sync_propagation": {
|
||||||
|
"name": "Синхронизировать узел ретрансляции",
|
||||||
|
"description": "Забрать сообщения из очереди настроенного узла ретрансляции.",
|
||||||
|
"fields": {
|
||||||
|
"max_messages": {
|
||||||
|
"name": "Макс. сообщений",
|
||||||
|
"description": "Максимальное число сообщений для получения."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user