Merge pull request #174 from jkingsman/ha

HomeAssistant MQTT Integration Module
This commit is contained in:
Jack Kingsman
2026-04-12 15:09:49 -07:00
committed by GitHub
10 changed files with 2156 additions and 5 deletions

3
.gitignore vendored
View File

@@ -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
View 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.

View File

@@ -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
View 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

View File

@@ -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(

View File

@@ -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">&mdash; 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> &mdash;
radio online/offline
</li>
<li>
<code className="text-[0.6875rem]">sensor.meshcore_*_noise_floor</code> &mdash;
radio noise floor (dBm)
</li>
</ul>
</div>
<div>
<span className="font-medium text-foreground">Per tracked repeater</span> &mdash;
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> &mdash; 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> &mdash;
latitude/longitude
</li>
</ul>
</div>
<div>
<span className="font-medium text-foreground">Message events</span> &mdash; 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> &mdash; 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&apos;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}/&lt;node_id&gt;/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)}`}
>
&times;
</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 &ldquo;{contactSearch}&rdquo;
</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} />
)}

View File

@@ -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(

View 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

View 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
View 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"