mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-09 18:23:20 +02:00
filter out geo from non-geo sensors, and publish gps for device tracking
This commit is contained in:
+12
-4
@@ -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.
|
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.<contact_name>_lpp_temperature_ch1`, `_lpp_voltage_ch1`).
|
||||||
|
|
||||||
| Entity | Description |
|
| Entity | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `device_tracker.<contact_name>` | GPS position (latitude/longitude) |
|
| `device_tracker.<contact_name>` | GPS position (`latitude`/`longitude` attributes, plus `altitude` when a telemetry reading includes it) |
|
||||||
|
| `sensor.<contact_name>_lpp_<type>_ch<n>` | CayenneLPP sensor reading (auto-detected; GPS excluded -- see tracker above) |
|
||||||
|
|
||||||
### Message Event Entity
|
### 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
|
- Always created: the local radio device and its entities
|
||||||
- Created when selected in the HA integration: tracked repeater devices and tracked contact device trackers
|
- 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
|
- Message event entity: always created once the HA integration is enabled for a connected radio
|
||||||
|
|
||||||
## Common Automations
|
## Common Automations
|
||||||
|
|||||||
+1
-1
@@ -415,7 +415,7 @@ tests/
|
|||||||
├── test_message_prefix_claim.py # Message prefix claim logic
|
├── test_message_prefix_claim.py # Message prefix claim logic
|
||||||
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
||||||
├── test_messages_search.py # Message search, around, forward pagination
|
├── 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_packet_pipeline.py # End-to-end packet processing
|
||||||
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
||||||
├── test_path_utils.py # Path hex rendering helpers
|
├── test_path_utils.py # Path hex rendering helpers
|
||||||
|
|||||||
+67
-1
@@ -124,15 +124,65 @@ def _lpp_sensor_key(type_name: str, channel: int) -> str:
|
|||||||
return f"lpp_{type_name}_ch{channel}"
|
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]]:
|
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.
|
"""Pair each LPP sensor dict with a disambiguated flat key and occurrence.
|
||||||
|
|
||||||
First occurrence keeps the base key (``lpp_temperature_ch1``), occurrence=1;
|
First occurrence keeps the base key (``lpp_temperature_ch1``), occurrence=1;
|
||||||
subsequent duplicates of the same (type_name, channel) get ``_2``, ``_3``, etc.
|
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] = {}
|
counts: dict[str, int] = {}
|
||||||
result: list[tuple[dict, str, int]] = []
|
result: list[tuple[dict, str, int]] = []
|
||||||
for sensor in lpp_sensors:
|
for sensor in lpp_sensors:
|
||||||
|
if _is_geo_sensor(sensor):
|
||||||
|
continue
|
||||||
base = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
|
base = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
|
||||||
n = counts.get(base, 0) + 1
|
n = counts.get(base, 0) + 1
|
||||||
counts[base] = n
|
counts[base] = n
|
||||||
@@ -616,7 +666,15 @@ class MqttHaModule(FanoutModule):
|
|||||||
# Message event entity (namespaced to this radio)
|
# Message event entity (namespaced to this radio)
|
||||||
configs.append(_message_event_discovery_config(self._prefix, self._radio_key, radio_name))
|
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:
|
for topic, payload in configs:
|
||||||
await self._publisher.publish(topic, payload, retain=True)
|
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)
|
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:
|
async def on_message(self, data: dict) -> None:
|
||||||
if not self._publisher.connected or not self._radio_key:
|
if not self._publisher.connected or not self._radio_key:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1179,14 +1179,22 @@ function MqttHaConfigEditor({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-foreground">Per tracked contact</span> — updates
|
<span className="font-medium text-foreground">Per tracked contact</span> — updates
|
||||||
passively when advertisements with GPS are heard. Shown for one contact; a tracker is
|
passively when advertisements with GPS are heard, and from any GPS reading in the
|
||||||
created for each selected contact.
|
contact's CayenneLPP telemetry. 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">
|
<ul className="mt-0.5 ml-4 list-disc space-y-0.5">
|
||||||
<li>
|
<li>
|
||||||
<code className="text-[0.6875rem]">
|
<code className="text-[0.6875rem]">
|
||||||
{`device_tracker.meshcore_${exampleContactNodeId}`}
|
{`device_tracker.meshcore_${exampleContactNodeId}`}
|
||||||
</code>{' '}
|
</code>{' '}
|
||||||
— latitude/longitude
|
— latitude/longitude (plus altitude when telemetry provides it)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code className="text-[0.6875rem]">
|
||||||
|
{`sensor.meshcore_${exampleContactNodeId}_lpp_temperature_ch1`}
|
||||||
|
</code>
|
||||||
|
, etc. — CayenneLPP sensors (when the contact is tracked for telemetry; GPS
|
||||||
|
goes to the tracker above)
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import pytest
|
|||||||
from app.fanout.mqtt_ha import (
|
from app.fanout.mqtt_ha import (
|
||||||
MqttHaModule,
|
MqttHaModule,
|
||||||
_assign_lpp_keys,
|
_assign_lpp_keys,
|
||||||
|
_contact_telemetry_payload,
|
||||||
_contact_tracker_discovery_config,
|
_contact_tracker_discovery_config,
|
||||||
_device_payload,
|
_device_payload,
|
||||||
|
_extract_gps_reading,
|
||||||
|
_is_geo_sensor,
|
||||||
_lpp_discovery_configs,
|
_lpp_discovery_configs,
|
||||||
_lpp_sensor_key,
|
_lpp_sensor_key,
|
||||||
_message_event_discovery_config,
|
_message_event_discovery_config,
|
||||||
@@ -254,6 +257,148 @@ class TestMqttHaFiltering:
|
|||||||
assert mod._publisher.publish.call_args.kwargs.get("retain") is not True
|
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:
|
class TestMqttHaHealth:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_on_health_publishes_state(self):
|
async def test_on_health_publishes_state(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user