mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-01 19:12:57 +02:00
Add some QOL improvements to HA integration
This commit is contained in:
124
README_HA.md
124
README_HA.md
@@ -19,6 +19,28 @@ RemoteTerm can publish mesh network data to Home Assistant via MQTT Discovery. D
|
||||
|
||||
Devices will appear in HA under **Settings > Devices & Services > MQTT** within a few seconds.
|
||||
|
||||
## How MeshCore IDs Map Into Home Assistant
|
||||
|
||||
RemoteTerm uses each node's public key to derive a stable short identifier:
|
||||
|
||||
- Full public key: `ae92577bae6c4f1d...`
|
||||
- Node ID: `ae92577bae6c` (the first 12 hex characters, lowercased)
|
||||
- Example entity ID: `device_tracker.meshcore_ae92577bae6c`
|
||||
- Example runtime topic: `meshcore/ae92577bae6c/gps`
|
||||
|
||||
When this README shows `<node_id>`, it always means that 12-character value.
|
||||
|
||||
The same node ID appears in:
|
||||
|
||||
- Home Assistant entity IDs
|
||||
- Home Assistant discovery topics under `homeassistant/...`
|
||||
- Runtime MQTT state topics under your configured prefix, usually `meshcore/...`
|
||||
|
||||
You can also see these IDs in RemoteTerm's Home Assistant integration UI:
|
||||
|
||||
- `What gets created in Home Assistant`
|
||||
- `Published Topic Summary`
|
||||
|
||||
## What Gets Created
|
||||
|
||||
### Local Radio Device
|
||||
@@ -27,24 +49,26 @@ 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) |
|
||||
| `binary_sensor.meshcore_<radio_node_id>_connected` | Connectivity | Radio online/offline |
|
||||
| `sensor.meshcore_<radio_node_id>_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).
|
||||
One device per tracked repeater selected in the HA integration. Updates when telemetry is collected (auto-collect cycle (~8 hours or variable in settings), 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 |
|
||||
| `sensor.meshcore_<repeater_node_id>_battery_voltage` | Voltage | V | Battery level |
|
||||
| `sensor.meshcore_<repeater_node_id>_noise_floor` | Signal strength | dBm | Local noise floor |
|
||||
| `sensor.meshcore_<repeater_node_id>_last_rssi` | Signal strength | dBm | Last received signal strength |
|
||||
| `sensor.meshcore_<repeater_node_id>_last_snr` | -- | dB | Last signal-to-noise ratio |
|
||||
| `sensor.meshcore_<repeater_node_id>_packets_received` | -- | count | Total packets received |
|
||||
| `sensor.meshcore_<repeater_node_id>_packets_sent` | -- | count | Total packets sent |
|
||||
| `sensor.meshcore_<repeater_node_id>_uptime` | Duration | s | Uptime since last reboot |
|
||||
|
||||
If RemoteTerm already has a cached telemetry snapshot for that repeater, it republishes it on startup so HA can populate the sensors immediately instead of waiting for the next collection cycle.
|
||||
|
||||
### Contact Device Trackers
|
||||
|
||||
@@ -52,11 +76,11 @@ One `device_tracker` per tracked contact. Updates passively whenever RemoteTerm
|
||||
|
||||
| Entity | Description |
|
||||
|--------|-------------|
|
||||
| `device_tracker.meshcore_*` | GPS position (latitude/longitude) |
|
||||
| `device_tracker.meshcore_<contact_node_id>` | 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:
|
||||
A single radio-scoped event entity, `event.meshcore_<radio_node_id>_messages`, fires for each message matching your configured scope. Each event carries these attributes:
|
||||
|
||||
| Attribute | Example | Description |
|
||||
|-----------|---------|-------------|
|
||||
@@ -73,13 +97,27 @@ A single `event.meshcore_messages` entity that fires for each message matching y
|
||||
|
||||
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.
|
||||
|
||||
That same 12-character node ID is also used in the MQTT topic paths. For example:
|
||||
|
||||
- Local radio health: `meshcore/<radio_node_id>/health`
|
||||
- Repeater telemetry: `meshcore/<repeater_node_id>/telemetry`
|
||||
- Contact GPS: `meshcore/<contact_node_id>/gps`
|
||||
- Message events: `meshcore/<radio_node_id>/events/message`
|
||||
|
||||
## What Appears When
|
||||
|
||||
- Always created: the local radio device and its entities
|
||||
- Created when selected in the HA integration: tracked repeater devices and tracked contact device trackers
|
||||
- Populated only after data exists: contact GPS trackers need an advert with GPS; repeater sensors need telemetry, although cached repeater telemetry is replayed on startup when available
|
||||
- Message event entity: always created once the HA integration is enabled for a connected radio
|
||||
|
||||
## 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.
|
||||
**GUI:** Settings > Automations > Create > Numeric state trigger on `sensor.meshcore_<repeater_node_id>_battery_voltage`, below `3.8`, action: notification.
|
||||
|
||||
**YAML:**
|
||||
```yaml
|
||||
@@ -102,7 +140,7 @@ automation:
|
||||
|
||||
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.
|
||||
**GUI:** Settings > Automations > Create > State trigger on `binary_sensor.meshcore_<radio_node_id>_connected`, to `off`, for `00:05:00`, action: notification.
|
||||
|
||||
**YAML:**
|
||||
```yaml
|
||||
@@ -128,7 +166,7 @@ Trigger when a message arrives in a specific channel. Two approaches:
|
||||
|
||||
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.
|
||||
**GUI:** Settings > Automations > Create > State trigger on `event.meshcore_<radio_node_id>_messages`, action: notification.
|
||||
|
||||
**YAML:**
|
||||
```yaml
|
||||
@@ -136,7 +174,7 @@ automation:
|
||||
- alias: "Emergency channel alert"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: event.meshcore_messages
|
||||
entity_id: event.meshcore_aabbccddeeff_messages
|
||||
action:
|
||||
- service: notify.mobile_app_your_phone
|
||||
data:
|
||||
@@ -150,7 +188,7 @@ automation:
|
||||
|
||||
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.
|
||||
**GUI:** Settings > Automations > Create > State trigger on `event.meshcore_<radio_node_id>_messages` > Add condition > Template > enter the template below.
|
||||
|
||||
**YAML:**
|
||||
```yaml
|
||||
@@ -158,7 +196,7 @@ automation:
|
||||
- alias: "Emergency channel alert"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: event.meshcore_messages
|
||||
entity_id: event.meshcore_aabbccddeeff_messages
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: >-
|
||||
@@ -180,7 +218,7 @@ automation:
|
||||
- alias: "DM from Alice"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: event.meshcore_messages
|
||||
entity_id: event.meshcore_aabbccddeeff_messages
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: >-
|
||||
@@ -201,7 +239,7 @@ automation:
|
||||
- alias: "Keyword alert"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: event.meshcore_messages
|
||||
entity_id: event.meshcore_aabbccddeeff_messages
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: >-
|
||||
@@ -266,7 +304,9 @@ mosquitto_pub -h <broker> -t 'homeassistant/sensor/meshcore_unknown/noise_floor/
|
||||
|
||||
### 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.
|
||||
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).
|
||||
|
||||
If RemoteTerm already has cached telemetry for that repeater, it republishes the last known values on startup. If the sensors are still unknown or unavailable, it usually means no telemetry has ever been collected for that repeater yet.
|
||||
|
||||
### Contact device tracker shows "Unknown"
|
||||
|
||||
@@ -280,26 +320,52 @@ Radio health entities have a 120-second expiry. If RemoteTerm stops sending heal
|
||||
|
||||
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.
|
||||
|
||||
## Local Test Environment
|
||||
|
||||
For local development, RemoteTerm includes a helper that starts Mosquitto and Home Assistant with MQTT preconfigured:
|
||||
|
||||
```bash
|
||||
./scripts/setup/start_ha_test_env.sh
|
||||
```
|
||||
|
||||
That gives you:
|
||||
|
||||
- Home Assistant at `http://localhost:8123`
|
||||
- Mosquitto at `localhost:1883`
|
||||
- A pre-created HA MQTT integration using that broker
|
||||
|
||||
To watch all MQTT traffic during testing:
|
||||
|
||||
```bash
|
||||
docker exec ha-test-mosquitto mosquitto_sub -h 127.0.0.1 -t '#' -v
|
||||
```
|
||||
|
||||
To stop and clean up:
|
||||
|
||||
```bash
|
||||
./scripts/setup/stop_ha_test_env.sh --clean
|
||||
```
|
||||
|
||||
## MQTT Topics Reference
|
||||
|
||||
State topics (where data is published):
|
||||
Runtime/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 |
|
||||
| `meshcore/{node_id}/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 |
|
||||
| `homeassistant/binary_sensor/meshcore_<node_id>/connected/config` | Radio connectivity |
|
||||
| `homeassistant/sensor/meshcore_<node_id>/noise_floor/config` | Noise floor sensor |
|
||||
| `homeassistant/sensor/meshcore_<node_id>/battery_voltage/config` | Repeater battery |
|
||||
| `homeassistant/sensor/meshcore_<node_id>/*/config` | Other repeater sensors |
|
||||
| `homeassistant/device_tracker/meshcore_<node_id>/config` | Contact GPS tracker |
|
||||
| `homeassistant/event/meshcore_<node_id>/messages/config` | Message event entity |
|
||||
|
||||
The `{node_id}` is always the first 12 characters of the node's public key, lowercased.
|
||||
|
||||
@@ -115,6 +115,21 @@ def _lpp_sensor_key(type_name: str, channel: int) -> str:
|
||||
return f"lpp_{type_name}_ch{channel}"
|
||||
|
||||
|
||||
def _repeater_telemetry_payload(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build the flat HA state payload for a repeater telemetry snapshot."""
|
||||
payload: dict[str, Any] = {}
|
||||
for sensor in _REPEATER_SENSORS:
|
||||
field = sensor["field"]
|
||||
if field is not None:
|
||||
payload[field] = data.get(field)
|
||||
|
||||
for sensor in data.get("lpp_sensors", []) or []:
|
||||
key = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
|
||||
payload[key] = sensor.get("value")
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _lpp_discovery_configs(
|
||||
prefix: str,
|
||||
pub_key: str,
|
||||
@@ -497,13 +512,14 @@ class MqttHaModule(FanoutModule):
|
||||
# ── Discovery publishing ──────────────────────────────────────────
|
||||
|
||||
async def _publish_discovery(self) -> None:
|
||||
"""Publish all HA discovery configs with retain=True."""
|
||||
"""Publish HA discovery configs and one-shot cached repeater state."""
|
||||
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]] = []
|
||||
cached_repeater_states: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
radio_name = self._radio_name or "MeshCore Radio"
|
||||
configs.extend(_radio_discovery_configs(self._prefix, self._radio_key, radio_name))
|
||||
@@ -514,8 +530,10 @@ class MqttHaModule(FanoutModule):
|
||||
configs.extend(
|
||||
_repeater_discovery_configs(self._prefix, pub_key, rname, self._radio_key)
|
||||
)
|
||||
latest = await self._resolve_latest_telemetry(pub_key)
|
||||
latest_data = latest.get("data", {}) if latest else {}
|
||||
# Dynamic LPP sensor entities from last known telemetry snapshot
|
||||
lpp_sensors = await self._resolve_lpp_sensors(pub_key)
|
||||
lpp_sensors = latest_data.get("lpp_sensors", [])
|
||||
if lpp_sensors:
|
||||
nid = _node_id(pub_key)
|
||||
device = _device_payload(pub_key, rname, "Repeater", via_device_key=self._radio_key)
|
||||
@@ -523,6 +541,13 @@ class MqttHaModule(FanoutModule):
|
||||
configs.extend(
|
||||
_lpp_discovery_configs(self._prefix, pub_key, device, lpp_sensors, state_topic)
|
||||
)
|
||||
if latest_data:
|
||||
cached_repeater_states.append(
|
||||
(
|
||||
f"{self._prefix}/{_node_id(pub_key)}/telemetry",
|
||||
_repeater_telemetry_payload(latest_data),
|
||||
)
|
||||
)
|
||||
|
||||
# Tracked contacts — resolve names from DB best-effort
|
||||
for pub_key in self._tracked_contacts:
|
||||
@@ -539,11 +564,18 @@ class MqttHaModule(FanoutModule):
|
||||
for topic, payload in configs:
|
||||
await self._publisher.publish(topic, payload, retain=True)
|
||||
|
||||
for topic, payload in cached_repeater_states:
|
||||
# Replay cached state after discovery so newly created HA entities
|
||||
# populate immediately, but do not retain it or HA will treat a
|
||||
# broker reconnect as fresh telemetry and reset expire_after.
|
||||
await self._publisher.publish(topic, payload)
|
||||
|
||||
logger.info(
|
||||
"HA MQTT: published %d discovery configs (%d repeaters, %d contacts)",
|
||||
"HA MQTT: published %d discovery configs (%d repeaters, %d contacts, %d cached telemetry states)",
|
||||
len(configs),
|
||||
len(self._tracked_repeaters),
|
||||
len(self._tracked_contacts),
|
||||
len(cached_repeater_states),
|
||||
)
|
||||
|
||||
async def _clear_retained_topics(self, topics: list[str]) -> None:
|
||||
@@ -575,17 +607,15 @@ class MqttHaModule(FanoutModule):
|
||||
return pub_key[:12]
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_lpp_sensors(pub_key: str) -> list[dict]:
|
||||
"""Return the LPP sensor list from the most recent telemetry snapshot, or []."""
|
||||
async def _resolve_latest_telemetry(pub_key: str) -> dict | None:
|
||||
"""Return the most recent telemetry row for a repeater, or None."""
|
||||
try:
|
||||
from app.repository.repeater_telemetry import RepeaterTelemetryRepository
|
||||
|
||||
latest = await RepeaterTelemetryRepository.get_latest(pub_key)
|
||||
if latest:
|
||||
return latest.get("data", {}).get("lpp_sensors", [])
|
||||
return await RepeaterTelemetryRepository.get_latest(pub_key)
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
return None
|
||||
|
||||
def _seed_radio_identity_from_runtime(self) -> None:
|
||||
"""Best-effort bootstrap from the currently connected radio session."""
|
||||
@@ -698,19 +728,12 @@ class MqttHaModule(FanoutModule):
|
||||
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)
|
||||
|
||||
# Flatten LPP sensors into the same payload so HA value_templates work
|
||||
payload = _repeater_telemetry_payload(data)
|
||||
lpp_sensors: list[dict] = data.get("lpp_sensors", [])
|
||||
rediscover = False
|
||||
for sensor in lpp_sensors:
|
||||
key = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
|
||||
payload[key] = sensor.get("value")
|
||||
# Check if discovery for this sensor has been published yet
|
||||
key = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
|
||||
expected_topic = f"homeassistant/sensor/meshcore_{nid}/{key}/config"
|
||||
if expected_topic not in self._discovery_topics:
|
||||
rediscover = True
|
||||
|
||||
@@ -843,6 +843,7 @@ function MqttHaConfigEditor({
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
const [trackedRepeaters, setTrackedRepeaters] = useState<string[]>([]);
|
||||
const [contactSearch, setContactSearch] = useState('');
|
||||
const [radioConfig, setRadioConfig] = useState<{ public_key: string; name: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -858,6 +859,11 @@ function MqttHaConfigEditor({
|
||||
setContacts(all);
|
||||
})().catch(console.error);
|
||||
|
||||
api
|
||||
.getRadioConfig()
|
||||
.then((radio) => setRadioConfig({ public_key: radio.public_key, name: radio.name }))
|
||||
.catch(console.error);
|
||||
|
||||
api
|
||||
.getSettings()
|
||||
.then((s) => setTrackedRepeaters(s.tracked_telemetry_repeaters ?? []))
|
||||
@@ -897,6 +903,82 @@ function MqttHaConfigEditor({
|
||||
const selectedContactDetails = contactOptions.filter((c) =>
|
||||
selectedContacts.includes(c.public_key)
|
||||
);
|
||||
const selectedRepeaterDetails = repeaterOptions.filter((c) =>
|
||||
selectedRepeaters.includes(c.public_key)
|
||||
);
|
||||
const prefix = ((config.topic_prefix as string) || 'meshcore').trim() || 'meshcore';
|
||||
|
||||
const nodeIdForKey = useCallback((publicKey: string) => publicKey.slice(0, 12).toLowerCase(), []);
|
||||
|
||||
const topicSummary = useMemo(() => {
|
||||
const items: Array<{
|
||||
kind: 'radio' | 'event' | 'repeater' | 'contact';
|
||||
label: string;
|
||||
publicKey: string;
|
||||
nodeId: string;
|
||||
topics: string[];
|
||||
}> = [];
|
||||
|
||||
if (radioConfig?.public_key) {
|
||||
const nodeId = nodeIdForKey(radioConfig.public_key);
|
||||
items.push({
|
||||
kind: 'radio',
|
||||
label: radioConfig.name || radioConfig.public_key.slice(0, 12),
|
||||
publicKey: radioConfig.public_key,
|
||||
nodeId,
|
||||
topics: [`${prefix}/${nodeId}/health`],
|
||||
});
|
||||
items.push({
|
||||
kind: 'event',
|
||||
label: radioConfig.name || radioConfig.public_key.slice(0, 12),
|
||||
publicKey: radioConfig.public_key,
|
||||
nodeId,
|
||||
topics: [`${prefix}/${nodeId}/events/message`],
|
||||
});
|
||||
}
|
||||
|
||||
for (const repeater of selectedRepeaterDetails) {
|
||||
const nodeId = nodeIdForKey(repeater.public_key);
|
||||
items.push({
|
||||
kind: 'repeater',
|
||||
label: repeater.name || repeater.public_key.slice(0, 12),
|
||||
publicKey: repeater.public_key,
|
||||
nodeId,
|
||||
topics: [`${prefix}/${nodeId}/telemetry`],
|
||||
});
|
||||
}
|
||||
|
||||
for (const contact of selectedContactDetails) {
|
||||
const nodeId = nodeIdForKey(contact.public_key);
|
||||
items.push({
|
||||
kind: 'contact',
|
||||
label: contact.name || contact.public_key.slice(0, 12),
|
||||
publicKey: contact.public_key,
|
||||
nodeId,
|
||||
topics: [`${prefix}/${nodeId}/gps`],
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [nodeIdForKey, prefix, radioConfig, selectedContactDetails, selectedRepeaterDetails]);
|
||||
|
||||
const kindLabel: Record<(typeof topicSummary)[number]['kind'], string> = {
|
||||
radio: 'Local radio state',
|
||||
event: 'Message events',
|
||||
repeater: 'Repeater telemetry',
|
||||
contact: 'Contact GPS',
|
||||
};
|
||||
const localRadioNodeId = radioConfig?.public_key
|
||||
? nodeIdForKey(radioConfig.public_key)
|
||||
: '<radio_node_id>';
|
||||
const exampleRepeaterNodeId =
|
||||
selectedRepeaterDetails.length > 0
|
||||
? nodeIdForKey(selectedRepeaterDetails[0].public_key)
|
||||
: '<repeater_node_id>';
|
||||
const exampleContactNodeId =
|
||||
selectedContactDetails.length > 0
|
||||
? nodeIdForKey(selectedContactDetails[0].public_key)
|
||||
: '<contact_node_id>';
|
||||
|
||||
const toggleTrackedContact = (key: string) => {
|
||||
const current = [...selectedContacts];
|
||||
@@ -914,111 +996,175 @@ function MqttHaConfigEditor({
|
||||
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')
|
||||
<div className="space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-semibold text-foreground">Home Assistant MQTT Discovery</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Publish discovery configs and MeshCore state to your MQTT broker so Home Assistant
|
||||
creates native devices, sensors, GPS trackers, and message events automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-3">
|
||||
<div className="rounded-md border border-border/70 bg-background/80 p-3">
|
||||
<div className="text-sm font-medium text-foreground">1. Same broker</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Home Assistant's built-in MQTT integration must point at the same broker
|
||||
configured below.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/70 bg-background/80 p-3">
|
||||
<div className="text-sm font-medium text-foreground">2. Pick what to expose</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Choose repeaters for telemetry sensors and contacts for GPS tracker entities.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/70 bg-background/80 p-3">
|
||||
<div className="text-sm font-medium text-foreground">3. Automate in HA</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Radio health and message events publish continuously; repeater and contact data update
|
||||
when new data is heard or collected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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'
|
||||
);
|
||||
}}
|
||||
>
|
||||
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')
|
||||
)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter')
|
||||
window.open(
|
||||
'https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery',
|
||||
'_blank'
|
||||
);
|
||||
}}
|
||||
>
|
||||
MQTT Discovery
|
||||
</span>{' '}
|
||||
and the topic conventions documented in{' '}
|
||||
<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'
|
||||
);
|
||||
}}
|
||||
>
|
||||
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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<summary className="text-sm font-medium text-foreground 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 className="mt-2 space-y-2 text-sm 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
|
||||
<code className="text-[0.6875rem]">
|
||||
{`binary_sensor.meshcore_${localRadioNodeId}_connected`}
|
||||
</code>{' '}
|
||||
— radio online/offline
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-[0.6875rem]">sensor.meshcore_*_noise_floor</code> —
|
||||
radio noise floor (dBm)
|
||||
<code className="text-[0.6875rem]">
|
||||
{`sensor.meshcore_${localRadioNodeId}_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
|
||||
updates on telemetry collect cycle (~8h) or manual dashboard fetch. Entity IDs shown use
|
||||
one repeater for example; these sensors are created for each selected repeater.
|
||||
<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)
|
||||
<code className="text-[0.6875rem]">
|
||||
{`sensor.meshcore_${exampleRepeaterNodeId}_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)
|
||||
<code className="text-[0.6875rem]">
|
||||
{`sensor.meshcore_${exampleRepeaterNodeId}_noise_floor`}
|
||||
</code>
|
||||
,{' '}
|
||||
<code className="text-[0.6875rem]">
|
||||
{`sensor.meshcore_${exampleRepeaterNodeId}_last_rssi`}
|
||||
</code>
|
||||
,{' '}
|
||||
<code className="text-[0.6875rem]">
|
||||
{`sensor.meshcore_${exampleRepeaterNodeId}_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>
|
||||
<code className="text-[0.6875rem]">
|
||||
{`sensor.meshcore_${exampleRepeaterNodeId}_packets_received`}
|
||||
</code>
|
||||
,{' '}
|
||||
<code className="text-[0.6875rem]">
|
||||
{`sensor.meshcore_${exampleRepeaterNodeId}_packets_sent`}
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-[0.6875rem]">sensor.meshcore_*_uptime</code> (seconds)
|
||||
<code className="text-[0.6875rem]">
|
||||
{`sensor.meshcore_${exampleRepeaterNodeId}_uptime`}
|
||||
</code>{' '}
|
||||
(seconds)
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-[0.6875rem]">sensor.meshcore_*_lpp_temperature_ch*</code>,{' '}
|
||||
<code className="text-[0.6875rem]">*_lpp_humidity_ch*</code>, etc. —
|
||||
CayenneLPP sensors (auto-detected from repeater)
|
||||
<code className="text-[0.6875rem]">
|
||||
{`sensor.meshcore_${exampleRepeaterNodeId}_lpp_temperature_ch1`}
|
||||
</code>
|
||||
,{' '}
|
||||
<code className="text-[0.6875rem]">
|
||||
{`sensor.meshcore_${exampleRepeaterNodeId}_lpp_humidity_ch1`}
|
||||
</code>
|
||||
, etc. — CayenneLPP sensors (auto-detected from repeater)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Per tracked contact</span> — updates
|
||||
passively when advertisements with GPS are heard
|
||||
passively when advertisements with GPS are heard. Shown for one contact; a tracker is
|
||||
created for each selected contact.
|
||||
<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
|
||||
<code className="text-[0.6875rem]">
|
||||
{`device_tracker.meshcore_${exampleContactNodeId}`}
|
||||
</code>{' '}
|
||||
— latitude/longitude
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1028,8 +1174,10 @@ function MqttHaConfigEditor({
|
||||
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
|
||||
<code className="text-[0.6875rem]">
|
||||
{`event.meshcore_${localRadioNodeId}_messages`}
|
||||
</code>{' '}
|
||||
— trigger automations on sender, channel, or message content
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1043,11 +1191,62 @@ function MqttHaConfigEditor({
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className="group">
|
||||
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
|
||||
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
|
||||
Published Topic Summary
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2 rounded-md border border-border bg-muted/20 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Home Assistant device and entity IDs are keyed off the first 12 characters of each
|
||||
node's public key, not the display name. Those same 12 characters are used in the
|
||||
MQTT state topics below.
|
||||
</p>
|
||||
{topicSummary.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No topic previews available yet. Connect to a radio to resolve the local radio key,
|
||||
and select contacts or repeaters above to preview their published topics.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{topicSummary.map((item) => (
|
||||
<div
|
||||
key={`${item.kind}-${item.publicKey}`}
|
||||
className="rounded border border-border/70 bg-background/70 p-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
<span className="font-medium text-foreground">{kindLabel[item.kind]}</span>
|
||||
<span className="text-foreground">{item.label}</span>
|
||||
<span className="font-mono text-[0.6875rem] text-muted-foreground">
|
||||
node id {item.nodeId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[0.6875rem] text-muted-foreground font-mono break-all">
|
||||
key {item.publicKey}
|
||||
</div>
|
||||
{item.topics.map((topic) => (
|
||||
<div
|
||||
key={topic}
|
||||
className="mt-1 rounded bg-muted px-2 py-1 text-[0.6875rem] font-mono text-foreground break-all"
|
||||
>
|
||||
{topic}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[0.6875rem] text-muted-foreground">
|
||||
Discovery config topics are also published under{' '}
|
||||
<code className="text-[0.6875rem]">homeassistant/.../config</code>, but the topics above
|
||||
are the primary runtime state and event topics.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<Separator />
|
||||
|
||||
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
MQTT Broker
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-foreground">MQTT Broker</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
@@ -1138,9 +1337,7 @@ function MqttHaConfigEditor({
|
||||
<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-sm font-semibold text-foreground">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
|
||||
@@ -1211,9 +1408,7 @@ function MqttHaConfigEditor({
|
||||
<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-sm font-semibold text-foreground">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
|
||||
@@ -1254,14 +1449,12 @@ function MqttHaConfigEditor({
|
||||
<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-sm font-semibold text-foreground">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.
|
||||
<code className="text-[0.6875rem]">{`event.meshcore_${localRadioNodeId}_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} />
|
||||
|
||||
@@ -12,6 +12,7 @@ vi.mock('../api', () => ({
|
||||
deleteFanoutConfig: vi.fn(),
|
||||
getChannels: vi.fn(),
|
||||
getContacts: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
getRadioConfig: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -97,6 +98,20 @@ beforeEach(() => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([]);
|
||||
mockedApi.getChannels.mockResolvedValue([]);
|
||||
mockedApi.getContacts.mockResolvedValue([]);
|
||||
mockedApi.getSettings.mockResolvedValue({
|
||||
max_radio_contacts: 200,
|
||||
auto_decrypt_dm_on_advert: true,
|
||||
last_message_times: {},
|
||||
advert_interval: 0,
|
||||
last_advert_time: 0,
|
||||
flood_scope: '',
|
||||
blocked_keys: [],
|
||||
blocked_names: [],
|
||||
discovery_blocked_types: [],
|
||||
tracked_telemetry_repeaters: [],
|
||||
auto_resend_channel: false,
|
||||
telemetry_interval_hours: 8,
|
||||
});
|
||||
mockedApi.getRadioConfig.mockResolvedValue({
|
||||
public_key: 'aa'.repeat(32),
|
||||
name: 'TestNode',
|
||||
@@ -975,6 +990,90 @@ describe('SettingsFanoutSection', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('shows Home Assistant topic summary with device-key-derived node ids', async () => {
|
||||
mockedApi.getContacts.mockResolvedValue([
|
||||
{
|
||||
public_key: 'bb'.repeat(32),
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
direct_path_updated_at: null,
|
||||
route_override_path: null,
|
||||
route_override_len: null,
|
||||
route_override_hash_mode: null,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
first_seen: null,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
public_key: 'cc'.repeat(32),
|
||||
name: 'Repeater One',
|
||||
type: 2,
|
||||
flags: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
direct_path_updated_at: null,
|
||||
route_override_path: null,
|
||||
route_override_len: null,
|
||||
route_override_hash_mode: null,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
first_seen: null,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
]);
|
||||
mockedApi.getSettings.mockResolvedValue({
|
||||
max_radio_contacts: 200,
|
||||
auto_decrypt_dm_on_advert: true,
|
||||
last_message_times: {},
|
||||
advert_interval: 0,
|
||||
last_advert_time: 0,
|
||||
flood_scope: '',
|
||||
blocked_keys: [],
|
||||
blocked_names: [],
|
||||
discovery_blocked_types: [],
|
||||
tracked_telemetry_repeaters: ['cc'.repeat(32)],
|
||||
auto_resend_channel: false,
|
||||
telemetry_interval_hours: 8,
|
||||
});
|
||||
|
||||
renderSection();
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Home Assistant MQTT Discovery');
|
||||
confirmCreateIntegration();
|
||||
|
||||
expect(await screen.findByText('Published Topic Summary')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText(/Alice/));
|
||||
fireEvent.click(await screen.findByLabelText(/Repeater One/));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('node id aaaaaaaaaaaa').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText('node id bbbbbbbbbbbb')).toBeInTheDocument();
|
||||
expect(screen.getByText('node id cccccccccccc')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('meshcore/aaaaaaaaaaaa/health')).toBeInTheDocument();
|
||||
expect(screen.getByText('meshcore/aaaaaaaaaaaa/events/message')).toBeInTheDocument();
|
||||
expect(screen.getByText('meshcore/bbbbbbbbbbbb/gps')).toBeInTheDocument();
|
||||
expect(screen.getByText('meshcore/cccccccccccc/telemetry')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('LetsMesh (US) preset pre-fills the expected broker defaults', async () => {
|
||||
const createdConfig: FanoutConfig = {
|
||||
id: 'comm-letsmesh-us',
|
||||
|
||||
@@ -24,6 +24,45 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
HA_CONFIG="$REPO_ROOT/ha_test_config"
|
||||
HA_CLIENT_ID="http://localhost:8123/"
|
||||
|
||||
ha_storage_has_domain() {
|
||||
local domain="$1"
|
||||
HA_STORAGE_DIR="$HA_CONFIG/.storage" HA_DOMAIN="$domain" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
storage = pathlib.Path(os.environ["HA_STORAGE_DIR"]) / "core.config_entries"
|
||||
if not storage.exists():
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
data = json.loads(storage.read_text())
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
|
||||
entries = data.get("data", {}).get("entries", [])
|
||||
found = any(entry.get("domain") == os.environ["HA_DOMAIN"] for entry in entries)
|
||||
sys.exit(0 if found else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
wait_for_storage_domain() {
|
||||
local domain="$1"
|
||||
local timeout_seconds="${2:-30}"
|
||||
|
||||
for i in $(seq 1 "$timeout_seconds"); do
|
||||
if ha_storage_has_domain "$domain"; then
|
||||
echo " Persisted $domain config entry after ${i}s"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "==> Stopping any existing test containers..."
|
||||
docker rm -f ha-test-mosquitto 2>/dev/null || true
|
||||
@@ -81,7 +120,7 @@ done
|
||||
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"}')
|
||||
-d "{\"client_id\":\"$HA_CLIENT_ID\",\"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
|
||||
@@ -99,7 +138,7 @@ 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/")
|
||||
-d "grant_type=authorization_code&code=$AUTH_CODE&client_id=$HA_CLIENT_ID")
|
||||
|
||||
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
|
||||
@@ -126,7 +165,7 @@ curl -s -X POST http://localhost:8123/api/onboarding/analytics \
|
||||
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
|
||||
-d "{\"client_id\":\"$HA_CLIENT_ID\"}" > /dev/null 2>&1 || true
|
||||
|
||||
# ── Configure MQTT integration ───────────────────────────────────────────
|
||||
|
||||
@@ -150,7 +189,14 @@ else
|
||||
|
||||
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."
|
||||
echo " MQTT integration configured in HA; waiting for storage flush..."
|
||||
if wait_for_storage_domain "mqtt" 30; then
|
||||
echo " MQTT integration configured successfully."
|
||||
else
|
||||
echo " ERROR: MQTT config entry never persisted to $HA_CONFIG/.storage/core.config_entries"
|
||||
echo " Response: $MQTT_RESULT"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo " WARNING: MQTT config flow returned: $RESULT_TYPE"
|
||||
echo " Response: $MQTT_RESULT"
|
||||
@@ -166,7 +212,7 @@ sudo tee -a "$HA_CONFIG/configuration.yaml" > /dev/null << 'EOF'
|
||||
logger:
|
||||
default: warning
|
||||
logs:
|
||||
homeassistant.components.mqtt: debug
|
||||
homeassistant.components.mqtt: info
|
||||
EOF
|
||||
|
||||
# Gracefully stop the backgrounded HA so it flushes config to disk
|
||||
@@ -175,6 +221,12 @@ 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
|
||||
|
||||
if ! ha_storage_has_domain "mqtt"; then
|
||||
echo " ERROR: MQTT config entry disappeared after Home Assistant shutdown."
|
||||
echo " Check $HA_CONFIG/.storage/core.config_entries"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.fanout.mqtt_ha import (
|
||||
_node_id,
|
||||
_radio_discovery_configs,
|
||||
_repeater_discovery_configs,
|
||||
_repeater_telemetry_payload,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -249,6 +250,7 @@ class TestMqttHaFiltering:
|
||||
assert topic == f"meshcore/{_node_id(key)}/telemetry"
|
||||
assert payload["battery_volts"] == 4.1
|
||||
assert payload["uptime_seconds"] == 86400
|
||||
assert mod._publisher.publish.call_args.kwargs.get("retain") is not True
|
||||
|
||||
|
||||
class TestMqttHaHealth:
|
||||
@@ -383,6 +385,40 @@ class TestMqttHaLifecycle:
|
||||
assert mod._radio_name == "MyRadio"
|
||||
mod._publisher.start.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_discovery_replays_cached_repeater_telemetry_after_configs(self):
|
||||
key = "ccdd11223344"
|
||||
mod = MqttHaModule("test", _base_config(tracked_repeaters=[key]))
|
||||
mod._publisher = MagicMock()
|
||||
mod._publisher.publish = AsyncMock()
|
||||
mod._radio_key = "aabbccddeeff"
|
||||
mod._radio_name = "MyRadio"
|
||||
|
||||
latest = {
|
||||
"timestamp": 1234,
|
||||
"data": {
|
||||
"battery_volts": 4.1,
|
||||
"noise_floor_dbm": -112,
|
||||
"lpp_sensors": [
|
||||
{"channel": 1, "type_name": "temperature", "value": 23.5},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
mod._resolve_contact_name = AsyncMock(return_value="Rep1")
|
||||
mod._resolve_latest_telemetry = AsyncMock(return_value=latest)
|
||||
|
||||
await mod._publish_discovery()
|
||||
|
||||
calls = mod._publisher.publish.call_args_list
|
||||
discovery_calls = [c for c in calls if c.args[0].startswith("homeassistant/")]
|
||||
telemetry_calls = [c for c in calls if c.args[0] == f"meshcore/{_node_id(key)}/telemetry"]
|
||||
|
||||
assert telemetry_calls
|
||||
assert telemetry_calls[-1].args[1] == _repeater_telemetry_payload(latest["data"])
|
||||
assert telemetry_calls[-1].kwargs.get("retain") is not True
|
||||
assert calls.index(telemetry_calls[-1]) > calls.index(discovery_calls[-1])
|
||||
|
||||
|
||||
class TestMqttHaMessage:
|
||||
@pytest.mark.asyncio
|
||||
@@ -589,6 +625,7 @@ class TestMqttHaTelemetryWithLpp:
|
||||
assert payload["battery_volts"] == 4.1
|
||||
assert payload["lpp_temperature_ch1"] == 23.5
|
||||
assert payload["lpp_humidity_ch2"] == 45.0
|
||||
assert mod._publisher.publish.call_args.kwargs.get("retain") is not True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_telemetry_triggers_rediscovery_for_new_lpp_sensor(self):
|
||||
|
||||
Reference in New Issue
Block a user