mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 17:23:05 +02:00
Add LPP/tracked repeater telemetry and HA fanout
This commit is contained in:
@@ -9,6 +9,8 @@ from app.fanout.mqtt_ha import (
|
||||
MqttHaModule,
|
||||
_contact_tracker_discovery_config,
|
||||
_device_payload,
|
||||
_lpp_discovery_configs,
|
||||
_lpp_sensor_key,
|
||||
_message_event_discovery_config,
|
||||
_node_id,
|
||||
_radio_discovery_configs,
|
||||
@@ -479,3 +481,197 @@ class TestMqttHaValidation:
|
||||
result = _enforce_scope("mqtt_ha", {"messages": "all", "raw_packets": "all"})
|
||||
assert result["raw_packets"] == "none"
|
||||
assert result["messages"] == "all"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LPP sensor discovery and telemetry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLppSensorKey:
|
||||
def test_basic(self):
|
||||
assert _lpp_sensor_key("temperature", 1) == "lpp_temperature_ch1"
|
||||
|
||||
def test_zero_channel(self):
|
||||
assert _lpp_sensor_key("humidity", 0) == "lpp_humidity_ch0"
|
||||
|
||||
|
||||
class TestLppDiscoveryConfigs:
|
||||
def test_produces_config_per_sensor(self):
|
||||
nid = "ccdd11223344"
|
||||
device = _device_payload(nid, "Rep1", "Repeater")
|
||||
sensors = [
|
||||
{"channel": 1, "type_name": "temperature", "value": 23.5},
|
||||
{"channel": 2, "type_name": "humidity", "value": 45.0},
|
||||
]
|
||||
configs = _lpp_discovery_configs("mc", nid, device, sensors, f"mc/{nid}/telemetry")
|
||||
|
||||
assert len(configs) == 2
|
||||
topics = [t for t, _ in configs]
|
||||
assert f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1/config" in topics
|
||||
assert f"homeassistant/sensor/meshcore_{nid}/lpp_humidity_ch2/config" in topics
|
||||
|
||||
def test_sensor_config_shape(self):
|
||||
nid = "ccdd11223344"
|
||||
device = _device_payload(nid, "Rep1", "Repeater")
|
||||
sensors = [{"channel": 1, "type_name": "temperature", "value": 23.5}]
|
||||
configs = _lpp_discovery_configs("mc", nid, device, sensors, f"mc/{nid}/telemetry")
|
||||
|
||||
_, cfg = configs[0]
|
||||
assert cfg["name"] == "Temperature (Ch 1)"
|
||||
assert cfg["unique_id"] == f"meshcore_{nid}_lpp_temperature_ch1"
|
||||
assert cfg["device_class"] == "temperature"
|
||||
assert cfg["unit_of_measurement"] == "°C"
|
||||
assert cfg["state_class"] == "measurement"
|
||||
assert cfg["expire_after"] == 36000
|
||||
assert "lpp_temperature_ch1" in cfg["value_template"]
|
||||
|
||||
def test_unknown_sensor_type_no_device_class(self):
|
||||
nid = "ccdd11223344"
|
||||
device = _device_payload(nid, "Rep1", "Repeater")
|
||||
sensors = [{"channel": 0, "type_name": "exotic_sensor", "value": 1.0}]
|
||||
configs = _lpp_discovery_configs("mc", nid, device, sensors, f"mc/{nid}/telemetry")
|
||||
|
||||
_, cfg = configs[0]
|
||||
assert "device_class" not in cfg
|
||||
assert "unit_of_measurement" not in cfg
|
||||
|
||||
|
||||
class TestMqttHaTelemetryWithLpp:
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_telemetry_flattens_lpp_sensors(self):
|
||||
key = "ccdd11223344"
|
||||
mod = MqttHaModule("test", _base_config(tracked_repeaters=[key]))
|
||||
mod._publisher = MagicMock()
|
||||
mod._publisher.connected = True
|
||||
mod._publisher.publish = AsyncMock()
|
||||
# Pretend discovery already covers these sensors
|
||||
nid = _node_id(key)
|
||||
mod._discovery_topics = [
|
||||
f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1/config",
|
||||
f"homeassistant/sensor/meshcore_{nid}/lpp_humidity_ch2/config",
|
||||
]
|
||||
|
||||
await mod.on_telemetry(
|
||||
{
|
||||
"public_key": key,
|
||||
"battery_volts": 4.1,
|
||||
"lpp_sensors": [
|
||||
{"channel": 1, "type_name": "temperature", "value": 23.5},
|
||||
{"channel": 2, "type_name": "humidity", "value": 45.0},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
mod._publisher.publish.assert_called_once()
|
||||
payload = mod._publisher.publish.call_args[0][1]
|
||||
assert payload["battery_volts"] == 4.1
|
||||
assert payload["lpp_temperature_ch1"] == 23.5
|
||||
assert payload["lpp_humidity_ch2"] == 45.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_telemetry_triggers_rediscovery_for_new_lpp_sensor(self):
|
||||
key = "ccdd11223344"
|
||||
mod = MqttHaModule("test", _base_config(tracked_repeaters=[key]))
|
||||
mod._publisher = MagicMock()
|
||||
mod._publisher.connected = True
|
||||
mod._publisher.publish = AsyncMock()
|
||||
mod._discovery_topics = [] # No sensors discovered yet
|
||||
mod._publish_discovery = AsyncMock()
|
||||
|
||||
await mod.on_telemetry(
|
||||
{
|
||||
"public_key": key,
|
||||
"battery_volts": 4.1,
|
||||
"lpp_sensors": [
|
||||
{"channel": 1, "type_name": "temperature", "value": 23.5},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
mod._publish_discovery.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_telemetry_discovery_published_before_state(self):
|
||||
"""Discovery configs must arrive before the state payload so HA knows the entity."""
|
||||
key = "ccdd11223344"
|
||||
mod = MqttHaModule("test", _base_config(tracked_repeaters=[key]))
|
||||
mod._publisher = MagicMock()
|
||||
mod._publisher.connected = True
|
||||
mod._publisher.publish = AsyncMock()
|
||||
mod._discovery_topics = [] # New sensor triggers rediscovery
|
||||
|
||||
call_order: list[str] = []
|
||||
|
||||
async def fake_discovery():
|
||||
call_order.append("discovery")
|
||||
|
||||
mod._publish_discovery = AsyncMock(side_effect=fake_discovery)
|
||||
|
||||
original_publish = mod._publisher.publish
|
||||
|
||||
async def tracking_publish(topic, payload, **kw):
|
||||
if "/telemetry" in topic:
|
||||
call_order.append("state")
|
||||
return await original_publish(topic, payload, **kw)
|
||||
|
||||
mod._publisher.publish = AsyncMock(side_effect=tracking_publish)
|
||||
|
||||
await mod.on_telemetry(
|
||||
{
|
||||
"public_key": key,
|
||||
"battery_volts": 4.1,
|
||||
"lpp_sensors": [
|
||||
{"channel": 1, "type_name": "temperature", "value": 23.5},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert call_order == ["discovery", "state"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_telemetry_no_rediscovery_when_already_known(self):
|
||||
key = "ccdd11223344"
|
||||
nid = _node_id(key)
|
||||
mod = MqttHaModule("test", _base_config(tracked_repeaters=[key]))
|
||||
mod._publisher = MagicMock()
|
||||
mod._publisher.connected = True
|
||||
mod._publisher.publish = AsyncMock()
|
||||
mod._discovery_topics = [
|
||||
f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1/config",
|
||||
]
|
||||
mod._publish_discovery = AsyncMock()
|
||||
|
||||
await mod.on_telemetry(
|
||||
{
|
||||
"public_key": key,
|
||||
"battery_volts": 4.1,
|
||||
"lpp_sensors": [
|
||||
{"channel": 1, "type_name": "temperature", "value": 23.5},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
mod._publish_discovery.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_telemetry_without_lpp_sensors(self):
|
||||
"""Existing behavior: no lpp_sensors key means no LPP fields in payload."""
|
||||
key = "ccdd11223344"
|
||||
mod = MqttHaModule("test", _base_config(tracked_repeaters=[key]))
|
||||
mod._publisher = MagicMock()
|
||||
mod._publisher.connected = True
|
||||
mod._publisher.publish = AsyncMock()
|
||||
|
||||
await mod.on_telemetry(
|
||||
{
|
||||
"public_key": key,
|
||||
"battery_volts": 4.1,
|
||||
"noise_floor_dbm": -112,
|
||||
}
|
||||
)
|
||||
|
||||
payload = mod._publisher.publish.call_args[0][1]
|
||||
assert payload["battery_volts"] == 4.1
|
||||
# No lpp keys
|
||||
assert not any(k.startswith("lpp_") for k in payload)
|
||||
|
||||
@@ -1695,3 +1695,170 @@ class TestPeriodicSyncLoopRaces:
|
||||
mock_cleanup.assert_called_once()
|
||||
mock_sync.assert_not_called()
|
||||
mock_time.assert_called_once_with(mock_mc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _collect_repeater_telemetry — LPP sensor collection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCollectRepeaterTelemetryLpp:
|
||||
"""Verify that _collect_repeater_telemetry fetches LPP sensors."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lpp_sensors_included_in_data(self):
|
||||
from app.radio_sync import _collect_repeater_telemetry
|
||||
|
||||
mc = MagicMock()
|
||||
mc.commands.add_contact = AsyncMock()
|
||||
mc.commands.req_status_sync = AsyncMock(
|
||||
return_value={"bat": 4100, "noise_floor": -110, "nb_recv": 10, "nb_sent": 5}
|
||||
)
|
||||
mc.commands.req_telemetry_sync = AsyncMock(
|
||||
return_value=[
|
||||
{"channel": 1, "type": "temperature", "value": 23.5},
|
||||
{"channel": 2, "type": "humidity", "value": 45.0},
|
||||
]
|
||||
)
|
||||
|
||||
contact = MagicMock()
|
||||
contact.public_key = "aabbccddeeff11223344"
|
||||
contact.name = "TestRepeater"
|
||||
contact.to_radio_dict.return_value = {}
|
||||
|
||||
recorded_data = {}
|
||||
|
||||
async def mock_record(public_key, timestamp, data):
|
||||
recorded_data.update(data)
|
||||
|
||||
mock_fanout = MagicMock()
|
||||
mock_fanout.broadcast_telemetry = AsyncMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.radio_sync.RepeaterTelemetryRepository.record",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=mock_record,
|
||||
),
|
||||
patch("app.fanout.manager.fanout_manager", mock_fanout),
|
||||
):
|
||||
result = await _collect_repeater_telemetry(mc, contact)
|
||||
|
||||
assert result is True
|
||||
assert "lpp_sensors" in recorded_data
|
||||
assert len(recorded_data["lpp_sensors"]) == 2
|
||||
assert recorded_data["lpp_sensors"][0]["type_name"] == "temperature"
|
||||
assert recorded_data["lpp_sensors"][0]["value"] == 23.5
|
||||
assert recorded_data["lpp_sensors"][1]["type_name"] == "humidity"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lpp_failure_does_not_fail_collection(self):
|
||||
from app.radio_sync import _collect_repeater_telemetry
|
||||
|
||||
mc = MagicMock()
|
||||
mc.commands.add_contact = AsyncMock()
|
||||
mc.commands.req_status_sync = AsyncMock(return_value={"bat": 4100, "noise_floor": -110})
|
||||
mc.commands.req_telemetry_sync = AsyncMock(side_effect=Exception("no sensors"))
|
||||
|
||||
contact = MagicMock()
|
||||
contact.public_key = "aabbccddeeff11223344"
|
||||
contact.name = "TestRepeater"
|
||||
contact.to_radio_dict.return_value = {}
|
||||
|
||||
recorded_data = {}
|
||||
|
||||
async def mock_record(public_key, timestamp, data):
|
||||
recorded_data.update(data)
|
||||
|
||||
mock_fanout = MagicMock()
|
||||
mock_fanout.broadcast_telemetry = AsyncMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.radio_sync.RepeaterTelemetryRepository.record",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=mock_record,
|
||||
),
|
||||
patch("app.fanout.manager.fanout_manager", mock_fanout),
|
||||
):
|
||||
result = await _collect_repeater_telemetry(mc, contact)
|
||||
|
||||
assert result is True
|
||||
assert "lpp_sensors" not in recorded_data
|
||||
# Status data still present
|
||||
assert recorded_data["battery_volts"] == 4.1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lpp_multivalue_sensors_skipped(self):
|
||||
from app.radio_sync import _collect_repeater_telemetry
|
||||
|
||||
mc = MagicMock()
|
||||
mc.commands.add_contact = AsyncMock()
|
||||
mc.commands.req_status_sync = AsyncMock(return_value={"bat": 4000})
|
||||
mc.commands.req_telemetry_sync = AsyncMock(
|
||||
return_value=[
|
||||
{"channel": 1, "type": "temperature", "value": 23.5},
|
||||
{"channel": 3, "type": "gps", "value": {"lat": 1.0, "lon": 2.0, "alt": 3.0}},
|
||||
]
|
||||
)
|
||||
|
||||
contact = MagicMock()
|
||||
contact.public_key = "aabbccddeeff11223344"
|
||||
contact.name = "TestRepeater"
|
||||
contact.to_radio_dict.return_value = {}
|
||||
|
||||
recorded_data = {}
|
||||
|
||||
async def mock_record(public_key, timestamp, data):
|
||||
recorded_data.update(data)
|
||||
|
||||
mock_fanout = MagicMock()
|
||||
mock_fanout.broadcast_telemetry = AsyncMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.radio_sync.RepeaterTelemetryRepository.record",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=mock_record,
|
||||
),
|
||||
patch("app.fanout.manager.fanout_manager", mock_fanout),
|
||||
):
|
||||
result = await _collect_repeater_telemetry(mc, contact)
|
||||
|
||||
assert result is True
|
||||
assert len(recorded_data["lpp_sensors"]) == 1
|
||||
assert recorded_data["lpp_sensors"][0]["type_name"] == "temperature"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lpp_none_response_no_sensors_key(self):
|
||||
from app.radio_sync import _collect_repeater_telemetry
|
||||
|
||||
mc = MagicMock()
|
||||
mc.commands.add_contact = AsyncMock()
|
||||
mc.commands.req_status_sync = AsyncMock(return_value={"bat": 4000})
|
||||
mc.commands.req_telemetry_sync = AsyncMock(return_value=None)
|
||||
|
||||
contact = MagicMock()
|
||||
contact.public_key = "aabbccddeeff11223344"
|
||||
contact.name = "TestRepeater"
|
||||
contact.to_radio_dict.return_value = {}
|
||||
|
||||
recorded_data = {}
|
||||
|
||||
async def mock_record(public_key, timestamp, data):
|
||||
recorded_data.update(data)
|
||||
|
||||
mock_fanout = MagicMock()
|
||||
mock_fanout.broadcast_telemetry = AsyncMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.radio_sync.RepeaterTelemetryRepository.record",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=mock_record,
|
||||
),
|
||||
patch("app.fanout.manager.fanout_manager", mock_fanout),
|
||||
):
|
||||
await _collect_repeater_telemetry(mc, contact)
|
||||
|
||||
assert "lpp_sensors" not in recorded_data
|
||||
|
||||
Reference in New Issue
Block a user