mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-01 19:12:57 +02:00
Merge pull request #174 from jkingsman/ha
HomeAssistant MQTT Integration Module
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -30,3 +30,6 @@ references/
|
||||
docker-compose.yml
|
||||
docker-compose.yaml
|
||||
.docker-certs/
|
||||
|
||||
# HA test environment (created by scripts/setup/start_ha_test_env.sh)
|
||||
ha_test_config/
|
||||
|
||||
305
README_HA.md
Normal file
305
README_HA.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# Home Assistant Integration
|
||||
|
||||
RemoteTerm can publish mesh network data to Home Assistant via MQTT Discovery. Devices and entities appear automatically in HA -- no custom component or HACS install needed.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Home Assistant with the [MQTT integration](https://www.home-assistant.io/integrations/mqtt/) configured
|
||||
- An MQTT broker (e.g. Mosquitto) accessible to both HA and RemoteTerm
|
||||
- RemoteTerm running and connected to a radio
|
||||
|
||||
## Setup
|
||||
|
||||
1. In RemoteTerm, go to **Settings > Integrations > Add > Home Assistant MQTT Discovery**
|
||||
2. Enter your MQTT broker host and port (same broker HA is connected to)
|
||||
3. Optionally enter broker username/password and TLS settings
|
||||
4. Select contacts for GPS tracking and repeaters for telemetry (see below)
|
||||
5. Configure which messages should fire events (scope selector at the bottom)
|
||||
6. Save and enable
|
||||
|
||||
Devices will appear in HA under **Settings > Devices & Services > MQTT** within a few seconds.
|
||||
|
||||
## What Gets Created
|
||||
|
||||
### Local Radio Device
|
||||
|
||||
Always created. Updates every 60 seconds.
|
||||
|
||||
| Entity | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `binary_sensor.meshcore_*_connected` | Connectivity | Radio online/offline |
|
||||
| `sensor.meshcore_*_noise_floor` | Signal strength | Radio noise floor (dBm) |
|
||||
|
||||
### Repeater Devices
|
||||
|
||||
One device per tracked repeater (must have repeater opted). Updates when telemetry is collected (auto-collect cycle (~8 hours), or when you manually fetch from the repeater dashboard).
|
||||
|
||||
Repeaters must first be added to the auto-telemetry tracking list in RemoteTerm's Radio settings section. Only auto-tracked repeaters appear in the HA integration's repeater picker.
|
||||
|
||||
| Entity | Type | Unit | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `sensor.meshcore_*_battery_voltage` | Voltage | V | Battery level |
|
||||
| `sensor.meshcore_*_noise_floor` | Signal strength | dBm | Local noise floor |
|
||||
| `sensor.meshcore_*_last_rssi` | Signal strength | dBm | Last received signal strength |
|
||||
| `sensor.meshcore_*_last_snr` | -- | dB | Last signal-to-noise ratio |
|
||||
| `sensor.meshcore_*_packets_received` | -- | count | Total packets received |
|
||||
| `sensor.meshcore_*_packets_sent` | -- | count | Total packets sent |
|
||||
| `sensor.meshcore_*_uptime` | Duration | s | Uptime since last reboot |
|
||||
|
||||
### Contact Device Trackers
|
||||
|
||||
One `device_tracker` per tracked contact. Updates passively whenever RemoteTerm hears an advertisement with GPS coordinates from that contact. No radio commands are sent -- it piggybacks on normal mesh traffic.
|
||||
|
||||
| Entity | Description |
|
||||
|--------|-------------|
|
||||
| `device_tracker.meshcore_*` | GPS position (latitude/longitude) |
|
||||
|
||||
### Message Event Entity
|
||||
|
||||
A single `event.meshcore_messages` entity that fires for each message matching your configured scope. Each event carries these attributes:
|
||||
|
||||
| Attribute | Example | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `event_type` | `message_received` | Always `message_received` |
|
||||
| `sender_name` | `Alice` | Display name of the sender |
|
||||
| `sender_key` | `aabbccdd...` | Sender's public key |
|
||||
| `text` | `hello` | Message body |
|
||||
| `message_type` | `PRIV` or `CHAN` | Direct message or channel |
|
||||
| `channel_name` | `#general` | Channel name (null for DMs) |
|
||||
| `conversation_key` | `aabbccdd...` | Contact key (DM) or channel key |
|
||||
| `outgoing` | `false` | Whether you sent this message |
|
||||
|
||||
## Entity Naming
|
||||
|
||||
Entity IDs use the first 12 characters of the node's public key as an identifier. For example, a contact with public key `ae92577bae6c...` gets entity ID `device_tracker.meshcore_ae92577bae6c`. You can rename entities in HA's UI without affecting the integration.
|
||||
|
||||
## Common Automations
|
||||
|
||||
### Low repeater battery alert
|
||||
|
||||
Notify when a tracked repeater's battery drops below a threshold.
|
||||
|
||||
**GUI:** Settings > Automations > Create > Numeric state trigger on `sensor.meshcore_*_battery_voltage`, below `3.8`, action: notification.
|
||||
|
||||
**YAML:**
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Repeater battery low"
|
||||
trigger:
|
||||
- platform: numeric_state
|
||||
entity_id: sensor.meshcore_aabbccddeeff_battery_voltage
|
||||
below: 3.8
|
||||
action:
|
||||
- service: notify.mobile_app_your_phone
|
||||
data:
|
||||
title: "Repeater Battery Low"
|
||||
message: >-
|
||||
{{ state_attr('sensor.meshcore_aabbccddeeff_battery_voltage', 'friendly_name') }}
|
||||
is at {{ states('sensor.meshcore_aabbccddeeff_battery_voltage') }}V
|
||||
```
|
||||
|
||||
### Radio offline alert
|
||||
|
||||
Notify if the radio has been disconnected for more than 5 minutes.
|
||||
|
||||
**GUI:** Settings > Automations > Create > State trigger on `binary_sensor.meshcore_*_connected`, to `off`, for `00:05:00`, action: notification.
|
||||
|
||||
**YAML:**
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Radio offline"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.meshcore_aabbccddeeff_connected
|
||||
to: "off"
|
||||
for: "00:05:00"
|
||||
action:
|
||||
- service: notify.mobile_app_your_phone
|
||||
data:
|
||||
title: "MeshCore Radio Offline"
|
||||
message: "Radio has been disconnected for 5 minutes"
|
||||
```
|
||||
|
||||
### Alert on any message from a specific room
|
||||
|
||||
Trigger when a message arrives in a specific channel. Two approaches:
|
||||
|
||||
#### Option A: Scope filtering (fully GUI, no template)
|
||||
|
||||
If you only care about one room, configure the HA integration's message scope to "Only listed channels" and select that room. Then every event fire is from that room.
|
||||
|
||||
**GUI:** Settings > Automations > Create > State trigger on `event.meshcore_messages`, action: notification.
|
||||
|
||||
**YAML:**
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Emergency channel alert"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: event.meshcore_messages
|
||||
action:
|
||||
- service: notify.mobile_app_your_phone
|
||||
data:
|
||||
title: "Message in #emergency"
|
||||
message: >-
|
||||
{{ trigger.to_state.attributes.sender_name }}:
|
||||
{{ trigger.to_state.attributes.text }}
|
||||
```
|
||||
|
||||
#### Option B: Template condition (multiple rooms, one integration)
|
||||
|
||||
Keep scope as "All messages" and filter in the automation. The trigger is GUI, but the condition uses a one-line template.
|
||||
|
||||
**GUI:** Settings > Automations > Create > State trigger on `event.meshcore_messages` > Add condition > Template > enter the template below.
|
||||
|
||||
**YAML:**
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Emergency channel alert"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: event.meshcore_messages
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ trigger.to_state.attributes.channel_name == '#emergency' }}
|
||||
action:
|
||||
- service: notify.mobile_app_your_phone
|
||||
data:
|
||||
title: "Message in #emergency"
|
||||
message: >-
|
||||
{{ trigger.to_state.attributes.sender_name }}:
|
||||
{{ trigger.to_state.attributes.text }}
|
||||
```
|
||||
|
||||
### Alert on DM from a specific contact
|
||||
|
||||
**YAML:**
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "DM from Alice"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: event.meshcore_messages
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ trigger.to_state.attributes.message_type == 'PRIV'
|
||||
and trigger.to_state.attributes.sender_name == 'Alice' }}
|
||||
action:
|
||||
- service: notify.mobile_app_your_phone
|
||||
data:
|
||||
title: "DM from Alice"
|
||||
message: "{{ trigger.to_state.attributes.text }}"
|
||||
```
|
||||
|
||||
### Alert on messages containing a keyword
|
||||
|
||||
**YAML:**
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Keyword alert"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: event.meshcore_messages
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ 'emergency' in trigger.to_state.attributes.text | lower }}
|
||||
action:
|
||||
- service: notify.mobile_app_your_phone
|
||||
data:
|
||||
title: "Emergency keyword detected"
|
||||
message: >-
|
||||
{{ trigger.to_state.attributes.sender_name }} in
|
||||
{{ trigger.to_state.attributes.channel_name or 'DM' }}:
|
||||
{{ trigger.to_state.attributes.text }}
|
||||
```
|
||||
|
||||
### Track a contact on the HA map
|
||||
|
||||
No automation needed. Once a contact is selected for GPS tracking, their `device_tracker` entity automatically appears on the HA map. Go to **Settings > Dashboards > Map** (or add a Map card to any dashboard) and the tracked contact will show up when they advertise their GPS position.
|
||||
|
||||
### Dashboard card showing repeater battery
|
||||
|
||||
Add a sensor card to any dashboard:
|
||||
|
||||
```yaml
|
||||
type: sensor
|
||||
entity: sensor.meshcore_aabbccddeeff_battery_voltage
|
||||
name: "Hilltop Repeater Battery"
|
||||
```
|
||||
|
||||
Or an entities card for multiple repeaters:
|
||||
|
||||
```yaml
|
||||
type: entities
|
||||
title: "Repeater Status"
|
||||
entities:
|
||||
- entity: sensor.meshcore_aabbccddeeff_battery_voltage
|
||||
name: "Hilltop"
|
||||
- entity: sensor.meshcore_ccdd11223344_battery_voltage
|
||||
name: "Valley"
|
||||
- entity: sensor.meshcore_eeff55667788_battery_voltage
|
||||
name: "Ridge"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Devices don't appear in HA
|
||||
|
||||
- Verify the MQTT integration is configured in HA (**Settings > Devices & Services > MQTT**) and shows "Connected"
|
||||
- Verify RemoteTerm's HA integration shows "Connected" (green dot)
|
||||
- Check that both HA and RemoteTerm are using the same MQTT broker
|
||||
- Subscribe to discovery topics to verify messages are flowing:
|
||||
```
|
||||
mosquitto_sub -h <broker> -t 'homeassistant/#' -v
|
||||
```
|
||||
|
||||
### Stale or duplicate devices
|
||||
|
||||
If you see unexpected devices (e.g. a generic "MeshCore Radio" alongside your named radio), clear the stale retained messages:
|
||||
```
|
||||
mosquitto_pub -h <broker> -t 'homeassistant/binary_sensor/meshcore_unknown/connected/config' -r -n
|
||||
mosquitto_pub -h <broker> -t 'homeassistant/sensor/meshcore_unknown/noise_floor/config' -r -n
|
||||
```
|
||||
|
||||
### Repeater sensors show "Unknown" or "Unavailable"
|
||||
|
||||
Repeater telemetry only updates when collected. Trigger a manual fetch by opening the repeater's dashboard in RemoteTerm and clicking "Status", or wait for the next auto-collect cycle (~8 hours). Sensors show "Unknown" until the first telemetry reading arrives.
|
||||
|
||||
### Contact device tracker shows "Unknown"
|
||||
|
||||
The contact's GPS position only updates when RemoteTerm hears an advertisement from that node that includes GPS coordinates. If the contact's device doesn't broadcast GPS or hasn't advertised recently, the tracker will show as unknown.
|
||||
|
||||
### Entity is "Unavailable"
|
||||
|
||||
Radio health entities have a 120-second expiry. If RemoteTerm stops sending health updates (e.g. it's shut down or loses connection to the broker), HA marks the entities as unavailable after 2 minutes. Restart RemoteTerm or check the broker connection.
|
||||
|
||||
## Removing the Integration
|
||||
|
||||
Disabling or deleting the HA integration in RemoteTerm's settings publishes empty retained messages to all discovery topics, which removes the devices and entities from HA automatically.
|
||||
|
||||
## MQTT Topics Reference
|
||||
|
||||
State topics (where data is published):
|
||||
|
||||
| Topic | Content | Update frequency |
|
||||
|-------|---------|-----------------|
|
||||
| `meshcore/{node_id}/health` | `{"connected": bool, "noise_floor_dbm": int}` | Every 60s |
|
||||
| `meshcore/{node_id}/telemetry` | `{"battery_volts": float, ...}` | ~8h or manual |
|
||||
| `meshcore/{node_id}/gps` | `{"latitude": float, "longitude": float, ...}` | On advert |
|
||||
| `meshcore/events/message` | `{"event_type": "message_received", ...}` | On message |
|
||||
|
||||
Discovery topics (entity registration, under `homeassistant/`):
|
||||
|
||||
| Pattern | Entity type |
|
||||
|---------|------------|
|
||||
| `homeassistant/binary_sensor/meshcore_*/connected/config` | Radio connectivity |
|
||||
| `homeassistant/sensor/meshcore_*/noise_floor/config` | Noise floor sensor |
|
||||
| `homeassistant/sensor/meshcore_*/battery_voltage/config` | Repeater battery |
|
||||
| `homeassistant/sensor/meshcore_*/*/config` | Other repeater sensors |
|
||||
| `homeassistant/device_tracker/meshcore_*/config` | Contact GPS tracker |
|
||||
| `homeassistant/event/meshcore_messages/config` | Message event entity |
|
||||
|
||||
The `{node_id}` is always the first 12 characters of the node's public key, lowercased.
|
||||
@@ -31,12 +31,14 @@ def _register_module_types() -> None:
|
||||
from app.fanout.bot import BotModule
|
||||
from app.fanout.map_upload import MapUploadModule
|
||||
from app.fanout.mqtt_community import MqttCommunityModule
|
||||
from app.fanout.mqtt_ha import MqttHaModule
|
||||
from app.fanout.mqtt_private import MqttPrivateModule
|
||||
from app.fanout.sqs import SqsModule
|
||||
from app.fanout.webhook import WebhookModule
|
||||
|
||||
_MODULE_TYPES["mqtt_private"] = MqttPrivateModule
|
||||
_MODULE_TYPES["mqtt_community"] = MqttCommunityModule
|
||||
_MODULE_TYPES["mqtt_ha"] = MqttHaModule
|
||||
_MODULE_TYPES["bot"] = BotModule
|
||||
_MODULE_TYPES["webhook"] = WebhookModule
|
||||
_MODULE_TYPES["apprise"] = AppriseModule
|
||||
|
||||
627
app/fanout/mqtt_ha.py
Normal file
627
app/fanout/mqtt_ha.py
Normal file
@@ -0,0 +1,627 @@
|
||||
"""Home Assistant MQTT Discovery fanout module.
|
||||
|
||||
Publishes HA-compatible discovery configs and state updates so that mesh
|
||||
network devices appear natively in Home Assistant via its built-in MQTT
|
||||
integration. No custom HA component is needed.
|
||||
|
||||
Entity types created:
|
||||
- Local radio: binary_sensor (connectivity) + sensors (noise floor, battery,
|
||||
uptime, RSSI, SNR, airtime, packet counts)
|
||||
- Per tracked repeater: sensor entities for telemetry fields
|
||||
- Per tracked contact: device_tracker for GPS position
|
||||
- Messages: event entity for scope-matched messages
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import ssl
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from app.fanout.base import FanoutModule, get_fanout_message_text
|
||||
from app.fanout.mqtt_base import BaseMqttPublisher
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Repeater telemetry sensor definitions ─────────────────────────────────
|
||||
|
||||
_REPEATER_SENSORS: list[dict[str, str | None]] = [
|
||||
{
|
||||
"field": "battery_volts",
|
||||
"name": "Battery Voltage",
|
||||
"object_id": "battery_voltage",
|
||||
"device_class": "voltage",
|
||||
"state_class": "measurement",
|
||||
"unit": "V",
|
||||
},
|
||||
{
|
||||
"field": "noise_floor_dbm",
|
||||
"name": "Noise Floor",
|
||||
"object_id": "noise_floor",
|
||||
"device_class": "signal_strength",
|
||||
"state_class": "measurement",
|
||||
"unit": "dBm",
|
||||
},
|
||||
{
|
||||
"field": "last_rssi_dbm",
|
||||
"name": "Last RSSI",
|
||||
"object_id": "last_rssi",
|
||||
"device_class": "signal_strength",
|
||||
"state_class": "measurement",
|
||||
"unit": "dBm",
|
||||
},
|
||||
{
|
||||
"field": "last_snr_db",
|
||||
"name": "Last SNR",
|
||||
"object_id": "last_snr",
|
||||
"device_class": None,
|
||||
"state_class": "measurement",
|
||||
"unit": "dB",
|
||||
},
|
||||
{
|
||||
"field": "packets_received",
|
||||
"name": "Packets Received",
|
||||
"object_id": "packets_received",
|
||||
"device_class": None,
|
||||
"state_class": "total_increasing",
|
||||
"unit": None,
|
||||
},
|
||||
{
|
||||
"field": "packets_sent",
|
||||
"name": "Packets Sent",
|
||||
"object_id": "packets_sent",
|
||||
"device_class": None,
|
||||
"state_class": "total_increasing",
|
||||
"unit": None,
|
||||
},
|
||||
{
|
||||
"field": "uptime_seconds",
|
||||
"name": "Uptime",
|
||||
"object_id": "uptime",
|
||||
"device_class": "duration",
|
||||
"state_class": None,
|
||||
"unit": "s",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ── Local radio sensor definitions ────────────────────────────────────────
|
||||
|
||||
_RADIO_SENSORS: list[dict[str, str | None]] = [
|
||||
{
|
||||
"field": "noise_floor_dbm",
|
||||
"name": "Noise Floor",
|
||||
"object_id": "noise_floor",
|
||||
"device_class": "signal_strength",
|
||||
"state_class": "measurement",
|
||||
"unit": "dBm",
|
||||
},
|
||||
{
|
||||
"field": "battery_mv",
|
||||
"name": "Battery",
|
||||
"object_id": "battery",
|
||||
"device_class": "voltage",
|
||||
"state_class": "measurement",
|
||||
"unit": "mV",
|
||||
},
|
||||
{
|
||||
"field": "uptime_secs",
|
||||
"name": "Uptime",
|
||||
"object_id": "uptime",
|
||||
"device_class": "duration",
|
||||
"state_class": None,
|
||||
"unit": "s",
|
||||
},
|
||||
{
|
||||
"field": "last_rssi",
|
||||
"name": "Last RSSI",
|
||||
"object_id": "last_rssi",
|
||||
"device_class": "signal_strength",
|
||||
"state_class": "measurement",
|
||||
"unit": "dBm",
|
||||
},
|
||||
{
|
||||
"field": "last_snr",
|
||||
"name": "Last SNR",
|
||||
"object_id": "last_snr",
|
||||
"device_class": None,
|
||||
"state_class": "measurement",
|
||||
"unit": "dB",
|
||||
},
|
||||
{
|
||||
"field": "tx_air_secs",
|
||||
"name": "TX Airtime",
|
||||
"object_id": "tx_airtime",
|
||||
"device_class": "duration",
|
||||
"state_class": "total_increasing",
|
||||
"unit": "s",
|
||||
},
|
||||
{
|
||||
"field": "rx_air_secs",
|
||||
"name": "RX Airtime",
|
||||
"object_id": "rx_airtime",
|
||||
"device_class": "duration",
|
||||
"state_class": "total_increasing",
|
||||
"unit": "s",
|
||||
},
|
||||
{
|
||||
"field": "packets_recv",
|
||||
"name": "Packets Received",
|
||||
"object_id": "packets_received",
|
||||
"device_class": None,
|
||||
"state_class": "total_increasing",
|
||||
"unit": None,
|
||||
},
|
||||
{
|
||||
"field": "packets_sent",
|
||||
"name": "Packets Sent",
|
||||
"object_id": "packets_sent",
|
||||
"device_class": None,
|
||||
"state_class": "total_increasing",
|
||||
"unit": None,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _node_id(public_key: str) -> str:
|
||||
"""Derive a stable, MQTT-safe node identifier from a public key."""
|
||||
return public_key[:12].lower()
|
||||
|
||||
|
||||
def _device_payload(
|
||||
public_key: str,
|
||||
name: str,
|
||||
model: str,
|
||||
*,
|
||||
via_device_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build an HA device registry fragment."""
|
||||
dev: dict[str, Any] = {
|
||||
"identifiers": [f"meshcore_{_node_id(public_key)}"],
|
||||
"name": name or public_key[:12],
|
||||
"manufacturer": "MeshCore",
|
||||
"model": model,
|
||||
}
|
||||
if via_device_key:
|
||||
dev["via_device"] = f"meshcore_{_node_id(via_device_key)}"
|
||||
return dev
|
||||
|
||||
|
||||
# ── MQTT publisher subclass ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class _HaMqttPublisher(BaseMqttPublisher):
|
||||
"""Thin MQTT lifecycle wrapper for the HA discovery module."""
|
||||
|
||||
_backoff_max = 30
|
||||
_log_prefix = "HA-MQTT"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._on_connected_callback: Any = None
|
||||
|
||||
def _is_configured(self) -> bool:
|
||||
s = self._settings
|
||||
return bool(s and s.broker_host)
|
||||
|
||||
def _build_client_kwargs(self, settings: object) -> dict[str, Any]:
|
||||
s: Any = settings
|
||||
kw: dict[str, Any] = {
|
||||
"hostname": s.broker_host,
|
||||
"port": s.broker_port,
|
||||
"username": s.username or None,
|
||||
"password": s.password or None,
|
||||
}
|
||||
if s.use_tls:
|
||||
ctx = ssl.create_default_context()
|
||||
if s.tls_insecure:
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
kw["tls_context"] = ctx
|
||||
return kw
|
||||
|
||||
def _on_connected(self, settings: object) -> tuple[str, str]:
|
||||
s: Any = settings
|
||||
return ("HA MQTT connected", f"{s.broker_host}:{s.broker_port}")
|
||||
|
||||
def _on_error(self) -> tuple[str, str]:
|
||||
return ("HA MQTT connection failure", "Please correct the settings or disable.")
|
||||
|
||||
async def _on_connected_async(self, settings: object) -> None:
|
||||
if self._on_connected_callback:
|
||||
await self._on_connected_callback()
|
||||
|
||||
|
||||
# ── Discovery config builders ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def _radio_discovery_configs(
|
||||
prefix: str,
|
||||
radio_key: str,
|
||||
radio_name: str,
|
||||
) -> list[tuple[str, dict]]:
|
||||
"""Build HA discovery config payloads for the local radio device."""
|
||||
nid = _node_id(radio_key)
|
||||
device = _device_payload(radio_key, radio_name, "Radio")
|
||||
state_topic = f"{prefix}/{nid}/health"
|
||||
configs: list[tuple[str, dict]] = []
|
||||
|
||||
# binary_sensor: connected
|
||||
configs.append(
|
||||
(
|
||||
f"homeassistant/binary_sensor/meshcore_{nid}/connected/config",
|
||||
{
|
||||
"name": "Connected",
|
||||
"unique_id": f"meshcore_{nid}_connected",
|
||||
"device": device,
|
||||
"state_topic": state_topic,
|
||||
"value_template": "{{ 'ON' if value_json.connected else 'OFF' }}",
|
||||
"device_class": "connectivity",
|
||||
"payload_on": "ON",
|
||||
"payload_off": "OFF",
|
||||
"expire_after": 120,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# sensors from _RADIO_SENSORS (noise floor, battery, uptime, RSSI, etc.)
|
||||
for sensor in _RADIO_SENSORS:
|
||||
cfg: dict[str, Any] = {
|
||||
"name": sensor["name"],
|
||||
"unique_id": f"meshcore_{nid}_{sensor['object_id']}",
|
||||
"device": device,
|
||||
"state_topic": state_topic,
|
||||
"value_template": "{{ value_json." + sensor["field"] + " }}", # type: ignore[operator]
|
||||
"expire_after": 120,
|
||||
}
|
||||
if sensor["device_class"]:
|
||||
cfg["device_class"] = sensor["device_class"]
|
||||
if sensor["state_class"]:
|
||||
cfg["state_class"] = sensor["state_class"]
|
||||
if sensor["unit"]:
|
||||
cfg["unit_of_measurement"] = sensor["unit"]
|
||||
|
||||
topic = f"homeassistant/sensor/meshcore_{nid}/{sensor['object_id']}/config"
|
||||
configs.append((topic, cfg))
|
||||
|
||||
return configs
|
||||
|
||||
|
||||
def _repeater_discovery_configs(
|
||||
prefix: str,
|
||||
pub_key: str,
|
||||
name: str,
|
||||
radio_key: str | None,
|
||||
) -> list[tuple[str, dict]]:
|
||||
"""Build HA discovery config payloads for a tracked repeater."""
|
||||
nid = _node_id(pub_key)
|
||||
device = _device_payload(pub_key, name, "Repeater", via_device_key=radio_key)
|
||||
state_topic = f"{prefix}/{nid}/telemetry"
|
||||
configs: list[tuple[str, dict]] = []
|
||||
|
||||
for sensor in _REPEATER_SENSORS:
|
||||
cfg: dict[str, Any] = {
|
||||
"name": sensor["name"],
|
||||
"unique_id": f"meshcore_{nid}_{sensor['object_id']}",
|
||||
"device": device,
|
||||
"state_topic": state_topic,
|
||||
"value_template": "{{ value_json." + sensor["field"] + " }}", # type: ignore[operator]
|
||||
}
|
||||
if sensor["device_class"]:
|
||||
cfg["device_class"] = sensor["device_class"]
|
||||
if sensor["state_class"]:
|
||||
cfg["state_class"] = sensor["state_class"]
|
||||
if sensor["unit"]:
|
||||
cfg["unit_of_measurement"] = sensor["unit"]
|
||||
# 10 hours — margin over the 8-hour auto-collect cycle
|
||||
cfg["expire_after"] = 36000
|
||||
|
||||
topic = f"homeassistant/sensor/meshcore_{nid}/{sensor['object_id']}/config"
|
||||
configs.append((topic, cfg))
|
||||
|
||||
return configs
|
||||
|
||||
|
||||
def _contact_tracker_discovery_config(
|
||||
prefix: str,
|
||||
pub_key: str,
|
||||
name: str,
|
||||
radio_key: str | None,
|
||||
) -> tuple[str, dict]:
|
||||
"""Build HA discovery config for a tracked contact's device_tracker."""
|
||||
nid = _node_id(pub_key)
|
||||
device = _device_payload(pub_key, name, "Node", via_device_key=radio_key)
|
||||
topic = f"homeassistant/device_tracker/meshcore_{nid}/config"
|
||||
cfg: dict[str, Any] = {
|
||||
"name": name or pub_key[:12],
|
||||
"unique_id": f"meshcore_{nid}_tracker",
|
||||
"device": device,
|
||||
"json_attributes_topic": f"{prefix}/{nid}/gps",
|
||||
"source_type": "gps",
|
||||
}
|
||||
return topic, cfg
|
||||
|
||||
|
||||
def _message_event_discovery_config(
|
||||
prefix: str, radio_key: str, radio_name: str
|
||||
) -> tuple[str, dict]:
|
||||
"""Build HA discovery config for the message event entity."""
|
||||
nid = _node_id(radio_key)
|
||||
device = _device_payload(radio_key, radio_name, "Radio")
|
||||
topic = f"homeassistant/event/meshcore_{nid}/messages/config"
|
||||
cfg: dict[str, Any] = {
|
||||
"name": "MeshCore Messages",
|
||||
"unique_id": f"meshcore_{nid}_messages",
|
||||
"device": device,
|
||||
"state_topic": f"{prefix}/{nid}/events/message",
|
||||
"event_types": ["message_received"],
|
||||
}
|
||||
return topic, cfg
|
||||
|
||||
|
||||
# ── Module class ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _config_to_settings(config: dict) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
broker_host=config.get("broker_host", ""),
|
||||
broker_port=config.get("broker_port", 1883),
|
||||
username=config.get("username", ""),
|
||||
password=config.get("password", ""),
|
||||
use_tls=config.get("use_tls", False),
|
||||
tls_insecure=config.get("tls_insecure", False),
|
||||
)
|
||||
|
||||
|
||||
class MqttHaModule(FanoutModule):
|
||||
"""Home Assistant MQTT Discovery fanout module."""
|
||||
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
self._publisher = _HaMqttPublisher()
|
||||
self._publisher.set_integration_name(name or config_id)
|
||||
self._publisher._on_connected_callback = self._publish_discovery
|
||||
self._discovery_topics: list[str] = []
|
||||
self._radio_key: str | None = None
|
||||
self._radio_name: str | None = None
|
||||
|
||||
@property
|
||||
def _prefix(self) -> str:
|
||||
return self.config.get("topic_prefix", "meshcore")
|
||||
|
||||
@property
|
||||
def _tracked_contacts(self) -> list[str]:
|
||||
return self.config.get("tracked_contacts") or []
|
||||
|
||||
@property
|
||||
def _tracked_repeaters(self) -> list[str]:
|
||||
return self.config.get("tracked_repeaters") or []
|
||||
|
||||
# ── Lifecycle ──────────────────────────────────────────────────────
|
||||
|
||||
async def start(self) -> None:
|
||||
self._seed_radio_identity_from_runtime()
|
||||
settings = _config_to_settings(self.config)
|
||||
await self._publisher.start(settings)
|
||||
|
||||
async def stop(self) -> None:
|
||||
await self._remove_discovery()
|
||||
await self._publisher.stop()
|
||||
self._discovery_topics.clear()
|
||||
|
||||
# ── Discovery publishing ──────────────────────────────────────────
|
||||
|
||||
async def _publish_discovery(self) -> None:
|
||||
"""Publish all HA discovery configs with retain=True."""
|
||||
if not self._radio_key:
|
||||
# Don't publish discovery until we know the radio identity —
|
||||
# the first health heartbeat will provide it and trigger this.
|
||||
return
|
||||
|
||||
configs: list[tuple[str, dict]] = []
|
||||
|
||||
radio_name = self._radio_name or "MeshCore Radio"
|
||||
configs.extend(_radio_discovery_configs(self._prefix, self._radio_key, radio_name))
|
||||
|
||||
# Tracked repeaters — resolve names from DB best-effort
|
||||
for pub_key in self._tracked_repeaters:
|
||||
rname = await self._resolve_contact_name(pub_key)
|
||||
configs.extend(
|
||||
_repeater_discovery_configs(self._prefix, pub_key, rname, self._radio_key)
|
||||
)
|
||||
|
||||
# Tracked contacts — resolve names from DB best-effort
|
||||
for pub_key in self._tracked_contacts:
|
||||
cname = await self._resolve_contact_name(pub_key)
|
||||
configs.append(
|
||||
_contact_tracker_discovery_config(self._prefix, pub_key, cname, self._radio_key)
|
||||
)
|
||||
|
||||
# Message event entity (namespaced to this radio)
|
||||
configs.append(_message_event_discovery_config(self._prefix, self._radio_key, radio_name))
|
||||
|
||||
self._discovery_topics = [topic for topic, _ in configs]
|
||||
|
||||
for topic, payload in configs:
|
||||
await self._publisher.publish(topic, payload, retain=True)
|
||||
|
||||
logger.info(
|
||||
"HA MQTT: published %d discovery configs (%d repeaters, %d contacts)",
|
||||
len(configs),
|
||||
len(self._tracked_repeaters),
|
||||
len(self._tracked_contacts),
|
||||
)
|
||||
|
||||
async def _clear_retained_topics(self, topics: list[str]) -> None:
|
||||
"""Publish empty retained payloads to remove entries from broker."""
|
||||
for topic in topics:
|
||||
try:
|
||||
if self._publisher._client:
|
||||
await self._publisher._client.publish(topic, b"", retain=True)
|
||||
except Exception:
|
||||
pass # best-effort cleanup
|
||||
|
||||
async def _remove_discovery(self) -> None:
|
||||
"""Publish empty retained payloads to remove all HA entities."""
|
||||
if not self._publisher.connected or not self._discovery_topics:
|
||||
return
|
||||
await self._clear_retained_topics(self._discovery_topics)
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_contact_name(pub_key: str) -> str:
|
||||
"""Look up a contact's display name, falling back to 12-char prefix."""
|
||||
try:
|
||||
from app.repository.contacts import ContactRepository
|
||||
|
||||
contact = await ContactRepository.get_by_key(pub_key)
|
||||
if contact and contact.name:
|
||||
return contact.name
|
||||
except Exception:
|
||||
pass
|
||||
return pub_key[:12]
|
||||
|
||||
def _seed_radio_identity_from_runtime(self) -> None:
|
||||
"""Best-effort bootstrap from the currently connected radio session."""
|
||||
try:
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
|
||||
if not radio_runtime.is_connected:
|
||||
return
|
||||
|
||||
mc = radio_runtime.meshcore
|
||||
self_info = mc.self_info if mc is not None else None
|
||||
if not isinstance(self_info, dict):
|
||||
return
|
||||
|
||||
pub_key = self_info.get("public_key")
|
||||
if isinstance(pub_key, str) and pub_key.strip():
|
||||
self._radio_key = pub_key.strip().lower()
|
||||
|
||||
name = self_info.get("name")
|
||||
if isinstance(name, str) and name.strip():
|
||||
self._radio_name = name.strip()
|
||||
except Exception:
|
||||
logger.debug("HA MQTT: failed to seed radio identity from runtime", exc_info=True)
|
||||
|
||||
# ── Event handlers ────────────────────────────────────────────────
|
||||
|
||||
async def on_health(self, data: dict) -> None:
|
||||
if not self._publisher.connected:
|
||||
return
|
||||
|
||||
# Cache radio identity for discovery config generation
|
||||
pub_key = data.get("public_key")
|
||||
if pub_key:
|
||||
new_name = data.get("name")
|
||||
key_changed = pub_key != self._radio_key
|
||||
name_changed = new_name and new_name != self._radio_name
|
||||
|
||||
if key_changed:
|
||||
old_key = self._radio_key
|
||||
old_topics = list(self._discovery_topics)
|
||||
if old_topics:
|
||||
await self._clear_retained_topics(old_topics)
|
||||
self._discovery_topics.clear()
|
||||
self._radio_key = pub_key
|
||||
self._radio_name = new_name
|
||||
# Remove stale discovery entries from the old identity (e.g.
|
||||
# "unknown" placeholder from before the radio key was known),
|
||||
# then re-publish with the real identity.
|
||||
if old_key is not None and not old_topics:
|
||||
await self._clear_retained_topics(
|
||||
[t for t, _ in _radio_discovery_configs(self._prefix, old_key, "")]
|
||||
)
|
||||
await self._publish_discovery()
|
||||
elif name_changed:
|
||||
self._radio_name = new_name
|
||||
await self._publish_discovery()
|
||||
|
||||
# Don't publish health state until we know the radio identity —
|
||||
# otherwise we create a stale "unknown" device in HA.
|
||||
if not self._radio_key:
|
||||
return
|
||||
|
||||
nid = _node_id(self._radio_key)
|
||||
payload: dict[str, Any] = {"connected": data.get("connected", False)}
|
||||
for sensor in _RADIO_SENSORS:
|
||||
field = sensor["field"]
|
||||
if field is not None:
|
||||
payload[field] = data.get(field)
|
||||
await self._publisher.publish(f"{self._prefix}/{nid}/health", payload)
|
||||
|
||||
async def on_contact(self, data: dict) -> None:
|
||||
if not self._publisher.connected:
|
||||
return
|
||||
|
||||
pub_key = data.get("public_key", "")
|
||||
if pub_key not in self._tracked_contacts:
|
||||
return
|
||||
|
||||
lat = data.get("lat")
|
||||
lon = data.get("lon")
|
||||
if lat is None or lon is None or (lat == 0.0 and lon == 0.0):
|
||||
return
|
||||
|
||||
nid = _node_id(pub_key)
|
||||
await self._publisher.publish(
|
||||
f"{self._prefix}/{nid}/gps",
|
||||
{
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"gps_accuracy": 0,
|
||||
"source_type": "gps",
|
||||
},
|
||||
)
|
||||
|
||||
async def on_telemetry(self, data: dict) -> None:
|
||||
if not self._publisher.connected:
|
||||
return
|
||||
|
||||
pub_key = data.get("public_key", "")
|
||||
if pub_key not in self._tracked_repeaters:
|
||||
return
|
||||
|
||||
nid = _node_id(pub_key)
|
||||
# Publish the full telemetry dict — HA sensors use value_template
|
||||
# to extract individual fields
|
||||
payload: dict[str, Any] = {}
|
||||
for s in _REPEATER_SENSORS:
|
||||
field = s["field"]
|
||||
if field is not None:
|
||||
payload[field] = data.get(field)
|
||||
await self._publisher.publish(f"{self._prefix}/{nid}/telemetry", payload)
|
||||
|
||||
async def on_message(self, data: dict) -> None:
|
||||
if not self._publisher.connected or not self._radio_key:
|
||||
return
|
||||
|
||||
text = get_fanout_message_text(data)
|
||||
nid = _node_id(self._radio_key)
|
||||
await self._publisher.publish(
|
||||
f"{self._prefix}/{nid}/events/message",
|
||||
{
|
||||
"event_type": "message_received",
|
||||
"sender_name": data.get("sender_name", ""),
|
||||
"sender_key": data.get("sender_key", ""),
|
||||
"text": text,
|
||||
"conversation_key": data.get("conversation_key", ""),
|
||||
"message_type": data.get("type", ""),
|
||||
"channel_name": data.get("channel_name"),
|
||||
"outgoing": data.get("outgoing", False),
|
||||
},
|
||||
)
|
||||
|
||||
# ── Status ────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if not self.config.get("broker_host"):
|
||||
return "disconnected"
|
||||
if self._publisher.last_error:
|
||||
return "error"
|
||||
return "connected" if self._publisher.connected else "disconnected"
|
||||
|
||||
@property
|
||||
def last_error(self) -> str | None:
|
||||
return self._publisher.last_error
|
||||
@@ -16,7 +16,16 @@ from app.repository.fanout import FanoutConfigRepository
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/fanout", tags=["fanout"])
|
||||
|
||||
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "sqs", "map_upload"}
|
||||
_VALID_TYPES = {
|
||||
"mqtt_private",
|
||||
"mqtt_community",
|
||||
"mqtt_ha",
|
||||
"bot",
|
||||
"webhook",
|
||||
"apprise",
|
||||
"sqs",
|
||||
"map_upload",
|
||||
}
|
||||
|
||||
_IATA_RE = re.compile(r"^[A-Z]{3}$")
|
||||
_DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE = "meshcore/{IATA}/{PUBLIC_KEY}/packets"
|
||||
@@ -96,6 +105,8 @@ def _validate_and_normalize_config(config_type: str, config: dict) -> dict:
|
||||
_validate_sqs_config(normalized)
|
||||
elif config_type == "map_upload":
|
||||
_validate_map_upload_config(normalized)
|
||||
elif config_type == "mqtt_ha":
|
||||
_validate_mqtt_ha_config(normalized)
|
||||
|
||||
return normalized
|
||||
|
||||
@@ -318,6 +329,19 @@ def _validate_map_upload_config(config: dict) -> None:
|
||||
config["geofence_radius_km"] = radius
|
||||
|
||||
|
||||
def _validate_mqtt_ha_config(config: dict) -> None:
|
||||
"""Validate mqtt_ha config blob."""
|
||||
if not config.get("broker_host"):
|
||||
raise HTTPException(status_code=400, detail="broker_host is required for mqtt_ha")
|
||||
port = config.get("broker_port", 1883)
|
||||
if not isinstance(port, int) or port < 1 or port > 65535:
|
||||
raise HTTPException(status_code=400, detail="broker_port must be between 1 and 65535")
|
||||
for field in ("tracked_contacts", "tracked_repeaters"):
|
||||
value = config.get(field)
|
||||
if value is not None and not isinstance(value, list):
|
||||
raise HTTPException(status_code=400, detail=f"{field} must be a list of public keys")
|
||||
|
||||
|
||||
def _enforce_scope(config_type: str, scope: dict) -> dict:
|
||||
"""Enforce type-specific scope constraints. Returns normalized scope."""
|
||||
if config_type == "mqtt_community":
|
||||
@@ -326,7 +350,7 @@ def _enforce_scope(config_type: str, scope: dict) -> dict:
|
||||
return {"messages": "none", "raw_packets": "all"}
|
||||
if config_type == "bot":
|
||||
return {"messages": "all", "raw_packets": "none"}
|
||||
if config_type in ("webhook", "apprise"):
|
||||
if config_type in ("webhook", "apprise", "mqtt_ha"):
|
||||
messages = scope.get("messages", "all")
|
||||
if messages not in ("all", "none") and not isinstance(messages, dict):
|
||||
raise HTTPException(
|
||||
|
||||
@@ -24,6 +24,7 @@ const BotCodeEditor = lazy(() =>
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
mqtt_private: 'Private MQTT',
|
||||
mqtt_community: 'Community Sharing',
|
||||
mqtt_ha: 'Home Assistant',
|
||||
bot: 'Python Bot',
|
||||
webhook: 'Webhook',
|
||||
apprise: 'Apprise',
|
||||
@@ -101,6 +102,7 @@ const DEFAULT_BOT_CODE = `def bot(**kwargs) -> str | list[str] | None:
|
||||
|
||||
type DraftType =
|
||||
| 'mqtt_private'
|
||||
| 'mqtt_ha'
|
||||
| 'mqtt_community'
|
||||
| 'mqtt_community_meshrank'
|
||||
| 'mqtt_community_letsmesh_us'
|
||||
@@ -130,7 +132,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
||||
value: 'mqtt_private',
|
||||
savedType: 'mqtt_private',
|
||||
label: 'Private MQTT',
|
||||
section: 'Bulk Forwarding',
|
||||
section: 'Private Forwarding',
|
||||
description:
|
||||
'Customizable-scope forwarding of all or some messages to an MQTT broker of your choosing, in raw and/or decrypted form.',
|
||||
defaultName: 'Private MQTT',
|
||||
@@ -148,6 +150,30 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
||||
scope: { messages: 'all', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'mqtt_ha',
|
||||
savedType: 'mqtt_ha',
|
||||
label: 'Home Assistant MQTT Discovery',
|
||||
section: 'Private Forwarding',
|
||||
description:
|
||||
"Publishes MQTT Discovery payloads so mesh devices appear natively in Home Assistant. Requires HA's built-in MQTT integration connected to the same broker. Select specific contacts for GPS tracking and repeaters for telemetry sensors.",
|
||||
defaultName: 'Home Assistant',
|
||||
nameMode: 'fixed',
|
||||
defaults: {
|
||||
config: {
|
||||
broker_host: '',
|
||||
broker_port: 1883,
|
||||
username: '',
|
||||
password: '',
|
||||
use_tls: false,
|
||||
tls_insecure: false,
|
||||
topic_prefix: 'meshcore',
|
||||
tracked_contacts: [],
|
||||
tracked_repeaters: [],
|
||||
},
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'mqtt_community',
|
||||
savedType: 'mqtt_community',
|
||||
@@ -261,7 +287,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
||||
value: 'sqs',
|
||||
savedType: 'sqs',
|
||||
label: 'Amazon SQS',
|
||||
section: 'Bulk Forwarding',
|
||||
section: 'Private Forwarding',
|
||||
description: 'Send full or scope-customized raw or decrypted packets to an SQS',
|
||||
defaultName: 'Amazon SQS',
|
||||
nameMode: 'counted',
|
||||
@@ -803,6 +829,441 @@ function MqttPrivateConfigEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function MqttHaConfigEditor({
|
||||
config,
|
||||
scope,
|
||||
onChange,
|
||||
onScopeChange,
|
||||
}: {
|
||||
config: Record<string, unknown>;
|
||||
scope: Record<string, unknown>;
|
||||
onChange: (config: Record<string, unknown>) => void;
|
||||
onScopeChange: (scope: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
const [trackedRepeaters, setTrackedRepeaters] = useState<string[]>([]);
|
||||
const [contactSearch, setContactSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const all: Contact[] = [];
|
||||
const pageSize = 1000;
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const page = await api.getContacts(pageSize, offset);
|
||||
all.push(...page);
|
||||
if (page.length < pageSize) break;
|
||||
offset += pageSize;
|
||||
}
|
||||
setContacts(all);
|
||||
})().catch(console.error);
|
||||
|
||||
api
|
||||
.getSettings()
|
||||
.then((s) => setTrackedRepeaters(s.tracked_telemetry_repeaters ?? []))
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
const selectedContacts = (config.tracked_contacts as string[]) || [];
|
||||
const selectedRepeaters = (config.tracked_repeaters as string[]) || [];
|
||||
|
||||
const contactOptions = useMemo(
|
||||
() => contacts.filter((c) => c.type === 0 || c.type === 1 || c.type === 3),
|
||||
[contacts]
|
||||
);
|
||||
|
||||
const repeaterOptions = useMemo(
|
||||
() => contacts.filter((c) => c.type === 2 && trackedRepeaters.includes(c.public_key)),
|
||||
[contacts, trackedRepeaters]
|
||||
);
|
||||
|
||||
const contactSearchLower = contactSearch.toLowerCase().trim();
|
||||
const filteredContacts = useMemo(() => {
|
||||
const matches = contactOptions.filter((c) => {
|
||||
if (!contactSearchLower) return true;
|
||||
const name = (c.name || '').toLowerCase();
|
||||
const key = c.public_key.toLowerCase();
|
||||
return name.includes(contactSearchLower) || key.startsWith(contactSearchLower);
|
||||
});
|
||||
// Selected contacts sort to top
|
||||
return matches.sort((a, b) => {
|
||||
const aSelected = selectedContacts.includes(a.public_key) ? 0 : 1;
|
||||
const bSelected = selectedContacts.includes(b.public_key) ? 0 : 1;
|
||||
if (aSelected !== bSelected) return aSelected - bSelected;
|
||||
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
|
||||
});
|
||||
}, [contactOptions, contactSearchLower, selectedContacts]);
|
||||
|
||||
const selectedContactDetails = contactOptions.filter((c) =>
|
||||
selectedContacts.includes(c.public_key)
|
||||
);
|
||||
|
||||
const toggleTrackedContact = (key: string) => {
|
||||
const current = [...selectedContacts];
|
||||
const idx = current.indexOf(key);
|
||||
if (idx >= 0) current.splice(idx, 1);
|
||||
else current.push(key);
|
||||
onChange({ ...config, tracked_contacts: current });
|
||||
};
|
||||
|
||||
const toggleTrackedRepeater = (key: string) => {
|
||||
const current = [...selectedRepeaters];
|
||||
const idx = current.indexOf(key);
|
||||
if (idx >= 0) current.splice(idx, 1);
|
||||
else current.push(key);
|
||||
onChange({ ...config, tracked_repeaters: current });
|
||||
};
|
||||
|
||||
const prefix = ((config.topic_prefix as string) || 'meshcore').trim() || 'meshcore';
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Uses{' '}
|
||||
<span
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
className="underline cursor-pointer hover:text-primary transition-colors"
|
||||
onClick={() =>
|
||||
window.open('https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery', '_blank')
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter')
|
||||
window.open(
|
||||
'https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery',
|
||||
'_blank'
|
||||
);
|
||||
}}
|
||||
>
|
||||
MQTT Discovery
|
||||
</span>{' '}
|
||||
to automatically create devices and entities in Home Assistant. Your HA instance must have
|
||||
the MQTT integration configured and connected to the same broker. See{' '}
|
||||
<span
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
className="underline cursor-pointer hover:text-primary transition-colors"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
'https://github.com/jkingsman/Remote-Terminal-for-MeshCore/blob/main/README_HA.md',
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter')
|
||||
window.open(
|
||||
'https://github.com/jkingsman/Remote-Terminal-for-MeshCore/blob/main/README_HA.md',
|
||||
'_blank'
|
||||
);
|
||||
}}
|
||||
>
|
||||
README_HA.md
|
||||
</span>{' '}
|
||||
for automation examples and setup details. Note that entities like repeaters and contact GPS
|
||||
won't update until new data is available; there is no caching layer (so devices/entities
|
||||
might take hours to days to appear).
|
||||
</p>
|
||||
|
||||
<details className="group">
|
||||
<summary className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium cursor-pointer select-none flex items-center gap-1">
|
||||
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
|
||||
What gets created in Home Assistant
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2 text-xs text-muted-foreground rounded-md border border-border bg-muted/20 p-3">
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Local radio device</span> (always)
|
||||
<span className="ml-1">— updates every 60s</span>
|
||||
<ul className="mt-0.5 ml-4 list-disc space-y-0.5">
|
||||
<li>
|
||||
<code className="text-[0.6875rem]">binary_sensor.meshcore_*_connected</code> —
|
||||
radio online/offline
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-[0.6875rem]">sensor.meshcore_*_noise_floor</code> —
|
||||
radio noise floor (dBm)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Per tracked repeater</span> —
|
||||
updates on telemetry collect cycle (~8h) or manual dashboard fetch
|
||||
<ul className="mt-0.5 ml-4 list-disc space-y-0.5">
|
||||
<li>
|
||||
<code className="text-[0.6875rem]">sensor.meshcore_*_battery_voltage</code> (V)
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-[0.6875rem]">sensor.meshcore_*_noise_floor</code>,{' '}
|
||||
<code className="text-[0.6875rem]">*_last_rssi</code>,{' '}
|
||||
<code className="text-[0.6875rem]">*_last_snr</code> (dBm/dB)
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-[0.6875rem]">sensor.meshcore_*_packets_received</code>,{' '}
|
||||
<code className="text-[0.6875rem]">*_packets_sent</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-[0.6875rem]">sensor.meshcore_*_uptime</code> (seconds)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Per tracked contact</span> — updates
|
||||
passively when advertisements with GPS are heard
|
||||
<ul className="mt-0.5 ml-4 list-disc space-y-0.5">
|
||||
<li>
|
||||
<code className="text-[0.6875rem]">device_tracker.meshcore_*</code> —
|
||||
latitude/longitude
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Message events</span> — fires for
|
||||
each message matching the scope below
|
||||
<ul className="mt-0.5 ml-4 list-disc space-y-0.5">
|
||||
<li>
|
||||
<code className="text-[0.6875rem]">event.meshcore_messages</code> — trigger
|
||||
automations on sender, channel, or message content
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p className="text-[0.6875rem] mt-1.5">
|
||||
Entity IDs use the first 12 characters of the node's public key. Entities are
|
||||
removed from HA when this integration is disabled or deleted. State topics are published
|
||||
under{' '}
|
||||
<code className="text-[0.6875rem]">{prefix}/<node_id>/health|telemetry|gps</code>.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<Separator />
|
||||
|
||||
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
MQTT Broker
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-ha-host">Broker Host</Label>
|
||||
<Input
|
||||
id="fanout-ha-host"
|
||||
type="text"
|
||||
placeholder="e.g. 192.168.1.100"
|
||||
value={(config.broker_host as string) || ''}
|
||||
onChange={(e) => onChange({ ...config, broker_host: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-ha-port">Broker Port</Label>
|
||||
<Input
|
||||
id="fanout-ha-port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={getNumberInputValue(config.broker_port, 1883)}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, broker_port: parseIntegerInputValue(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-ha-user">Username</Label>
|
||||
<Input
|
||||
id="fanout-ha-user"
|
||||
type="text"
|
||||
placeholder="Optional"
|
||||
value={(config.username as string) || ''}
|
||||
onChange={(e) => onChange({ ...config, username: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-ha-pass">Password</Label>
|
||||
<Input
|
||||
id="fanout-ha-pass"
|
||||
type="password"
|
||||
placeholder="Optional"
|
||||
value={(config.password as string) || ''}
|
||||
onChange={(e) => onChange({ ...config, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!config.use_tls}
|
||||
onChange={(e) => onChange({ ...config, use_tls: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm">Use TLS</span>
|
||||
</label>
|
||||
|
||||
{!!config.use_tls && (
|
||||
<label className="flex items-center gap-3 cursor-pointer ml-7">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!config.tls_insecure}
|
||||
onChange={(e) => onChange({ ...config, tls_insecure: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm">Skip certificate verification</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-ha-prefix">Topic Prefix</Label>
|
||||
<Input
|
||||
id="fanout-ha-prefix"
|
||||
type="text"
|
||||
placeholder="meshcore"
|
||||
value={(config.topic_prefix as string | undefined) ?? ''}
|
||||
onChange={(e) => onChange({ ...config, topic_prefix: e.target.value })}
|
||||
/>
|
||||
<p className="text-[0.6875rem] text-muted-foreground">
|
||||
State updates publish under <code className="text-[0.6875rem]">{prefix}/</code>. Discovery
|
||||
configs always use the <code className="text-[0.6875rem]">homeassistant/</code> prefix.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
GPS Tracked Contacts
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Each selected contact becomes a <code className="text-[0.6875rem]">device_tracker</code>{' '}
|
||||
in HA, updated whenever an advertisement with GPS coordinates is heard. Useful for
|
||||
tracking mobile nodes on an HA map dashboard.
|
||||
</p>
|
||||
|
||||
{selectedContactDetails.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedContactDetails.map((c) => (
|
||||
<span
|
||||
key={c.public_key}
|
||||
className="inline-flex items-center gap-1 text-[0.6875rem] px-2 py-0.5 rounded-full bg-primary/10 text-primary"
|
||||
>
|
||||
{c.name || c.public_key.slice(0, 12)}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 hover:text-destructive transition-colors"
|
||||
onClick={() => toggleTrackedContact(c.public_key)}
|
||||
aria-label={`Remove ${c.name || c.public_key.slice(0, 12)}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contactOptions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">No contacts available.</p>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={`Search ${contactOptions.length} contacts...`}
|
||||
value={contactSearch}
|
||||
onChange={(e) => setContactSearch(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto space-y-1 rounded border border-border p-2">
|
||||
{filteredContacts.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic py-1">
|
||||
No contacts match “{contactSearch}”
|
||||
</p>
|
||||
) : (
|
||||
filteredContacts.map((c) => (
|
||||
<label
|
||||
key={c.public_key}
|
||||
className="flex items-center gap-2 cursor-pointer text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedContacts.includes(c.public_key)}
|
||||
onChange={() => toggleTrackedContact(c.public_key)}
|
||||
className="h-3.5 w-3.5 rounded border-border"
|
||||
/>
|
||||
<span className="truncate">{c.name || c.public_key.slice(0, 12)}</span>
|
||||
<span className="text-[0.625rem] text-muted-foreground ml-auto font-mono shrink-0">
|
||||
{c.public_key.slice(0, 12)}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Telemetry Tracked Repeaters
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Each selected repeater becomes an HA device with sensors for battery voltage, RSSI, SNR,
|
||||
noise floor, packet counts, and uptime. Data updates whenever telemetry is collected
|
||||
(auto-collect runs every ~8 hours, or on manual dashboard fetch). Only repeaters already
|
||||
in the auto-telemetry tracking list appear here (add new repeaters by logging into the
|
||||
repeater and opting in at the bottom of the page).
|
||||
</p>
|
||||
{trackedRepeaters.length === 0 ? (
|
||||
<div className="rounded-md border border-muted bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
No repeaters are being auto-tracked for telemetry. Add repeaters to the auto-telemetry
|
||||
tracking list in the Radio section first, then return here to select which ones to
|
||||
expose to HA.
|
||||
</div>
|
||||
) : repeaterOptions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Auto-tracked repeaters not found in contact list.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-40 overflow-y-auto space-y-1 rounded border border-border p-2">
|
||||
{repeaterOptions.map((c) => (
|
||||
<label key={c.public_key} className="flex items-center gap-2 cursor-pointer text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRepeaters.includes(c.public_key)}
|
||||
onChange={() => toggleTrackedRepeater(c.public_key)}
|
||||
className="h-3.5 w-3.5 rounded border-border"
|
||||
/>
|
||||
<span className="truncate">{c.name || c.public_key.slice(0, 12)}</span>
|
||||
<span className="text-[0.625rem] text-muted-foreground ml-auto font-mono">
|
||||
{c.public_key.slice(0, 12)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Message Events
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Matching messages fire an{' '}
|
||||
<code className="text-[0.6875rem]">event.meshcore_messages</code> entity in HA with
|
||||
sender, text, channel, and direction attributes. Use HA automations to trigger actions on
|
||||
specific messages, channels, or contacts.
|
||||
</p>
|
||||
</div>
|
||||
<ScopeSelector scope={scope} onChange={onScopeChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MqttCommunityConfigEditor({
|
||||
config,
|
||||
onChange,
|
||||
@@ -2185,6 +2646,15 @@ export function SettingsFanoutSection({
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailType === 'mqtt_ha' && (
|
||||
<MqttHaConfigEditor
|
||||
config={editConfig}
|
||||
scope={editScope}
|
||||
onChange={setEditConfig}
|
||||
onScopeChange={setEditScope}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailType === 'mqtt_community' && (
|
||||
<MqttCommunityConfigEditor config={editConfig} onChange={setEditConfig} />
|
||||
)}
|
||||
|
||||
@@ -118,7 +118,7 @@ describe('SettingsFanoutSection', () => {
|
||||
const optionButtons = within(dialog)
|
||||
.getAllByRole('button')
|
||||
.filter((button) => button.hasAttribute('aria-pressed'));
|
||||
expect(optionButtons).toHaveLength(10);
|
||||
expect(optionButtons).toHaveLength(11);
|
||||
expect(within(dialog).getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole('button', { name: 'Create' })).toBeInTheDocument();
|
||||
expect(
|
||||
|
||||
210
scripts/setup/start_ha_test_env.sh
Normal file
210
scripts/setup/start_ha_test_env.sh
Normal file
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start a fresh Home Assistant + Mosquitto environment for testing the
|
||||
# mqtt_ha fanout integration. Runs everything on the host network so
|
||||
# RemoteTerm (running locally) can reach the broker at localhost:1883.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/setup/start_ha_test_env.sh
|
||||
#
|
||||
# After this script completes:
|
||||
# 1. HA is at http://localhost:8123 (login: dev / dev)
|
||||
# 2. Mosquitto is at localhost:1883 (no auth)
|
||||
# 3. HA's MQTT integration is configured and connected to Mosquitto
|
||||
#
|
||||
# Then in RemoteTerm:
|
||||
# Settings > Integrations > Add > Home Assistant
|
||||
# Broker Host: 127.0.0.1 Port: 1883
|
||||
# Select contacts/repeaters and save.
|
||||
#
|
||||
# To tear down:
|
||||
# ./scripts/setup/stop_ha_test_env.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
HA_CONFIG="$REPO_ROOT/ha_test_config"
|
||||
|
||||
echo "==> Stopping any existing test containers..."
|
||||
docker rm -f ha-test-mosquitto 2>/dev/null || true
|
||||
docker rm -f ha-test-homeassistant 2>/dev/null || true
|
||||
|
||||
echo "==> Wiping HA config for fresh start..."
|
||||
sudo rm -rf "$HA_CONFIG"
|
||||
mkdir -p "$HA_CONFIG"
|
||||
|
||||
# ── Mosquitto ─────────────────────────────────────────────────────────────
|
||||
|
||||
echo "==> Starting Mosquitto (port 1883, no auth)..."
|
||||
MOSQUITTO_CONF=$(mktemp)
|
||||
cat > "$MOSQUITTO_CONF" << 'MQTTEOF'
|
||||
listener 1883 0.0.0.0
|
||||
allow_anonymous true
|
||||
MQTTEOF
|
||||
|
||||
docker run -d \
|
||||
--name ha-test-mosquitto \
|
||||
--network host \
|
||||
-v "$MOSQUITTO_CONF:/mosquitto/config/mosquitto.conf:ro" \
|
||||
eclipse-mosquitto:2
|
||||
|
||||
# Give Mosquitto a moment to bind
|
||||
sleep 2
|
||||
rm -f "$MOSQUITTO_CONF"
|
||||
|
||||
# ── Home Assistant ────────────────────────────────────────────────────────
|
||||
|
||||
echo "==> Starting Home Assistant (port 8123)..."
|
||||
docker run -d \
|
||||
--name ha-test-homeassistant \
|
||||
--network host \
|
||||
-v "$HA_CONFIG:/config" \
|
||||
ghcr.io/home-assistant/home-assistant:stable
|
||||
|
||||
echo "==> Waiting for HA to boot..."
|
||||
for i in $(seq 1 90); do
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8123/api/onboarding/users 2>/dev/null || echo "000")
|
||||
if echo "$HTTP_CODE" | grep -q '200\|405'; then
|
||||
echo " HA is up after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 90 ]; then
|
||||
echo " ERROR: HA did not start within 90s"
|
||||
echo " Check: docker logs ha-test-homeassistant"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# ── Onboarding ────────────────────────────────────────────────────────────
|
||||
|
||||
echo "==> Running onboarding (user: dev / pass: dev)..."
|
||||
ONBOARD_RESP=$(curl -s -X POST http://localhost:8123/api/onboarding/users \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"client_id":"http://localhost:8123/","name":"Dev","username":"dev","password":"dev","language":"en"}')
|
||||
|
||||
AUTH_CODE=$(echo "$ONBOARD_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('auth_code',''))" 2>/dev/null || echo "")
|
||||
if [ -z "$AUTH_CODE" ]; then
|
||||
echo " WARNING: Could not extract auth_code from onboarding. HA may already be onboarded."
|
||||
echo " Response: $ONBOARD_RESP"
|
||||
echo ""
|
||||
echo " Skipping MQTT auto-config. Configure MQTT manually:"
|
||||
echo " Settings > Devices & Services > Add Integration > MQTT"
|
||||
echo " Broker: 127.0.0.1 Port: 1883"
|
||||
echo ""
|
||||
echo "==> Done! Open http://localhost:8123"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Exchange auth code for tokens
|
||||
echo "==> Exchanging auth code for access token..."
|
||||
TOKEN_RESP=$(curl -s -X POST http://localhost:8123/auth/token \
|
||||
-d "grant_type=authorization_code&code=$AUTH_CODE&client_id=http://localhost:8123/")
|
||||
|
||||
ACCESS_TOKEN=$(echo "$TOKEN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || echo "")
|
||||
if [ -z "$ACCESS_TOKEN" ]; then
|
||||
echo " WARNING: Could not get access token."
|
||||
echo " Configure MQTT manually: Settings > Devices & Services > Add Integration > MQTT"
|
||||
echo " Broker: 127.0.0.1 Port: 1883"
|
||||
echo ""
|
||||
echo "==> Done! Open http://localhost:8123 and log in as dev/dev"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Complete remaining onboarding steps
|
||||
echo "==> Completing onboarding steps..."
|
||||
curl -s -X POST http://localhost:8123/api/onboarding/core_config \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}' > /dev/null 2>&1 || true
|
||||
|
||||
curl -s -X POST http://localhost:8123/api/onboarding/analytics \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}' > /dev/null 2>&1 || true
|
||||
|
||||
curl -s -X POST http://localhost:8123/api/onboarding/integration \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}' > /dev/null 2>&1 || true
|
||||
|
||||
# ── Configure MQTT integration ───────────────────────────────────────────
|
||||
|
||||
echo "==> Adding MQTT integration (broker: 127.0.0.1:1883)..."
|
||||
FLOW_RESP=$(curl -s -X POST http://localhost:8123/api/config/config_entries/flow \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"handler":"mqtt"}')
|
||||
|
||||
FLOW_ID=$(echo "$FLOW_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('flow_id',''))" 2>/dev/null || echo "")
|
||||
if [ -z "$FLOW_ID" ]; then
|
||||
echo " WARNING: Could not start MQTT config flow."
|
||||
echo " Response: $FLOW_RESP"
|
||||
echo " Configure MQTT manually: Settings > Devices & Services > Add Integration > MQTT"
|
||||
echo " Broker: 127.0.0.1 Port: 1883"
|
||||
else
|
||||
MQTT_RESULT=$(curl -s -X POST "http://localhost:8123/api/config/config_entries/flow/$FLOW_ID" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"broker":"127.0.0.1","port":1883,"username":"","password":""}')
|
||||
|
||||
RESULT_TYPE=$(echo "$MQTT_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('type',''))" 2>/dev/null || echo "")
|
||||
if [ "$RESULT_TYPE" = "create_entry" ]; then
|
||||
echo " MQTT integration configured successfully."
|
||||
else
|
||||
echo " WARNING: MQTT config flow returned: $RESULT_TYPE"
|
||||
echo " Response: $MQTT_RESULT"
|
||||
echo " You may need to configure MQTT manually."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Debug logging ─────────────────────────────────────────────────────────
|
||||
|
||||
echo "==> Enabling MQTT debug logging..."
|
||||
sudo tee -a "$HA_CONFIG/configuration.yaml" > /dev/null << 'EOF'
|
||||
|
||||
logger:
|
||||
default: warning
|
||||
logs:
|
||||
homeassistant.components.mqtt: debug
|
||||
EOF
|
||||
|
||||
# Gracefully stop the backgrounded HA so it flushes config to disk
|
||||
# (docker rm -f sends SIGKILL which loses in-memory state like the MQTT config entry)
|
||||
echo "==> Stopping background HA (graceful, flushing config)..."
|
||||
docker stop ha-test-homeassistant > /dev/null 2>&1
|
||||
docker rm ha-test-homeassistant > /dev/null 2>&1
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "============================================================"
|
||||
echo " HA test environment ready!"
|
||||
echo "============================================================"
|
||||
echo ""
|
||||
echo " Home Assistant: http://localhost:8123 (dev / dev)"
|
||||
echo " Mosquitto: localhost:1883 (no auth)"
|
||||
echo " MQTT integration: pre-configured"
|
||||
echo ""
|
||||
echo " Next steps:"
|
||||
echo " 1. Start RemoteTerm as usual"
|
||||
echo " 2. In RemoteTerm: Settings > Integrations > Add > Home Assistant"
|
||||
echo " 3. Set Broker Host: 127.0.0.1, Port: 1883"
|
||||
echo " 4. Select contacts for GPS tracking and/or repeaters for telemetry"
|
||||
echo " 5. Save and enable"
|
||||
echo " 6. In HA: Settings > Devices & Services > MQTT"
|
||||
echo " You should see MeshCore devices appearing automatically"
|
||||
echo ""
|
||||
echo " MQTT debug tool (in another terminal):"
|
||||
echo " docker exec ha-test-mosquitto mosquitto_sub -h 127.0.0.1 -t '#' -v"
|
||||
echo ""
|
||||
echo " Tear down: Ctrl+C here, then ./scripts/setup/stop_ha_test_env.sh"
|
||||
echo ""
|
||||
echo "==> Starting Home Assistant in foreground (Ctrl+C to stop)..."
|
||||
echo ""
|
||||
|
||||
exec docker run --rm \
|
||||
--name ha-test-homeassistant \
|
||||
--network host \
|
||||
-v "$HA_CONFIG:/config" \
|
||||
ghcr.io/home-assistant/home-assistant:stable
|
||||
29
scripts/setup/stop_ha_test_env.sh
Normal file
29
scripts/setup/stop_ha_test_env.sh
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stop and remove the HA + Mosquitto test containers.
|
||||
# Optionally pass --clean to also wipe the HA config directory.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/setup/stop_ha_test_env.sh # stop containers only
|
||||
# ./scripts/setup/stop_ha_test_env.sh --clean # stop + wipe ha_test_config
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
HA_CONFIG="$REPO_ROOT/ha_test_config"
|
||||
|
||||
echo "==> Stopping test containers..."
|
||||
docker rm -f ha-test-mosquitto 2>/dev/null && echo " Removed ha-test-mosquitto" || echo " ha-test-mosquitto not running"
|
||||
docker rm -f ha-test-homeassistant 2>/dev/null && echo " Removed ha-test-homeassistant" || echo " ha-test-homeassistant not running"
|
||||
|
||||
if [ "${1:-}" = "--clean" ]; then
|
||||
echo "==> Wiping $HA_CONFIG..."
|
||||
sudo rm -rf "$HA_CONFIG"
|
||||
echo " Done."
|
||||
else
|
||||
echo ""
|
||||
echo " HA config preserved at $HA_CONFIG"
|
||||
echo " Run with --clean to remove it."
|
||||
fi
|
||||
|
||||
echo "==> Done."
|
||||
481
tests/test_mqtt_ha.py
Normal file
481
tests/test_mqtt_ha.py
Normal file
@@ -0,0 +1,481 @@
|
||||
"""Tests for the Home Assistant MQTT Discovery fanout module."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.fanout.mqtt_ha import (
|
||||
MqttHaModule,
|
||||
_contact_tracker_discovery_config,
|
||||
_device_payload,
|
||||
_message_event_discovery_config,
|
||||
_node_id,
|
||||
_radio_discovery_configs,
|
||||
_repeater_discovery_configs,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _base_config(**overrides) -> dict:
|
||||
cfg = {
|
||||
"broker_host": "127.0.0.1",
|
||||
"broker_port": 1883,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"use_tls": False,
|
||||
"tls_insecure": False,
|
||||
"topic_prefix": "meshcore",
|
||||
"tracked_contacts": [],
|
||||
"tracked_repeaters": [],
|
||||
}
|
||||
cfg.update(overrides)
|
||||
return cfg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit: node_id and device_payload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNodeId:
|
||||
def test_returns_12_char_prefix(self):
|
||||
assert _node_id("aabbccddeeff11223344") == "aabbccddeeff"
|
||||
|
||||
def test_lowercases(self):
|
||||
assert _node_id("AABBCCDDEEFF") == "aabbccddeeff"
|
||||
|
||||
|
||||
class TestDevicePayload:
|
||||
def test_basic(self):
|
||||
dev = _device_payload("aabbccddeeff1122", "MyRadio", "Radio")
|
||||
assert dev["identifiers"] == ["meshcore_aabbccddeeff"]
|
||||
assert dev["name"] == "MyRadio"
|
||||
assert dev["manufacturer"] == "MeshCore"
|
||||
assert dev["model"] == "Radio"
|
||||
assert "via_device" not in dev
|
||||
|
||||
def test_via_device(self):
|
||||
dev = _device_payload("ccdd", "Repeater1", "Repeater", via_device_key="aabb")
|
||||
assert dev["via_device"] == "meshcore_aabb"
|
||||
|
||||
def test_name_fallback(self):
|
||||
dev = _device_payload("aabbccddeeff", "", "Radio")
|
||||
assert dev["name"] == "aabbccddeeff"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit: discovery config builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRadioDiscovery:
|
||||
def test_produces_discovery_configs(self):
|
||||
configs = _radio_discovery_configs("meshcore", "aabbccddeeff1122", "MyRadio")
|
||||
# 1 binary_sensor (connected) + 9 sensors from _RADIO_SENSORS
|
||||
assert len(configs) == 10
|
||||
|
||||
topics = [t for t, _ in configs]
|
||||
assert "homeassistant/binary_sensor/meshcore_aabbccddeeff/connected/config" in topics
|
||||
assert "homeassistant/sensor/meshcore_aabbccddeeff/noise_floor/config" in topics
|
||||
assert "homeassistant/sensor/meshcore_aabbccddeeff/battery/config" in topics
|
||||
assert "homeassistant/sensor/meshcore_aabbccddeeff/uptime/config" in topics
|
||||
assert "homeassistant/sensor/meshcore_aabbccddeeff/last_rssi/config" in topics
|
||||
assert "homeassistant/sensor/meshcore_aabbccddeeff/last_snr/config" in topics
|
||||
assert "homeassistant/sensor/meshcore_aabbccddeeff/packets_received/config" in topics
|
||||
assert "homeassistant/sensor/meshcore_aabbccddeeff/packets_sent/config" in topics
|
||||
|
||||
def test_connected_binary_sensor_shape(self):
|
||||
configs = _radio_discovery_configs("mc", "aabbccddeeff", "R")
|
||||
topic, cfg = configs[0]
|
||||
assert cfg["device_class"] == "connectivity"
|
||||
assert cfg["state_topic"] == "mc/aabbccddeeff/health"
|
||||
assert cfg["unique_id"] == "meshcore_aabbccddeeff_connected"
|
||||
assert cfg["expire_after"] == 120
|
||||
|
||||
def test_sensor_configs_have_expire_after(self):
|
||||
configs = _radio_discovery_configs("mc", "aabbccddeeff", "R")
|
||||
# All sensor configs (skip the binary_sensor at index 0)
|
||||
for _, cfg in configs[1:]:
|
||||
assert cfg["expire_after"] == 120
|
||||
|
||||
|
||||
class TestRepeaterDiscovery:
|
||||
def test_produces_sensor_per_field(self):
|
||||
configs = _repeater_discovery_configs("mc", "ccdd11223344", "Rep1", "aabb")
|
||||
assert len(configs) == 7 # matches _REPEATER_SENSORS length
|
||||
|
||||
topics = [t for t, _ in configs]
|
||||
assert "homeassistant/sensor/meshcore_ccdd11223344/battery_voltage/config" in topics
|
||||
assert "homeassistant/sensor/meshcore_ccdd11223344/uptime/config" in topics
|
||||
|
||||
def test_via_device_set(self):
|
||||
configs = _repeater_discovery_configs("mc", "ccdd", "Rep1", "aabb")
|
||||
_, cfg = configs[0]
|
||||
assert cfg["device"]["via_device"] == "meshcore_aabb"
|
||||
|
||||
def test_sensors_have_expire_after(self):
|
||||
configs = _repeater_discovery_configs("mc", "ccdd", "Rep1", None)
|
||||
for _, cfg in configs:
|
||||
assert cfg["expire_after"] == 36000
|
||||
|
||||
|
||||
class TestContactTrackerDiscovery:
|
||||
def test_config_shape(self):
|
||||
topic, cfg = _contact_tracker_discovery_config("mc", "eeff11223344", "Alice", "aabb")
|
||||
assert topic == "homeassistant/device_tracker/meshcore_eeff11223344/config"
|
||||
assert cfg["unique_id"] == "meshcore_eeff11223344_tracker"
|
||||
assert cfg["source_type"] == "gps"
|
||||
assert cfg["json_attributes_topic"] == "mc/eeff11223344/gps"
|
||||
assert "state_topic" not in cfg
|
||||
|
||||
|
||||
class TestMessageEventDiscovery:
|
||||
def test_config_shape(self):
|
||||
topic, cfg = _message_event_discovery_config("mc", "aabbccddeeff", "MyRadio")
|
||||
assert topic == "homeassistant/event/meshcore_aabbccddeeff/messages/config"
|
||||
assert "message_received" in cfg["event_types"]
|
||||
assert cfg["state_topic"] == "mc/aabbccddeeff/events/message"
|
||||
assert cfg["unique_id"] == "meshcore_aabbccddeeff_messages"
|
||||
assert cfg["device"]["identifiers"] == ["meshcore_aabbccddeeff"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module: filtering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMqttHaFiltering:
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_contact_ignores_untracked(self):
|
||||
mod = MqttHaModule("test", _base_config(tracked_contacts=["aaaa"]))
|
||||
mod._publisher = MagicMock()
|
||||
mod._publisher.connected = True
|
||||
mod._publisher.publish = AsyncMock()
|
||||
|
||||
await mod.on_contact({"public_key": "bbbb", "lat": 1.0, "lon": 2.0})
|
||||
|
||||
mod._publisher.publish.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_contact_publishes_tracked(self):
|
||||
key = "aabbccddeeff"
|
||||
mod = MqttHaModule("test", _base_config(tracked_contacts=[key]))
|
||||
mod._publisher = MagicMock()
|
||||
mod._publisher.connected = True
|
||||
mod._publisher.publish = AsyncMock()
|
||||
|
||||
await mod.on_contact({"public_key": key, "lat": 37.7, "lon": -122.4})
|
||||
|
||||
mod._publisher.publish.assert_called_once()
|
||||
topic = mod._publisher.publish.call_args[0][0]
|
||||
payload = mod._publisher.publish.call_args[0][1]
|
||||
assert topic == f"meshcore/{_node_id(key)}/gps"
|
||||
assert payload["latitude"] == 37.7
|
||||
assert payload["longitude"] == -122.4
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_contact_skips_zero_gps(self):
|
||||
key = "aabbccddeeff"
|
||||
mod = MqttHaModule("test", _base_config(tracked_contacts=[key]))
|
||||
mod._publisher = MagicMock()
|
||||
mod._publisher.connected = True
|
||||
mod._publisher.publish = AsyncMock()
|
||||
|
||||
await mod.on_contact({"public_key": key, "lat": 0.0, "lon": 0.0})
|
||||
|
||||
mod._publisher.publish.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_telemetry_ignores_untracked(self):
|
||||
mod = MqttHaModule("test", _base_config(tracked_repeaters=["aaaa"]))
|
||||
mod._publisher = MagicMock()
|
||||
mod._publisher.connected = True
|
||||
mod._publisher.publish = AsyncMock()
|
||||
|
||||
await mod.on_telemetry({"public_key": "bbbb", "battery_volts": 4.1})
|
||||
|
||||
mod._publisher.publish.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_telemetry_publishes_tracked(self):
|
||||
key = "ccdd11223344"
|
||||
mod = MqttHaModule("test", _base_config(tracked_repeaters=[key]))
|
||||
mod._publisher = MagicMock()
|
||||
mod._publisher.connected = True
|
||||
mod._publisher.publish = AsyncMock()
|
||||
|
||||
await mod.on_telemetry(
|
||||
{
|
||||
"public_key": key,
|
||||
"battery_volts": 4.1,
|
||||
"noise_floor_dbm": -112,
|
||||
"last_rssi_dbm": -80,
|
||||
"last_snr_db": 10.0,
|
||||
"packets_received": 500,
|
||||
"packets_sent": 300,
|
||||
"uptime_seconds": 86400,
|
||||
}
|
||||
)
|
||||
|
||||
mod._publisher.publish.assert_called_once()
|
||||
topic = mod._publisher.publish.call_args[0][0]
|
||||
payload = mod._publisher.publish.call_args[0][1]
|
||||
assert topic == f"meshcore/{_node_id(key)}/telemetry"
|
||||
assert payload["battery_volts"] == 4.1
|
||||
assert payload["uptime_seconds"] == 86400
|
||||
|
||||
|
||||
class TestMqttHaHealth:
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_health_publishes_state(self):
|
||||
mod = MqttHaModule("test", _base_config())
|
||||
mod._publisher = MagicMock()
|
||||
mod._publisher.connected = True
|
||||
mod._publisher.publish = AsyncMock()
|
||||
mod._radio_key = "aabbccddeeff"
|
||||
|
||||
await mod.on_health(
|
||||
{
|
||||
"connected": True,
|
||||
"public_key": "aabbccddeeff",
|
||||
"name": "MyRadio",
|
||||
"noise_floor_dbm": -110,
|
||||
"battery_mv": 4150,
|
||||
"uptime_secs": 3600,
|
||||
"last_rssi": -85,
|
||||
"last_snr": 9.5,
|
||||
"packets_recv": 500,
|
||||
"packets_sent": 250,
|
||||
}
|
||||
)
|
||||
|
||||
# Should publish health state
|
||||
calls = mod._publisher.publish.call_args_list
|
||||
# Last call should be health state (discovery may also be published)
|
||||
health_calls = [c for c in calls if "/health" in c[0][0]]
|
||||
assert len(health_calls) >= 1
|
||||
payload = health_calls[-1][0][1]
|
||||
assert payload["connected"] is True
|
||||
assert payload["noise_floor_dbm"] == -110
|
||||
assert payload["battery_mv"] == 4150
|
||||
assert payload["uptime_secs"] == 3600
|
||||
assert payload["last_rssi"] == -85
|
||||
assert payload["packets_recv"] == 500
|
||||
assert payload["packets_sent"] == 250
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_health_caches_radio_key(self):
|
||||
mod = MqttHaModule("test", _base_config())
|
||||
mod._publisher = MagicMock()
|
||||
mod._publisher.connected = True
|
||||
mod._publisher.publish = AsyncMock()
|
||||
|
||||
await mod.on_health(
|
||||
{
|
||||
"connected": True,
|
||||
"public_key": "aabbccddeeff",
|
||||
"name": "MyRadio",
|
||||
"noise_floor_dbm": None,
|
||||
}
|
||||
)
|
||||
|
||||
assert mod._radio_key == "aabbccddeeff"
|
||||
assert mod._radio_name == "MyRadio"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_health_key_change_clears_all_existing_discovery_topics(self):
|
||||
mod = MqttHaModule("test", _base_config())
|
||||
mod._publisher = MagicMock()
|
||||
mod._publisher.connected = True
|
||||
mod._publisher.publish = AsyncMock()
|
||||
mod._radio_key = "aabbccddeeff"
|
||||
mod._radio_name = "OldRadio"
|
||||
mod._discovery_topics = [
|
||||
"homeassistant/sensor/meshcore_aabbccddeeff/noise_floor/config",
|
||||
"homeassistant/event/meshcore_aabbccddeeff/messages/config",
|
||||
"homeassistant/device_tracker/meshcore_ccdd11223344/config",
|
||||
"homeassistant/sensor/meshcore_eeff11223344/battery_voltage/config",
|
||||
]
|
||||
|
||||
mod._clear_retained_topics = AsyncMock()
|
||||
|
||||
async def publish_discovery_side_effect():
|
||||
assert mod._discovery_topics == []
|
||||
mod._discovery_topics = [
|
||||
"homeassistant/sensor/meshcore_112233445566/noise_floor/config",
|
||||
"homeassistant/event/meshcore_112233445566/messages/config",
|
||||
]
|
||||
|
||||
mod._publish_discovery = AsyncMock(side_effect=publish_discovery_side_effect)
|
||||
|
||||
await mod.on_health(
|
||||
{
|
||||
"connected": True,
|
||||
"public_key": "112233445566",
|
||||
"name": "NewRadio",
|
||||
}
|
||||
)
|
||||
|
||||
mod._clear_retained_topics.assert_awaited_once_with(
|
||||
[
|
||||
"homeassistant/sensor/meshcore_aabbccddeeff/noise_floor/config",
|
||||
"homeassistant/event/meshcore_aabbccddeeff/messages/config",
|
||||
"homeassistant/device_tracker/meshcore_ccdd11223344/config",
|
||||
"homeassistant/sensor/meshcore_eeff11223344/battery_voltage/config",
|
||||
]
|
||||
)
|
||||
mod._publish_discovery.assert_awaited_once()
|
||||
assert mod._radio_key == "112233445566"
|
||||
assert mod._radio_name == "NewRadio"
|
||||
assert mod._discovery_topics == [
|
||||
"homeassistant/sensor/meshcore_112233445566/noise_floor/config",
|
||||
"homeassistant/event/meshcore_112233445566/messages/config",
|
||||
]
|
||||
|
||||
|
||||
class TestMqttHaLifecycle:
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_seeds_radio_identity_from_connected_runtime(self, monkeypatch):
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
|
||||
monkeypatch.setattr(
|
||||
radio_runtime.manager,
|
||||
"_meshcore",
|
||||
SimpleNamespace(
|
||||
is_connected=True,
|
||||
self_info={"public_key": "AABBCCDDEEFF", "name": "MyRadio"},
|
||||
),
|
||||
)
|
||||
|
||||
mod = MqttHaModule("test", _base_config())
|
||||
mod._publisher = MagicMock()
|
||||
mod._publisher.start = AsyncMock()
|
||||
|
||||
await mod.start()
|
||||
|
||||
assert mod._radio_key == "aabbccddeeff"
|
||||
assert mod._radio_name == "MyRadio"
|
||||
mod._publisher.start.assert_awaited_once()
|
||||
|
||||
|
||||
class TestMqttHaMessage:
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_publishes_event(self):
|
||||
mod = MqttHaModule("test", _base_config())
|
||||
mod._publisher = MagicMock()
|
||||
mod._publisher.connected = True
|
||||
mod._publisher.publish = AsyncMock()
|
||||
mod._radio_key = "aabbccddeeff"
|
||||
|
||||
await mod.on_message(
|
||||
{
|
||||
"type": "PRIV",
|
||||
"conversation_key": "pk1",
|
||||
"text": "hello",
|
||||
"sender_name": "Alice",
|
||||
"sender_key": "aabb",
|
||||
"outgoing": False,
|
||||
}
|
||||
)
|
||||
|
||||
mod._publisher.publish.assert_called_once()
|
||||
topic = mod._publisher.publish.call_args[0][0]
|
||||
payload = mod._publisher.publish.call_args[0][1]
|
||||
assert topic == "meshcore/aabbccddeeff/events/message"
|
||||
assert payload["event_type"] == "message_received"
|
||||
assert payload["text"] == "hello"
|
||||
assert payload["sender_name"] == "Alice"
|
||||
assert payload["outgoing"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_skips_without_radio_key(self):
|
||||
mod = MqttHaModule("test", _base_config())
|
||||
mod._publisher = MagicMock()
|
||||
mod._publisher.connected = True
|
||||
mod._publisher.publish = AsyncMock()
|
||||
# _radio_key is None — should not publish
|
||||
await mod.on_message({"type": "PRIV", "text": "hi", "sender_name": "X"})
|
||||
mod._publisher.publish.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_strips_channel_sender_prefix(self):
|
||||
mod = MqttHaModule("test", _base_config())
|
||||
mod._publisher = MagicMock()
|
||||
mod._publisher.connected = True
|
||||
mod._publisher.publish = AsyncMock()
|
||||
mod._radio_key = "aabbccddeeff"
|
||||
|
||||
await mod.on_message(
|
||||
{
|
||||
"type": "CHAN",
|
||||
"conversation_key": "ch1",
|
||||
"text": "Alice: hello from channel",
|
||||
"sender_name": "Alice",
|
||||
"sender_key": "aabb",
|
||||
"outgoing": False,
|
||||
}
|
||||
)
|
||||
|
||||
payload = mod._publisher.publish.call_args[0][1]
|
||||
assert payload["text"] == "hello from channel"
|
||||
|
||||
|
||||
class TestMqttHaStatus:
|
||||
def test_disconnected_without_host(self):
|
||||
mod = MqttHaModule("test", _base_config(broker_host=""))
|
||||
assert mod.status == "disconnected"
|
||||
|
||||
def test_disconnected_with_host_but_not_connected(self):
|
||||
mod = MqttHaModule("test", _base_config(broker_host="192.168.1.1"))
|
||||
assert mod.status == "disconnected"
|
||||
|
||||
def test_error_when_publisher_has_error(self):
|
||||
mod = MqttHaModule("test", _base_config(broker_host="192.168.1.1"))
|
||||
mod._publisher._last_error = "connection refused"
|
||||
assert mod.status == "error"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMqttHaValidation:
|
||||
def test_valid_config_passes(self):
|
||||
from app.routers.fanout import _validate_mqtt_ha_config
|
||||
|
||||
_validate_mqtt_ha_config({"broker_host": "192.168.1.1", "broker_port": 1883})
|
||||
|
||||
def test_missing_host_fails(self):
|
||||
from app.routers.fanout import _validate_mqtt_ha_config
|
||||
|
||||
with pytest.raises(Exception, match="broker_host"):
|
||||
_validate_mqtt_ha_config({"broker_host": ""})
|
||||
|
||||
def test_bad_port_fails(self):
|
||||
from app.routers.fanout import _validate_mqtt_ha_config
|
||||
|
||||
with pytest.raises(Exception, match="broker_port"):
|
||||
_validate_mqtt_ha_config({"broker_host": "x", "broker_port": 99999})
|
||||
|
||||
def test_tracked_contacts_must_be_list(self):
|
||||
from app.routers.fanout import _validate_mqtt_ha_config
|
||||
|
||||
with pytest.raises(Exception, match="tracked_contacts"):
|
||||
_validate_mqtt_ha_config(
|
||||
{
|
||||
"broker_host": "x",
|
||||
"tracked_contacts": "not-a-list",
|
||||
}
|
||||
)
|
||||
|
||||
def test_scope_enforced_no_raw_packets(self):
|
||||
from app.routers.fanout import _enforce_scope
|
||||
|
||||
result = _enforce_scope("mqtt_ha", {"messages": "all", "raw_packets": "all"})
|
||||
assert result["raw_packets"] == "none"
|
||||
assert result["messages"] == "all"
|
||||
Reference in New Issue
Block a user