Files
ledgrab/server/tests/test_ha_discovery.py
T
alexei.dolgolyov 39b0554444 feat: roadmap round two (2026-06-23) — per-pixel smart-lights + integrations
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.
2026-06-23 00:50:22 +03:00

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"])