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.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
"""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"])
|
||||
Reference in New Issue
Block a user