diff --git a/README_HA.md b/README_HA.md index 58e9cb3..f236360 100644 --- a/README_HA.md +++ b/README_HA.md @@ -68,13 +68,21 @@ Repeaters must first be added to the auto-telemetry tracking list in RemoteTerm' 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 +### Contact Devices -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. +One HA device per tracked contact, which can expose two kinds of entities. + +**GPS tracker** -- one `device_tracker`, populated from two sources: + +- **Advertisements** -- updates passively whenever RemoteTerm hears an advert carrying GPS coordinates from that contact. No radio commands are sent; it piggybacks on normal mesh traffic. +- **CayenneLPP telemetry** -- if the contact also reports a GPS reading in its LPP telemetry (and is tracked for contact telemetry collection), that reading updates the tracker too. GPS is routed to the tracker, not exposed as a numeric sensor. + +**CayenneLPP sensors** -- if the contact is tracked for telemetry collection and reports LPP readings, a numeric sensor is created per reading, auto-detected from the data (e.g. `sensor._lpp_temperature_ch1`, `_lpp_voltage_ch1`). | Entity | Description | |--------|-------------| -| `device_tracker.` | GPS position (latitude/longitude) | +| `device_tracker.` | GPS position (`latitude`/`longitude` attributes, plus `altitude` when a telemetry reading includes it) | +| `sensor._lpp__ch` | CayenneLPP sensor reading (auto-detected; GPS excluded -- see tracker above) | ### Message Event Entity @@ -106,7 +114,7 @@ MQTT topic paths use the 12-character node ID (first 12 hex characters of the pu - 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 +- Populated only after data exists: contact GPS trackers need an advert with GPS or a GPS reading in collected LPP telemetry; 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 diff --git a/app/AGENTS.md b/app/AGENTS.md index dbdb032..2ff4915 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -415,7 +415,7 @@ tests/ ├── test_message_prefix_claim.py # Message prefix claim logic ├── test_mqtt.py # MQTT publisher topic routing and lifecycle ├── test_messages_search.py # Message search, around, forward pagination -├── test_mqtt_ha.py # MQTT HA (high-availability) behavior +├── test_mqtt_ha.py # Home Assistant MQTT Discovery fanout module ├── test_packet_pipeline.py # End-to-end packet processing ├── test_packets_router.py # Packets router endpoints (decrypt, maintenance) ├── test_path_utils.py # Path hex rendering helpers diff --git a/app/fanout/mqtt_ha.py b/app/fanout/mqtt_ha.py index 4cbacea..ebd3578 100644 --- a/app/fanout/mqtt_ha.py +++ b/app/fanout/mqtt_ha.py @@ -124,15 +124,65 @@ def _lpp_sensor_key(type_name: str, channel: int) -> str: return f"lpp_{type_name}_ch{channel}" +def _is_geo_sensor(sensor: dict) -> bool: + """True for multi-value GPS/location readings whose value is a lat/lon dict. + + These cannot be represented as a single numeric HA sensor (HA rejects the + nested object on a ``state_class: measurement`` entity). They are routed to + the contact ``device_tracker`` instead, so they are excluded from the flat + numeric-sensor key assignment below. + """ + if sensor.get("type_name") == "gps": + return True + value = sensor.get("value") + return isinstance(value, dict) and "latitude" in value and "longitude" in value + + +def _extract_gps_reading(lpp_sensors: list[dict]) -> dict | None: + """Return the first usable GPS reading as a device_tracker attributes payload. + + Mirrors the ``on_contact`` advert-sourced GPS shape (``latitude``, + ``longitude``, ``gps_accuracy``, ``source_type``) and adds ``altitude`` when + the reading carries it. Returns ``None`` when there is no GPS sensor or the + coordinates are the ``(0, 0)`` "no fix" sentinel. + """ + for sensor in lpp_sensors or []: + if not _is_geo_sensor(sensor): + continue + value = sensor.get("value") + if not isinstance(value, dict): + continue + lat = value.get("latitude") + lon = value.get("longitude") + if lat is None or lon is None or (lat == 0.0 and lon == 0.0): + continue + attrs: dict[str, Any] = { + "latitude": lat, + "longitude": lon, + "gps_accuracy": 0, + "source_type": "gps", + } + alt = value.get("altitude") + if alt is not None: + attrs["altitude"] = alt + return attrs + return None + + def _assign_lpp_keys(lpp_sensors: list[dict]) -> list[tuple[dict, str, int]]: """Pair each LPP sensor dict with a disambiguated flat key and occurrence. First occurrence keeps the base key (``lpp_temperature_ch1``), occurrence=1; subsequent duplicates of the same (type_name, channel) get ``_2``, ``_3``, etc. + + Multi-value GPS/location sensors are skipped here: they are surfaced through + the device_tracker (see ``_extract_gps_reading``), not as numeric sensors. """ counts: dict[str, int] = {} result: list[tuple[dict, str, int]] = [] for sensor in lpp_sensors: + if _is_geo_sensor(sensor): + continue base = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0)) n = counts.get(base, 0) + 1 counts[base] = n @@ -616,7 +666,15 @@ class MqttHaModule(FanoutModule): # 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] + # Clear any retained discovery configs we previously published but no + # longer generate (e.g. the legacy broken GPS numeric sensor, now routed + # to the device_tracker, or sensors from an untracked node). Without this + # the broker's retained config would keep recreating the dead entity. + new_topics = [topic for topic, _ in configs] + stale = [t for t in self._discovery_topics if t not in new_topics] + if stale: + await self._clear_retained_topics(stale) + self._discovery_topics = new_topics for topic, payload in configs: await self._publisher.publish(topic, payload, retain=True) @@ -812,6 +870,14 @@ class MqttHaModule(FanoutModule): await self._publisher.publish(f"{self._prefix}/{nid}/telemetry", payload) + # Route any GPS/location reading to the contact device_tracker instead of + # a numeric sensor. Only tracked contacts have a device_tracker entity; + # repeaters expose no tracker, so their GPS readings are simply dropped. + if not is_repeater: + gps_attrs = _extract_gps_reading(lpp_sensors) + if gps_attrs is not None: + await self._publisher.publish(f"{self._prefix}/{nid}/gps", gps_attrs) + async def on_message(self, data: dict) -> None: if not self._publisher.connected or not self._radio_key: return diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index 9ef0726..b8b4716 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -1179,14 +1179,22 @@ function MqttHaConfigEditor({
Per tracked contact — updates - passively when advertisements with GPS are heard. Shown for one contact; a tracker is - created for each selected contact. + passively when advertisements with GPS are heard, and from any GPS reading in the + contact's CayenneLPP telemetry. Shown for one contact; a tracker is created for each + selected contact.
  • {`device_tracker.meshcore_${exampleContactNodeId}`} {' '} - — latitude/longitude + — latitude/longitude (plus altitude when telemetry provides it) +
  • +
  • + + {`sensor.meshcore_${exampleContactNodeId}_lpp_temperature_ch1`} + + , etc. — CayenneLPP sensors (when the contact is tracked for telemetry; GPS + goes to the tracker above)
