mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-03 12:03:04 +02:00
699 lines
26 KiB
Python
699 lines
26 KiB
Python
"""Tests for the Home Assistant MQTT Discovery fanout module."""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
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,
|
|
_repeater_discovery_configs,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper builders
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _base_config(**overrides) -> dict:
|
|
cfg = {
|
|
"broker_host": "127.0.0.1",
|
|
"broker_port": 1883,
|
|
"username": "",
|
|
"password": "",
|
|
"use_tls": False,
|
|
"tls_insecure": False,
|
|
"topic_prefix": "meshcore",
|
|
"tracked_contacts": [],
|
|
"tracked_repeaters": [],
|
|
}
|
|
cfg.update(overrides)
|
|
return cfg
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unit: node_id and device_payload
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNodeId:
|
|
def test_returns_12_char_prefix(self):
|
|
assert _node_id("aabbccddeeff11223344") == "aabbccddeeff"
|
|
|
|
def test_lowercases(self):
|
|
assert _node_id("AABBCCDDEEFF") == "aabbccddeeff"
|
|
|
|
|
|
class TestDevicePayload:
|
|
def test_basic(self):
|
|
dev = _device_payload("aabbccddeeff1122", "MyRadio", "Radio")
|
|
assert dev["identifiers"] == ["meshcore_aabbccddeeff"]
|
|
assert dev["name"] == "MyRadio"
|
|
assert dev["manufacturer"] == "MeshCore"
|
|
assert dev["model"] == "Radio"
|
|
assert "via_device" not in dev
|
|
|
|
def test_via_device(self):
|
|
dev = _device_payload("ccdd", "Repeater1", "Repeater", via_device_key="aabb")
|
|
assert dev["via_device"] == "meshcore_aabb"
|
|
|
|
def test_name_fallback(self):
|
|
dev = _device_payload("aabbccddeeff", "", "Radio")
|
|
assert dev["name"] == "aabbccddeeff"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unit: discovery config builders
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRadioDiscovery:
|
|
def test_produces_discovery_configs(self):
|
|
configs = _radio_discovery_configs("meshcore", "aabbccddeeff1122", "MyRadio")
|
|
# 1 binary_sensor (connected) + 9 sensors from _RADIO_SENSORS
|
|
assert len(configs) == 10
|
|
|
|
topics = [t for t, _ in configs]
|
|
assert "homeassistant/binary_sensor/meshcore_aabbccddeeff/connected/config" in topics
|
|
assert "homeassistant/sensor/meshcore_aabbccddeeff/noise_floor/config" in topics
|
|
assert "homeassistant/sensor/meshcore_aabbccddeeff/battery/config" in topics
|
|
assert "homeassistant/sensor/meshcore_aabbccddeeff/uptime/config" in topics
|
|
assert "homeassistant/sensor/meshcore_aabbccddeeff/last_rssi/config" in topics
|
|
assert "homeassistant/sensor/meshcore_aabbccddeeff/last_snr/config" in topics
|
|
assert "homeassistant/sensor/meshcore_aabbccddeeff/packets_received/config" in topics
|
|
assert "homeassistant/sensor/meshcore_aabbccddeeff/packets_sent/config" in topics
|
|
|
|
def test_connected_binary_sensor_shape(self):
|
|
configs = _radio_discovery_configs("mc", "aabbccddeeff", "R")
|
|
topic, cfg = configs[0]
|
|
assert cfg["device_class"] == "connectivity"
|
|
assert cfg["state_topic"] == "mc/aabbccddeeff/health"
|
|
assert cfg["unique_id"] == "meshcore_aabbccddeeff_connected"
|
|
assert cfg["expire_after"] == 120
|
|
|
|
def test_sensor_configs_have_expire_after(self):
|
|
configs = _radio_discovery_configs("mc", "aabbccddeeff", "R")
|
|
# All sensor configs (skip the binary_sensor at index 0)
|
|
for _, cfg in configs[1:]:
|
|
assert cfg["expire_after"] == 120
|
|
|
|
def test_sensor_configs_have_display_precision(self):
|
|
configs = _radio_discovery_configs("mc", "aabbccddeeff", "R")
|
|
# All sensor configs (skip the binary_sensor at index 0)
|
|
for _, cfg in configs[1:]:
|
|
assert "suggested_display_precision" in cfg
|
|
assert isinstance(cfg["suggested_display_precision"], int)
|
|
|
|
def test_battery_sensor_uses_volts(self):
|
|
configs = _radio_discovery_configs("mc", "aabbccddeeff", "R")
|
|
battery_cfgs = [(t, c) for t, c in configs if "battery" in t]
|
|
assert len(battery_cfgs) == 1
|
|
_, cfg = battery_cfgs[0]
|
|
assert cfg["unit_of_measurement"] == "V"
|
|
assert cfg["suggested_display_precision"] == 2
|
|
|
|
|
|
class TestRepeaterDiscovery:
|
|
def test_produces_sensor_per_field(self):
|
|
configs = _repeater_discovery_configs("mc", "ccdd11223344", "Rep1", "aabb")
|
|
assert len(configs) == 7 # matches _REPEATER_SENSORS length
|
|
|
|
topics = [t for t, _ in configs]
|
|
assert "homeassistant/sensor/meshcore_ccdd11223344/battery_voltage/config" in topics
|
|
assert "homeassistant/sensor/meshcore_ccdd11223344/uptime/config" in topics
|
|
|
|
def test_via_device_set(self):
|
|
configs = _repeater_discovery_configs("mc", "ccdd", "Rep1", "aabb")
|
|
_, cfg = configs[0]
|
|
assert cfg["device"]["via_device"] == "meshcore_aabb"
|
|
|
|
def test_sensors_have_expire_after(self):
|
|
configs = _repeater_discovery_configs("mc", "ccdd", "Rep1", None)
|
|
for _, cfg in configs:
|
|
assert cfg["expire_after"] == 36000
|
|
|
|
def test_sensors_have_display_precision(self):
|
|
configs = _repeater_discovery_configs("mc", "ccdd", "Rep1", None)
|
|
for _, cfg in configs:
|
|
assert "suggested_display_precision" in cfg
|
|
|
|
|
|
class TestContactTrackerDiscovery:
|
|
def test_config_shape(self):
|
|
topic, cfg = _contact_tracker_discovery_config("mc", "eeff11223344", "Alice", "aabb")
|
|
assert topic == "homeassistant/device_tracker/meshcore_eeff11223344/config"
|
|
assert cfg["unique_id"] == "meshcore_eeff11223344_tracker"
|
|
assert cfg["source_type"] == "gps"
|
|
assert cfg["json_attributes_topic"] == "mc/eeff11223344/gps"
|
|
assert "state_topic" not in cfg
|
|
|
|
|
|
class TestMessageEventDiscovery:
|
|
def test_config_shape(self):
|
|
topic, cfg = _message_event_discovery_config("mc", "aabbccddeeff", "MyRadio")
|
|
assert topic == "homeassistant/event/meshcore_aabbccddeeff/messages/config"
|
|
assert "message_received" in cfg["event_types"]
|
|
assert cfg["state_topic"] == "mc/aabbccddeeff/events/message"
|
|
assert cfg["unique_id"] == "meshcore_aabbccddeeff_messages"
|
|
assert cfg["device"]["identifiers"] == ["meshcore_aabbccddeeff"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Module: filtering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMqttHaFiltering:
|
|
@pytest.mark.asyncio
|
|
async def test_on_contact_ignores_untracked(self):
|
|
mod = MqttHaModule("test", _base_config(tracked_contacts=["aaaa"]))
|
|
mod._publisher = MagicMock()
|
|
mod._publisher.connected = True
|
|
mod._publisher.publish = AsyncMock()
|
|
|
|
await mod.on_contact({"public_key": "bbbb", "lat": 1.0, "lon": 2.0})
|
|
|
|
mod._publisher.publish.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_contact_publishes_tracked(self):
|
|
key = "aabbccddeeff"
|
|
mod = MqttHaModule("test", _base_config(tracked_contacts=[key]))
|
|
mod._publisher = MagicMock()
|
|
mod._publisher.connected = True
|
|
mod._publisher.publish = AsyncMock()
|
|
|
|
await mod.on_contact({"public_key": key, "lat": 37.7, "lon": -122.4})
|
|
|
|
mod._publisher.publish.assert_called_once()
|
|
topic = mod._publisher.publish.call_args[0][0]
|
|
payload = mod._publisher.publish.call_args[0][1]
|
|
assert topic == f"meshcore/{_node_id(key)}/gps"
|
|
assert payload["latitude"] == 37.7
|
|
assert payload["longitude"] == -122.4
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_contact_skips_zero_gps(self):
|
|
key = "aabbccddeeff"
|
|
mod = MqttHaModule("test", _base_config(tracked_contacts=[key]))
|
|
mod._publisher = MagicMock()
|
|
mod._publisher.connected = True
|
|
mod._publisher.publish = AsyncMock()
|
|
|
|
await mod.on_contact({"public_key": key, "lat": 0.0, "lon": 0.0})
|
|
|
|
mod._publisher.publish.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_telemetry_ignores_untracked(self):
|
|
mod = MqttHaModule("test", _base_config(tracked_repeaters=["aaaa"]))
|
|
mod._publisher = MagicMock()
|
|
mod._publisher.connected = True
|
|
mod._publisher.publish = AsyncMock()
|
|
|
|
await mod.on_telemetry({"public_key": "bbbb", "battery_volts": 4.1})
|
|
|
|
mod._publisher.publish.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_telemetry_publishes_tracked(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,
|
|
"noise_floor_dbm": -112,
|
|
"last_rssi_dbm": -80,
|
|
"last_snr_db": 10.0,
|
|
"packets_received": 500,
|
|
"packets_sent": 300,
|
|
"uptime_seconds": 86400,
|
|
}
|
|
)
|
|
|
|
mod._publisher.publish.assert_called_once()
|
|
topic = mod._publisher.publish.call_args[0][0]
|
|
payload = mod._publisher.publish.call_args[0][1]
|
|
assert topic == f"meshcore/{_node_id(key)}/telemetry"
|
|
assert payload["battery_volts"] == 4.1
|
|
assert payload["uptime_seconds"] == 86400
|
|
|
|
|
|
class TestMqttHaHealth:
|
|
@pytest.mark.asyncio
|
|
async def test_on_health_publishes_state(self):
|
|
mod = MqttHaModule("test", _base_config())
|
|
mod._publisher = MagicMock()
|
|
mod._publisher.connected = True
|
|
mod._publisher.publish = AsyncMock()
|
|
mod._radio_key = "aabbccddeeff"
|
|
|
|
await mod.on_health(
|
|
{
|
|
"connected": True,
|
|
"public_key": "aabbccddeeff",
|
|
"name": "MyRadio",
|
|
"noise_floor_dbm": -110,
|
|
"battery_mv": 4150,
|
|
"uptime_secs": 3600,
|
|
"last_rssi": -85,
|
|
"last_snr": 9.5,
|
|
"packets_recv": 500,
|
|
"packets_sent": 250,
|
|
}
|
|
)
|
|
|
|
# Should publish health state
|
|
calls = mod._publisher.publish.call_args_list
|
|
# Last call should be health state (discovery may also be published)
|
|
health_calls = [c for c in calls if "/health" in c[0][0]]
|
|
assert len(health_calls) >= 1
|
|
payload = health_calls[-1][0][1]
|
|
assert payload["connected"] is True
|
|
assert payload["noise_floor_dbm"] == -110
|
|
assert payload["battery_volts"] == 4.15
|
|
assert payload["uptime_secs"] == 3600
|
|
assert payload["last_rssi"] == -85
|
|
assert payload["packets_recv"] == 500
|
|
assert payload["packets_sent"] == 250
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_health_caches_radio_key(self):
|
|
mod = MqttHaModule("test", _base_config())
|
|
mod._publisher = MagicMock()
|
|
mod._publisher.connected = True
|
|
mod._publisher.publish = AsyncMock()
|
|
|
|
await mod.on_health(
|
|
{
|
|
"connected": True,
|
|
"public_key": "aabbccddeeff",
|
|
"name": "MyRadio",
|
|
"noise_floor_dbm": None,
|
|
}
|
|
)
|
|
|
|
assert mod._radio_key == "aabbccddeeff"
|
|
assert mod._radio_name == "MyRadio"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_health_key_change_clears_all_existing_discovery_topics(self):
|
|
mod = MqttHaModule("test", _base_config())
|
|
mod._publisher = MagicMock()
|
|
mod._publisher.connected = True
|
|
mod._publisher.publish = AsyncMock()
|
|
mod._radio_key = "aabbccddeeff"
|
|
mod._radio_name = "OldRadio"
|
|
mod._discovery_topics = [
|
|
"homeassistant/sensor/meshcore_aabbccddeeff/noise_floor/config",
|
|
"homeassistant/event/meshcore_aabbccddeeff/messages/config",
|
|
"homeassistant/device_tracker/meshcore_ccdd11223344/config",
|
|
"homeassistant/sensor/meshcore_eeff11223344/battery_voltage/config",
|
|
]
|
|
|
|
mod._clear_retained_topics = AsyncMock()
|
|
|
|
async def publish_discovery_side_effect():
|
|
assert mod._discovery_topics == []
|
|
mod._discovery_topics = [
|
|
"homeassistant/sensor/meshcore_112233445566/noise_floor/config",
|
|
"homeassistant/event/meshcore_112233445566/messages/config",
|
|
]
|
|
|
|
mod._publish_discovery = AsyncMock(side_effect=publish_discovery_side_effect)
|
|
|
|
await mod.on_health(
|
|
{
|
|
"connected": True,
|
|
"public_key": "112233445566",
|
|
"name": "NewRadio",
|
|
}
|
|
)
|
|
|
|
mod._clear_retained_topics.assert_awaited_once_with(
|
|
[
|
|
"homeassistant/sensor/meshcore_aabbccddeeff/noise_floor/config",
|
|
"homeassistant/event/meshcore_aabbccddeeff/messages/config",
|
|
"homeassistant/device_tracker/meshcore_ccdd11223344/config",
|
|
"homeassistant/sensor/meshcore_eeff11223344/battery_voltage/config",
|
|
]
|
|
)
|
|
mod._publish_discovery.assert_awaited_once()
|
|
assert mod._radio_key == "112233445566"
|
|
assert mod._radio_name == "NewRadio"
|
|
assert mod._discovery_topics == [
|
|
"homeassistant/sensor/meshcore_112233445566/noise_floor/config",
|
|
"homeassistant/event/meshcore_112233445566/messages/config",
|
|
]
|
|
|
|
|
|
class TestMqttHaLifecycle:
|
|
@pytest.mark.asyncio
|
|
async def test_start_seeds_radio_identity_from_connected_runtime(self, monkeypatch):
|
|
from app.services.radio_runtime import radio_runtime
|
|
|
|
monkeypatch.setattr(
|
|
radio_runtime.manager,
|
|
"_meshcore",
|
|
SimpleNamespace(
|
|
is_connected=True,
|
|
self_info={"public_key": "AABBCCDDEEFF", "name": "MyRadio"},
|
|
),
|
|
)
|
|
|
|
mod = MqttHaModule("test", _base_config())
|
|
mod._publisher = MagicMock()
|
|
mod._publisher.start = AsyncMock()
|
|
|
|
await mod.start()
|
|
|
|
assert mod._radio_key == "aabbccddeeff"
|
|
assert mod._radio_name == "MyRadio"
|
|
mod._publisher.start.assert_awaited_once()
|
|
|
|
|
|
class TestMqttHaMessage:
|
|
@pytest.mark.asyncio
|
|
async def test_on_message_publishes_event(self):
|
|
mod = MqttHaModule("test", _base_config())
|
|
mod._publisher = MagicMock()
|
|
mod._publisher.connected = True
|
|
mod._publisher.publish = AsyncMock()
|
|
mod._radio_key = "aabbccddeeff"
|
|
|
|
await mod.on_message(
|
|
{
|
|
"type": "PRIV",
|
|
"conversation_key": "pk1",
|
|
"text": "hello",
|
|
"sender_name": "Alice",
|
|
"sender_key": "aabb",
|
|
"outgoing": False,
|
|
}
|
|
)
|
|
|
|
mod._publisher.publish.assert_called_once()
|
|
topic = mod._publisher.publish.call_args[0][0]
|
|
payload = mod._publisher.publish.call_args[0][1]
|
|
assert topic == "meshcore/aabbccddeeff/events/message"
|
|
assert payload["event_type"] == "message_received"
|
|
assert payload["text"] == "hello"
|
|
assert payload["sender_name"] == "Alice"
|
|
assert payload["outgoing"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_message_skips_without_radio_key(self):
|
|
mod = MqttHaModule("test", _base_config())
|
|
mod._publisher = MagicMock()
|
|
mod._publisher.connected = True
|
|
mod._publisher.publish = AsyncMock()
|
|
# _radio_key is None — should not publish
|
|
await mod.on_message({"type": "PRIV", "text": "hi", "sender_name": "X"})
|
|
mod._publisher.publish.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_message_strips_channel_sender_prefix(self):
|
|
mod = MqttHaModule("test", _base_config())
|
|
mod._publisher = MagicMock()
|
|
mod._publisher.connected = True
|
|
mod._publisher.publish = AsyncMock()
|
|
mod._radio_key = "aabbccddeeff"
|
|
|
|
await mod.on_message(
|
|
{
|
|
"type": "CHAN",
|
|
"conversation_key": "ch1",
|
|
"text": "Alice: hello from channel",
|
|
"sender_name": "Alice",
|
|
"sender_key": "aabb",
|
|
"outgoing": False,
|
|
}
|
|
)
|
|
|
|
payload = mod._publisher.publish.call_args[0][1]
|
|
assert payload["text"] == "hello from channel"
|
|
|
|
|
|
class TestMqttHaStatus:
|
|
def test_disconnected_without_host(self):
|
|
mod = MqttHaModule("test", _base_config(broker_host=""))
|
|
assert mod.status == "disconnected"
|
|
|
|
def test_disconnected_with_host_but_not_connected(self):
|
|
mod = MqttHaModule("test", _base_config(broker_host="192.168.1.1"))
|
|
assert mod.status == "disconnected"
|
|
|
|
def test_error_when_publisher_has_error(self):
|
|
mod = MqttHaModule("test", _base_config(broker_host="192.168.1.1"))
|
|
mod._publisher._last_error = "connection refused"
|
|
assert mod.status == "error"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Router validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMqttHaValidation:
|
|
def test_valid_config_passes(self):
|
|
from app.routers.fanout import _validate_mqtt_ha_config
|
|
|
|
_validate_mqtt_ha_config({"broker_host": "192.168.1.1", "broker_port": 1883})
|
|
|
|
def test_missing_host_fails(self):
|
|
from app.routers.fanout import _validate_mqtt_ha_config
|
|
|
|
with pytest.raises(Exception, match="broker_host"):
|
|
_validate_mqtt_ha_config({"broker_host": ""})
|
|
|
|
def test_bad_port_fails(self):
|
|
from app.routers.fanout import _validate_mqtt_ha_config
|
|
|
|
with pytest.raises(Exception, match="broker_port"):
|
|
_validate_mqtt_ha_config({"broker_host": "x", "broker_port": 99999})
|
|
|
|
def test_tracked_contacts_must_be_list(self):
|
|
from app.routers.fanout import _validate_mqtt_ha_config
|
|
|
|
with pytest.raises(Exception, match="tracked_contacts"):
|
|
_validate_mqtt_ha_config(
|
|
{
|
|
"broker_host": "x",
|
|
"tracked_contacts": "not-a-list",
|
|
}
|
|
)
|
|
|
|
def test_scope_enforced_no_raw_packets(self):
|
|
from app.routers.fanout import _enforce_scope
|
|
|
|
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 cfg["suggested_display_precision"] == 1
|
|
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)
|