39b0554444
A) Per-pixel smart-lights - LIFX multizone (SetExtendedColorZones msg 510, <=82 zones) + Tile (SetTileState64 715), auto-detected on connect with single-colour fallback; lifx_per_zone threaded like nanoleaf_per_panel - Hue gradient-lightstrip mapping: Entertainment v2 frame now keyed by channel id (was 1 light=1 LED), channels discovered on connect; hue_gradient_mode toggle (default on) B) Integrations bundle - Outbound webhook automation action (Discord/IFTTT/Zapier/Node-RED), SSRF-gated via validate_polling_url at both save and fire time; fires on activate/deactivate, best-effort, audited - Home Assistant MQTT auto-discovery: read-only binary_sensors per automation + connectivity, availability via birth/will, cleanup on disable/delete, live state from the engine Shared: pixel_reduce.resample_to_n nearest-neighbour helper. 57 new tests (lifx_multizone, hue_segment, webhook_action, ha_discovery). Gate: ruff + tsc + build clean, pytest 2719 passed / 2 skipped.
146 lines
5.6 KiB
Python
146 lines
5.6 KiB
Python
"""Tests for Home Assistant MQTT auto-discovery.
|
|
|
|
The end-to-end entity appearance, retained-config survival across HA restart,
|
|
and availability flips need a live broker + HA. Here we lock down the parts
|
|
that DON'T: the discovery config payloads, publish_all / remove_all topic sets,
|
|
the MQTTSource field round-trip, and the manager's state fan-out.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from ledgrab.core.mqtt.ha_discovery import HADiscoveryPublisher
|
|
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
|
|
from ledgrab.storage.mqtt_source import MQTTSource
|
|
|
|
|
|
class _FakeRuntime:
|
|
def __init__(self) -> None:
|
|
self.published: list[tuple[str, str, bool]] = []
|
|
self.states: list[tuple[str, str]] = []
|
|
|
|
async def publish(self, topic: str, payload: str, retain: bool = False, qos: int = 0) -> None:
|
|
self.published.append((topic, payload, retain))
|
|
|
|
async def publish_automation_state(self, automation_id: str, action: str) -> None:
|
|
self.states.append((automation_id, action))
|
|
|
|
|
|
class _Auto:
|
|
def __init__(self, id: str, name: str) -> None:
|
|
self.id = id
|
|
self.name = name
|
|
|
|
|
|
class _FakeAutomationStore:
|
|
def __init__(self, autos) -> None:
|
|
self._autos = autos
|
|
|
|
def get_all(self):
|
|
return self._autos
|
|
|
|
|
|
def _source(**kw) -> MQTTSource:
|
|
now = datetime.now(timezone.utc)
|
|
defaults = dict(id="mqs_1", name="Broker", created_at=now, updated_at=now)
|
|
defaults.update(kw)
|
|
return MQTTSource(**defaults)
|
|
|
|
|
|
def _publisher(autos=None, **src_kw):
|
|
runtime = _FakeRuntime()
|
|
source = _source(**src_kw)
|
|
store = _FakeAutomationStore(autos if autos is not None else [_Auto("auto_1", "Movie Night")])
|
|
return HADiscoveryPublisher(runtime, source, store, version="9.9.9"), runtime
|
|
|
|
|
|
class TestConfigBuilders:
|
|
def test_connectivity_config_shape(self):
|
|
pub, _ = _publisher(base_topic="ledgrab")
|
|
topic, payload = pub.build_connectivity_config()
|
|
assert topic == "homeassistant/binary_sensor/ledgrab_mqs_1/connectivity/config"
|
|
assert payload["device_class"] == "connectivity"
|
|
assert payload["state_topic"] == "ledgrab/status"
|
|
assert payload["unique_id"] == "ledgrab_mqs_1_connectivity"
|
|
assert payload["device"]["identifiers"] == ["ledgrab_mqs_1"]
|
|
assert payload["device"]["sw_version"] == "9.9.9"
|
|
|
|
def test_automation_config_shape(self):
|
|
pub, _ = _publisher(base_topic="lg")
|
|
topic, payload = pub.build_automation_config(_Auto("auto_7", "Night"))
|
|
assert topic == "homeassistant/binary_sensor/ledgrab_mqs_1/automation_auto_7/config"
|
|
assert payload["state_topic"] == "lg/automation/auto_7/state"
|
|
assert payload["value_template"] == "{{ value_json.action }}"
|
|
assert payload["payload_on"] == "active" and payload["payload_off"] == "inactive"
|
|
assert payload["availability_topic"] == "lg/status"
|
|
assert payload["name"] == "Night"
|
|
|
|
def test_custom_discovery_prefix(self):
|
|
pub, _ = _publisher(discovery_prefix="ha")
|
|
topic, _ = pub.build_connectivity_config()
|
|
assert topic.startswith("ha/binary_sensor/")
|
|
|
|
|
|
class TestPublishRemove:
|
|
@pytest.mark.asyncio
|
|
async def test_publish_all_publishes_retained_configs_and_state(self):
|
|
pub, runtime = _publisher(autos=[_Auto("a1", "One"), _Auto("a2", "Two")])
|
|
await pub.publish_all()
|
|
config_topics = [t for (t, p, r) in runtime.published if t.endswith("/config")]
|
|
assert any("connectivity" in t for t in config_topics)
|
|
assert sum("automation_" in t for t in config_topics) == 2
|
|
assert all(r for (_t, _p, r) in runtime.published) # all retained
|
|
# Seeded initial states.
|
|
assert ("a1", "inactive") in runtime.states
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_all_clears_with_empty_payload(self):
|
|
pub, runtime = _publisher(autos=[_Auto("a1", "One")])
|
|
await pub.publish_all()
|
|
runtime.published.clear()
|
|
await pub.remove_all()
|
|
# Every clear is an empty retained payload to a config topic.
|
|
assert runtime.published
|
|
assert all(p == "" and r for (_t, p, r) in runtime.published)
|
|
assert all(t.endswith("/config") for (t, _p, _r) in runtime.published)
|
|
|
|
|
|
class TestSourceRoundTrip:
|
|
def test_fields_round_trip(self):
|
|
s = _source(publish_ha_discovery=True, discovery_prefix="ha")
|
|
back = MQTTSource.from_dict(s.to_dict())
|
|
assert back.publish_ha_discovery is True
|
|
assert back.discovery_prefix == "ha"
|
|
|
|
def test_defaults_when_absent(self):
|
|
back = MQTTSource.from_dict({"id": "x", "name": "n", "created_at": "", "updated_at": ""})
|
|
assert back.publish_ha_discovery is False
|
|
assert back.discovery_prefix == "homeassistant"
|
|
|
|
|
|
class TestManagerStateFanout:
|
|
@pytest.mark.asyncio
|
|
async def test_publish_state_only_to_discovery_sources(self):
|
|
mgr = MQTTManager(store=None, automation_store=None)
|
|
runtime = _FakeRuntime()
|
|
# Inject a fake runtime + mark it discovery-enabled.
|
|
mgr._runtimes["mqs_1"] = (runtime, 1)
|
|
mgr._discovery_sources.add("mqs_1")
|
|
await mgr.publish_automation_state_all("auto_1", True)
|
|
assert runtime.states == [("auto_1", "active")]
|
|
await mgr.publish_automation_state_all("auto_1", False)
|
|
assert runtime.states[-1] == ("auto_1", "inactive")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_discovery_sources_is_noop(self):
|
|
mgr = MQTTManager(store=None, automation_store=None)
|
|
# Should not raise with no discovery sources.
|
|
await mgr.publish_automation_state_all("auto_1", True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|