Root cause of setup failure: Reticulum.__init__ calls signal.signal() with no main-thread guard, but we (correctly) initialise off the event loop in an executor thread, where signal.signal() raises ValueError. It failed after setting the __instance singleton, which both broke setup and caused the "Attempt to reinitialise Reticulum" error on every retry. - Temporarily neutralise signal.signal during RNS init so init completes in the executor, and so RNS does not hijack HA's SIGINT/SIGTERM (needed for clean shutdown under Kubernetes). - Unregister RNS's and LXMF's atexit exit handlers, which otherwise persist state with blocking file I/O on the event-loop thread and detach HA's stdout/stderr at interpreter exit (the loop-blocking warnings seen in logs). - Persist RNS/LXMF state ourselves off-loop on the homeassistant_stop event. Documents the remaining non-daemon RNS worker threads as a known shutdown note. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
230 lines
8.7 KiB
Markdown
230 lines
8.7 KiB
Markdown
# 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, the running stack is reused across reloads. Changing the
|
||
host/port (or recovering from a failed first start) therefore requires a
|
||
**full Home Assistant restart**, not just an integration reload — a reload
|
||
reuses the already-running stack. Option changes that don't touch the stack
|
||
apply on reload.
|
||
- The identity is stored in `config/reticulum/identity` — back it up to keep the
|
||
same address.
|
||
- RNS is a standalone-daemon-style library. To embed it cleanly the integration
|
||
suppresses RNS's own signal handlers (so Home Assistant keeps ownership of
|
||
SIGINT/SIGTERM — important under Kubernetes) and unregisters its blocking
|
||
`atexit` persistence, persisting RNS/LXMF state itself, off the event loop,
|
||
on the `homeassistant_stop` event. RNS still spawns a couple of non-daemon
|
||
worker threads, so on shutdown you may see a "non-daemonic threads" notice and
|
||
a short delay before the process exits (within the pod's termination grace
|
||
period).
|
||
- 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
|