diff --git a/tests/test_mqtt_ha.py b/tests/test_mqtt_ha.py index 663c4d9..b8e739a 100644 --- a/tests/test_mqtt_ha.py +++ b/tests/test_mqtt_ha.py @@ -8,8 +8,11 @@ import pytest from app.fanout.mqtt_ha import ( MqttHaModule, _assign_lpp_keys, + _contact_telemetry_payload, _contact_tracker_discovery_config, _device_payload, + _extract_gps_reading, + _is_geo_sensor, _lpp_discovery_configs, _lpp_sensor_key, _message_event_discovery_config, @@ -254,6 +257,148 @@ class TestMqttHaFiltering: assert mod._publisher.publish.call_args.kwargs.get("retain") is not True +class TestLppGpsHandling: + """GPS/location LPP readings route to the device_tracker, not numeric sensors.""" + + _GPS_SENSOR = { + "channel": 1, + "type_name": "gps", + "value": {"latitude": 21.0021, "longitude": -21.0021, "altitude": 125.3}, + } + + def test_is_geo_sensor_detects_gps_type(self): + assert _is_geo_sensor(self._GPS_SENSOR) is True + + def test_is_geo_sensor_detects_dict_value(self): + assert ( + _is_geo_sensor( + { + "channel": 1, + "type_name": "location", + "value": {"latitude": 1.0, "longitude": 2.0}, + } + ) + is True + ) + + def test_is_geo_sensor_false_for_scalar(self): + assert _is_geo_sensor({"channel": 1, "type_name": "voltage", "value": 4.03}) is False + + def test_assign_lpp_keys_skips_gps(self): + sensors = [ + {"channel": 1, "type_name": "voltage", "value": 4.03}, + self._GPS_SENSOR, + {"channel": 1, "type_name": "temperature", "value": 24.5}, + ] + keys = [key for _, key, _ in _assign_lpp_keys(sensors)] + assert keys == ["lpp_voltage_ch1", "lpp_temperature_ch1"] + + def test_lpp_discovery_skips_gps(self): + topics = [ + topic + for topic, _ in _lpp_discovery_configs( + "meshcore", "ccdd11223344", {}, [self._GPS_SENSOR], "t" + ) + ] + assert topics == [] + + def test_contact_telemetry_payload_excludes_gps(self): + payload = _contact_telemetry_payload( + { + "lpp_sensors": [ + {"channel": 1, "type_name": "voltage", "value": 4.03}, + self._GPS_SENSOR, + ] + } + ) + assert payload == {"lpp_voltage_ch1": 4.03} + assert "lpp_gps_ch1" not in payload + + def test_extract_gps_reading(self): + attrs = _extract_gps_reading([self._GPS_SENSOR]) + assert attrs == { + "latitude": 21.0021, + "longitude": -21.0021, + "gps_accuracy": 0, + "source_type": "gps", + "altitude": 125.3, + } + + def test_extract_gps_reading_skips_zero_fix(self): + assert ( + _extract_gps_reading( + [{"channel": 1, "type_name": "gps", "value": {"latitude": 0.0, "longitude": 0.0}}] + ) + is None + ) + + def test_extract_gps_reading_none_without_gps(self): + assert _extract_gps_reading([{"channel": 1, "type_name": "voltage", "value": 4.03}]) is None + + @pytest.mark.asyncio + async def test_on_telemetry_routes_contact_gps_to_tracker(self): + key = "ccdd11223344" + mod = MqttHaModule("test", _base_config(tracked_contacts=[key])) + mod._publisher = MagicMock() + mod._publisher.connected = True + mod._publisher.publish = AsyncMock() + + await mod.on_telemetry( + { + "public_key": key, + "lpp_sensors": [ + {"channel": 1, "type_name": "voltage", "value": 4.03}, + self._GPS_SENSOR, + ], + } + ) + + topics = {c[0][0]: c[0][1] for c in mod._publisher.publish.call_args_list} + # Numeric telemetry still published, without the GPS object. + assert f"meshcore/{_node_id(key)}/telemetry" in topics + assert "lpp_gps_ch1" not in topics[f"meshcore/{_node_id(key)}/telemetry"] + # GPS routed to the device_tracker attributes topic. + gps_payload = topics[f"meshcore/{_node_id(key)}/gps"] + assert gps_payload["latitude"] == 21.0021 + assert gps_payload["longitude"] == -21.0021 + assert gps_payload["altitude"] == 125.3 + + @pytest.mark.asyncio + async def test_on_telemetry_repeater_does_not_publish_gps(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, "lpp_sensors": [self._GPS_SENSOR]} + ) + + topics = [c[0][0] for c in mod._publisher.publish.call_args_list] + assert f"meshcore/{_node_id(key)}/gps" not in topics + + +class TestStaleDiscoveryCleanup: + @pytest.mark.asyncio + async def test_publish_discovery_clears_removed_topics(self): + mod = MqttHaModule("test", _base_config()) + mod._radio_key = "aabbccddeeff" + mod._publisher = MagicMock() + mod._publisher.connected = True + mod._publisher.publish = AsyncMock() + mod._clear_retained_topics = AsyncMock() + # Simulate a previously-published topic that the new run no longer emits. + stale_topic = "homeassistant/sensor/meshcore_aabbccddeeff/lpp_gps_ch1/config" + mod._discovery_topics = [stale_topic] + + await mod._publish_discovery() + + cleared = mod._clear_retained_topics.call_args[0][0] + assert stale_topic in cleared + assert stale_topic not in mod._discovery_topics + + class TestMqttHaHealth: @pytest.mark.asyncio async def test_on_health_publishes_state(self):