diff --git a/README_HA.md b/README_HA.md index 0f1e2a2..b58c620 100644 --- a/README_HA.md +++ b/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 ``, 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__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). +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__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 | + +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_` | 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__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//health` +- Repeater telemetry: `meshcore//telemetry` +- Contact GPS: `meshcore//gps` +- Message events: `meshcore//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__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__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__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__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 -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_/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. diff --git a/app/fanout/mqtt_ha.py b/app/fanout/mqtt_ha.py index 27801f8..9fe6d5e 100644 --- a/app/fanout/mqtt_ha.py +++ b/app/fanout/mqtt_ha.py @@ -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 diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index 416217d..ffd5b2f 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -843,6 +843,7 @@ function MqttHaConfigEditor({ const [contacts, setContacts] = useState([]); const [trackedRepeaters, setTrackedRepeaters] = useState([]); 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) + : ''; + const exampleRepeaterNodeId = + selectedRepeaterDetails.length > 0 + ? nodeIdForKey(selectedRepeaterDetails[0].public_key) + : ''; + const exampleContactNodeId = + selectedContactDetails.length > 0 + ? nodeIdForKey(selectedContactDetails[0].public_key) + : ''; 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 (
-

- Uses{' '} - - window.open('https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery', '_blank') - } - onKeyDown={(e) => { - if (e.key === 'Enter') +

+
+

Home Assistant MQTT Discovery

+

+ Publish discovery configs and MeshCore state to your MQTT broker so Home Assistant + creates native devices, sensors, GPS trackers, and message events automatically. +

+
+ +
+
+
1. Same broker
+

+ Home Assistant's built-in MQTT integration must point at the same broker + configured below. +

+
+
+
2. Pick what to expose
+

+ Choose repeaters for telemetry sensors and contacts for GPS tracker entities. +

+
+
+
3. Automate in HA
+

+ Radio health and message events publish continuously; repeater and contact data update + when new data is heard or collected. +

+
+
+ +

+ Uses{' '} + window.open( 'https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery', '_blank' - ); - }} - > - MQTT Discovery - {' '} - 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{' '} - - 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 + {' '} + and the topic conventions documented in{' '} + window.open( 'https://github.com/jkingsman/Remote-Terminal-for-MeshCore/blob/main/README_HA.md', '_blank' - ); - }} - > - README_HA.md - {' '} - 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). -

+ ) + } + 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 + + . +

+
- + What gets created in Home Assistant -
+
Local radio device (always) — updates every 60s
  • - binary_sensor.meshcore_*_connected — - radio online/offline + + {`binary_sensor.meshcore_${localRadioNodeId}_connected`} + {' '} + — radio online/offline
  • - sensor.meshcore_*_noise_floor — - radio noise floor (dBm) + + {`sensor.meshcore_${localRadioNodeId}_noise_floor`} + {' '} + — radio noise floor (dBm)
Per tracked repeater — - 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.
  • - sensor.meshcore_*_battery_voltage (V) + + {`sensor.meshcore_${exampleRepeaterNodeId}_battery_voltage`} + {' '} + (V)
  • - sensor.meshcore_*_noise_floor,{' '} - *_last_rssi,{' '} - *_last_snr (dBm/dB) + + {`sensor.meshcore_${exampleRepeaterNodeId}_noise_floor`} + + ,{' '} + + {`sensor.meshcore_${exampleRepeaterNodeId}_last_rssi`} + + ,{' '} + + {`sensor.meshcore_${exampleRepeaterNodeId}_last_snr`} + {' '} + (dBm/dB)
  • - sensor.meshcore_*_packets_received,{' '} - *_packets_sent + + {`sensor.meshcore_${exampleRepeaterNodeId}_packets_received`} + + ,{' '} + + {`sensor.meshcore_${exampleRepeaterNodeId}_packets_sent`} +
  • - sensor.meshcore_*_uptime (seconds) + + {`sensor.meshcore_${exampleRepeaterNodeId}_uptime`} + {' '} + (seconds)
  • - sensor.meshcore_*_lpp_temperature_ch*,{' '} - *_lpp_humidity_ch*, etc. — - CayenneLPP sensors (auto-detected from repeater) + + {`sensor.meshcore_${exampleRepeaterNodeId}_lpp_temperature_ch1`} + + ,{' '} + + {`sensor.meshcore_${exampleRepeaterNodeId}_lpp_humidity_ch1`} + + , etc. — CayenneLPP sensors (auto-detected from repeater)
Per tracked contact — 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.
  • - device_tracker.meshcore_* — - latitude/longitude + + {`device_tracker.meshcore_${exampleContactNodeId}`} + {' '} + — latitude/longitude
@@ -1028,8 +1174,10 @@ function MqttHaConfigEditor({ each message matching the scope below
  • - event.meshcore_messages — trigger - automations on sender, channel, or message content + + {`event.meshcore_${localRadioNodeId}_messages`} + {' '} + — trigger automations on sender, channel, or message content
@@ -1043,11 +1191,62 @@ function MqttHaConfigEditor({
+
+ + + Published Topic Summary + +
+

+ 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. +

+ {topicSummary.length === 0 ? ( +

+ 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. +

+ ) : ( +
+ {topicSummary.map((item) => ( +
+
+ {kindLabel[item.kind]} + {item.label} + + node id {item.nodeId} + +
+
+ key {item.publicKey} +
+ {item.topics.map((topic) => ( +
+ {topic} +
+ ))} +
+ ))} +
+ )} +

+ Discovery config topics are also published under{' '} + homeassistant/.../config, but the topics above + are the primary runtime state and event topics. +

+
+
+ -

- MQTT Broker -

+

MQTT Broker

@@ -1138,9 +1337,7 @@ function MqttHaConfigEditor({
-

- GPS Tracked Contacts -

+

GPS Tracked Contacts

Each selected contact becomes a device_tracker{' '} in HA, updated whenever an advertisement with GPS coordinates is heard. Useful for @@ -1211,9 +1408,7 @@ function MqttHaConfigEditor({

-

- Telemetry Tracked Repeaters -

+

Telemetry Tracked Repeaters

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

-

- Message Events -

+

Message Events

Matching messages fire an{' '} - event.meshcore_messages entity in HA with - sender, text, channel, and direction attributes. Use HA automations to trigger actions on - specific messages, channels, or contacts. + {`event.meshcore_${localRadioNodeId}_messages`}{' '} + entity in HA with sender, text, channel, and direction attributes. Use HA automations to + trigger actions on specific messages, channels, or contacts.

diff --git a/frontend/src/test/fanoutSection.test.tsx b/frontend/src/test/fanoutSection.test.tsx index 2e17a19..03cf057 100644 --- a/frontend/src/test/fanoutSection.test.tsx +++ b/frontend/src/test/fanoutSection.test.tsx @@ -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', diff --git a/scripts/setup/start_ha_test_env.sh b/scripts/setup/start_ha_test_env.sh index 906fab4..eb49333 100755 --- a/scripts/setup/start_ha_test_env.sh +++ b/scripts/setup/start_ha_test_env.sh @@ -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 "" diff --git a/tests/test_mqtt_ha.py b/tests/test_mqtt_ha.py index 3868e98..94fa5d9 100644 --- a/tests/test_mqtt_ha.py +++ b/tests/test_mqtt_ha.py @@ -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):