Since HA 2026.x the brands component serves /api/brands/integration/<domain>/<image> from a custom integration's own brand/ directory before falling back to the brands CDN, so without one the integration renders as the grey placeholder. Add brand/icon.png (256x256) and brand/icon@2x.png (512x512) - a mesh lattice, drawn by make_brand_icons.py in the applications repo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267 lines
11 KiB
Markdown
267 lines
11 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.**
|
||
|
||
You configure the shared **stack** once (the RNS connection), and a first LXMF
|
||
**identity** is created automatically. Additional identities are added as
|
||
**sub-entries** from the integration page.
|
||
|
||
| Stack field | Meaning |
|
||
|-------|---------|
|
||
| Target host | Host/IP of the neighbouring Reticulum TCP server |
|
||
| Target port | Its `listen_port` (default `4242`) |
|
||
| Interface name | Label for the connection in the generated RNS config |
|
||
| First identity name | Announce name of the first identity |
|
||
|
||
### Identities (sub-entries)
|
||
|
||
Each identity is an independent LXMF address with its own settings, its own
|
||
device, and its own entities. Add one via the integration's **⋮ → Add identity**
|
||
(or reconfigure an existing one). Typical uses:
|
||
|
||
- an **assistant** identity (Assist enabled) that users chat with;
|
||
- a **receive-only** identity (Assist disabled) whose messages are handled by
|
||
automations via the `reticulum_message_received` event.
|
||
|
||
Per-identity settings:
|
||
|
||
- **Name** – contact name announced to peers (also the identity's HA name).
|
||
Change it any time by reconfiguring the identity; future announces use it.
|
||
- **Route incoming messages to Assist** – turn the chat bridge on/off.
|
||
- **Assist agent / language** – pick the conversation agent from the dropdown.
|
||
> **Want free-form chat?** The default *Home Assistant* agent only recognises
|
||
> device-control commands. To chat, install an LLM conversation integration
|
||
> (OpenAI, Google Generative AI, Anthropic, Ollama, …) and select it here.
|
||
- **Reply to any sender** / **Allowed sender addresses** – restrict who is answered.
|
||
- **Default recipient** – address this identity's notify entity sends to.
|
||
- **Auto-reply (when Assist is off)** – message sent automatically in reply to
|
||
incoming messages only when Assist routing is off. Blank = disabled.
|
||
- **Default delivery method**, **announce interval**, **propagation node** +
|
||
**sync interval**.
|
||
|
||
Stack-level options (**⚙ → Configure**): **Reticulum log level** (`0`–`7`).
|
||
|
||
Find each identity's **LXMF address** on its *LXMF address* sensor, or in the
|
||
log (`Reticulum identity '…' ready: a1b2c3…`). Add it as a contact in
|
||
Sideband/MeshChat and message it.
|
||
|
||
---
|
||
|
||
## Entities
|
||
|
||
**Hub (stack) device — `Reticulum`:**
|
||
|
||
| Entity | Description |
|
||
|--------|-------------|
|
||
| `binary_sensor` Connected | Whether the TCP interface is online |
|
||
| `sensor` Known peers | Count + `peers` attribute (address, name, hops) |
|
||
| `sensor` Received / Transmitted | Interface RX/TX byte counters |
|
||
| `sensor` Bitrate | Interface bitrate |
|
||
| `sensor` RSSI / SNR / Link quality | Physical-link telemetry (disabled by default) |
|
||
|
||
**Per identity device (one per identity):**
|
||
|
||
| Entity | Description |
|
||
|--------|-------------|
|
||
| `sensor` LXMF address | This identity's address (share it with users) |
|
||
| `sensor` Messages received / sent / failed | Counters |
|
||
| `sensor` Last message | Last inbound text + source/title attributes |
|
||
| `button` Announce | Announce this identity now |
|
||
| `button` Sync propagation node | Pull queued messages now |
|
||
| `button` Regenerate identity | Generate a brand-new address for this identity |
|
||
| `notify` Send message | Sends an LXMF message from this identity to its **default recipient** (`action: notify.send_message`); for arbitrary addresses use the `reticulum.send_message` service |
|
||
|
||
RSSI, SNR and link quality only carry data on a physical interface (RNode/LoRa)
|
||
— they stay unavailable on a plain TCP link, so they are disabled by default.
|
||
|
||
> **Regenerate identity** replaces this identity's address with a fresh one and
|
||
> re-announces. The old address stops working — contacts must re-add the new one.
|
||
|
||
---
|
||
|
||
## 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
|
||
identity: "Home Assistant" # optional: which identity to send from (name or
|
||
# LXMF address); defaults to the first identity
|
||
```
|
||
|
||
Other services: `reticulum.announce`, `reticulum.request_path`,
|
||
`reticulum.set_propagation_node`, `reticulum.sync_propagation`. Each accepts an
|
||
optional `identity` to pick which identity to act through.
|
||
|
||
---
|
||
|
||
## Events
|
||
|
||
- `reticulum_message_received` — `{content, title, source, destination, timestamp, signature_validated, fields, identity, identity_name, local_address}`
|
||
- `reticulum_message_delivered` — `{message_hash, destination, identity, identity_name, local_address}`
|
||
- `reticulum_message_failed` — `{message_hash, destination, identity, identity_name, local_address}`
|
||
- `reticulum_announce_received` — `{destination, display_name, stamp_cost, hops}`
|
||
|
||
`identity` is the subentry id and `identity_name` the identity's name, so
|
||
automations can tell which identity received/sent a message.
|
||
|
||
### Example automation — forward every inbound message to a mobile push
|
||
|
||
```yaml
|
||
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**, so only one Reticulum
|
||
config entry (the stack) is allowed; add multiple **identities** as sub-entries
|
||
under it. Because RNS/LXMF cannot be cleanly torn down inside a running
|
||
process, the running stack and each identity's router are reused across
|
||
reloads. Changing the host/port (or recovering from a failed first start)
|
||
therefore requires a **full Home Assistant restart**, not just a reload.
|
||
- Each identity is stored in `config/reticulum/identities/<subentry_id>/identity`
|
||
— back it up to keep the same address. **Regenerating** an identity overwrites
|
||
it with a new address.
|
||
- RNS and LXMF are standalone-daemon-style libraries — both install
|
||
SIGINT/SIGTERM handlers and `atexit` persistence in their constructors. To
|
||
embed them cleanly the integration suppresses those signal handlers during
|
||
init (so Home Assistant keeps ownership of SIGINT/SIGTERM — important under
|
||
Kubernetes) and unregisters their 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.
|
||
|
||
## Brand icon
|
||
|
||
`custom_components/reticulum/brand/icon.png` (256x256) and `icon@2x.png`
|
||
(512x512) are the integration's own icon. Since HA 2026.x the `brands`
|
||
component serves `/api/brands/integration/reticulum/<image>` from a custom
|
||
integration's `brand/` directory before falling back to the brands CDN, so
|
||
without them the integration shows the grey placeholder. Detection is
|
||
`Integration.has_branding`, which only checks that a top-level `brand`
|
||
directory exists - it is a cached property, so Home Assistant has to be
|
||
restarted after the files land.
|
||
|
||
## License
|
||
MIT
|