from __future__ import annotations import asyncio import httpx import pytest from app.dmx.engine import DmxEngine from app.dmx.frame import DmxFrame from app.homeassistant.service import HomeAssistantService from app.telemetry.service import TelemetryService class FakeHomeAssistantAdapter: def __init__(self) -> None: self.calls: list[tuple[str, str, str, dict[str, object]]] = [] self.list_payload: list[dict[str, object]] = [ { "entity_id": "light.bar_rgb", "state": "on", "attributes": { "friendly_name": "Bar RGB", "supported_color_modes": ["rgb", "brightness"], }, }, { "entity_id": "light.bar_rgbw", "state": "on", "attributes": { "friendly_name": "Bar RGBW", "supported_color_modes": ["rgbw", "brightness"], }, }, { "entity_id": "light.bar_cct", "state": "on", "attributes": { "friendly_name": "Bar CCT", "supported_color_modes": ["color_temp", "brightness"], }, }, { "entity_id": "light.test", "state": "off", "attributes": { "friendly_name": "Test light", "supported_color_modes": ["brightness"], }, }, { "entity_id": "switch.test", "state": "off", "attributes": { "friendly_name": "Test switch", }, }, { "entity_id": "input_boolean.test", "state": "off", "attributes": { "friendly_name": "Test bool", }, }, { "entity_id": "scene.test", "state": "scening", "attributes": { "friendly_name": "Test scene", }, }, { "entity_id": "automation.test", "state": "on", "attributes": { "friendly_name": "Test automation", }, }, ] self.connection_error: Exception | None = None self.dispatch_error: Exception | None = None async def get_api_root(self, base_url: str, token: str) -> dict[str, object]: if self.connection_error is not None: raise self.connection_error return {"message": "API running."} async def get_config(self, base_url: str, token: str) -> dict[str, object]: if self.connection_error is not None: raise self.connection_error return {"version": "2026.7.0"} async def list_entities(self, base_url: str, token: str) -> list[dict[str, object]]: if self.connection_error is not None: raise self.connection_error return self.list_payload async def call_service( self, base_url: str, token: str, domain: str, service: str, data: dict[str, object], ) -> dict[str, object]: if self.dispatch_error is not None: raise self.dispatch_error self.calls.append((base_url, token, f"{domain}.{service}", data)) return {"ok": True} def build_service(adapter: FakeHomeAssistantAdapter) -> HomeAssistantService: engine = DmxEngine(TelemetryService()) service = HomeAssistantService(engine, adapter=adapter) service._config = { "enabled": True, "base_url": "http://ha.local:8123", "default_universe": 10, } service._token = "test-token" return service @pytest.mark.asyncio async def test_home_assistant_connection_reports_version_and_auth() -> None: adapter = FakeHomeAssistantAdapter() service = build_service(adapter) result = await service.test_connection() assert result["reachable"] is True assert result["auth_ok"] is True assert result["ha_version"] == "2026.7.0" config = await service.get_config() assert config["has_token"] is True assert config["token_mask"] == "********" @pytest.mark.asyncio async def test_home_assistant_rgb_mapping_reads_virtual_universe_10() -> None: adapter = FakeHomeAssistantAdapter() service = build_service(adapter) frame = DmxFrame(universe=10) frame.set_channel(1, 255, "scene") frame.set_channel(2, 200, "scene") frame.set_channel(3, 100, "scene") frame.set_channel(4, 50, "scene") service.engine.current_frames = {1: DmxFrame(universe=1), 10: frame} await service.list_entities() service._mappings = [ service._normalize_mapping( { "id": 1, "name": "Bar RGB", "universe": 10, "start_address": 1, "fixture_type": "rgb", "entity_id": "light.bar_rgb", "rate_limit_hz": 5, "deadband": 1, "fade_ms": 150, "enabled": True, "master_dimmer": True, } ) ] await service.dispatch_once() assert len(adapter.calls) == 1 _base_url, _token, service_name, data = adapter.calls[0] assert service_name == "light.turn_on" assert data["entity_id"] == "light.bar_rgb" assert data["brightness"] == 255 assert data["rgb_color"] == [200, 100, 50] @pytest.mark.asyncio async def test_home_assistant_rgbw_mapping_at_channel_512_uses_last_channel() -> None: adapter = FakeHomeAssistantAdapter() service = build_service(adapter) frame = DmxFrame(universe=10) frame.set_channel(508, 255, "scene") frame.set_channel(509, 10, "scene") frame.set_channel(510, 20, "scene") frame.set_channel(511, 30, "scene") frame.set_channel(512, 40, "scene") service.engine.current_frames = {10: frame} await service.list_entities() service._mappings = [ service._normalize_mapping( { "id": 2, "name": "Bar RGBW", "universe": 10, "start_address": 508, "fixture_type": "rgbw", "entity_id": "light.bar_rgbw", "rate_limit_hz": 5, "deadband": 1, "fade_ms": 100, "enabled": True, "master_dimmer": True, } ) ] await service.dispatch_once() assert adapter.calls[0][2] == "light.turn_on" assert adapter.calls[0][3]["rgbw_color"] == [10, 20, 30, 40] @pytest.mark.asyncio async def test_home_assistant_rate_limit_prevents_duplicate_spam() -> None: adapter = FakeHomeAssistantAdapter() service = build_service(adapter) frame = DmxFrame(universe=10) frame.set_channel(1, 180, "scene") service.engine.current_frames = {10: frame} await service.list_entities() service._mappings = [ service._normalize_mapping( { "id": 3, "name": "Dimmer", "universe": 10, "start_address": 1, "fixture_type": "dimmer", "entity_id": "light.bar_rgb", "rate_limit_hz": 5, "deadband": 2, "fade_ms": 0, "enabled": True, "master_dimmer": False, } ) ] await service.dispatch_once() await service.dispatch_once() assert len(adapter.calls) == 1 @pytest.mark.asyncio async def test_scene_mapping_uses_rising_edge_only() -> None: adapter = FakeHomeAssistantAdapter() service = build_service(adapter) frame = DmxFrame(universe=10) service.engine.current_frames = {10: frame} service._mappings = [ service._normalize_mapping( { "id": 4, "name": "Party scene", "universe": 10, "start_address": 6, "fixture_type": "scene", "entity_id": "scene.party_mode", "enabled": True, "master_dimmer": False, } ) ] await service.dispatch_once() frame.set_channel(6, 140, "scene") await service.dispatch_once() await service.dispatch_once() assert len(adapter.calls) == 1 assert adapter.calls[0][2] == "scene.turn_on" @pytest.mark.asyncio async def test_switch_mapping_uses_hysteresis() -> None: adapter = FakeHomeAssistantAdapter() service = build_service(adapter) frame = DmxFrame(universe=10) service.engine.current_frames = {10: frame} service._mappings = [ service._normalize_mapping( { "id": 5, "name": "Switch", "universe": 10, "start_address": 50, "fixture_type": "switch", "entity_id": "switch.party_relay", "enabled": True, "master_dimmer": False, } ) ] frame.set_channel(50, 150, "scene") await service.dispatch_once() frame.set_channel(50, 130, "scene") await service.dispatch_once() frame.set_channel(50, 110, "scene") await service.dispatch_once() assert [call[2] for call in adapter.calls] == ["switch.turn_on", "switch.turn_off"] @pytest.mark.asyncio async def test_switch_fixture_routes_light_entity_by_entity_domain() -> None: adapter = FakeHomeAssistantAdapter() service = build_service(adapter) frame = DmxFrame(universe=10) service.engine.current_frames = {10: frame} service._mappings = [ service._normalize_mapping( { "id": 10, "name": "Light as switch", "universe": 10, "start_address": 20, "fixture_type": "switch", "entity_id": "light.test", "enabled": True, "master_dimmer": False, } ) ] frame.set_channel(20, 150, "scene") await service.dispatch_once() frame.set_channel(20, 110, "scene") await service.dispatch_once() mapping = (await service.list_mappings())["items"][0] assert [call[2] for call in adapter.calls] == ["light.turn_on", "light.turn_off"] assert mapping["last_sent_summary"] == "light.turn_off light.test" @pytest.mark.asyncio async def test_switch_fixture_routes_switch_entity_by_entity_domain() -> None: adapter = FakeHomeAssistantAdapter() service = build_service(adapter) frame = DmxFrame(universe=10) service.engine.current_frames = {10: frame} service._mappings = [ service._normalize_mapping( { "id": 11, "name": "Switch domain", "universe": 10, "start_address": 21, "fixture_type": "switch", "entity_id": "switch.test", "enabled": True, "master_dimmer": False, } ) ] frame.set_channel(21, 150, "scene") await service.dispatch_once() frame.set_channel(21, 110, "scene") await service.dispatch_once() assert [call[2] for call in adapter.calls] == ["switch.turn_on", "switch.turn_off"] @pytest.mark.asyncio async def test_dimmer_fixture_routes_light_entity_with_brightness() -> None: adapter = FakeHomeAssistantAdapter() service = build_service(adapter) frame = DmxFrame(universe=10) frame.set_channel(22, 180, "scene") service.engine.current_frames = {10: frame} await service.list_entities() service._mappings = [ service._normalize_mapping( { "id": 12, "name": "Dimmer light", "universe": 10, "start_address": 22, "fixture_type": "dimmer", "entity_id": "light.test", "enabled": True, "master_dimmer": False, } ) ] await service.dispatch_once() assert adapter.calls[0][2] == "light.turn_on" assert adapter.calls[0][3]["brightness"] == 180 @pytest.mark.asyncio async def test_scene_fixture_routes_scene_entity() -> None: adapter = FakeHomeAssistantAdapter() service = build_service(adapter) frame = DmxFrame(universe=10) service.engine.current_frames = {10: frame} service._mappings = [ service._normalize_mapping( { "id": 13, "name": "Scene test", "universe": 10, "start_address": 23, "fixture_type": "scene", "entity_id": "scene.test", "enabled": True, "master_dimmer": False, } ) ] frame.set_channel(23, 200, "scene") await service.dispatch_once() assert adapter.calls[0][2] == "scene.turn_on" @pytest.mark.asyncio async def test_automation_fixture_routes_automation_entity() -> None: adapter = FakeHomeAssistantAdapter() service = build_service(adapter) frame = DmxFrame(universe=10) service.engine.current_frames = {10: frame} service._mappings = [ service._normalize_mapping( { "id": 14, "name": "Automation test", "universe": 10, "start_address": 24, "fixture_type": "automation", "entity_id": "automation.test", "enabled": True, "master_dimmer": False, } ) ] frame.set_channel(24, 200, "scene") await service.dispatch_once() assert adapter.calls[0][2] == "automation.trigger" @pytest.mark.asyncio async def test_test_mapping_uses_same_entity_domain_routing() -> None: adapter = FakeHomeAssistantAdapter() service = build_service(adapter) await service.list_entities() service._mappings = [ service._normalize_mapping( { "id": 15, "name": "Test mapping light switch", "universe": 10, "start_address": 25, "fixture_type": "switch", "entity_id": "light.test", "enabled": True, "master_dimmer": False, } ) ] await service.test_mapping(15) assert adapter.calls[0][2] == "light.turn_on" @pytest.mark.asyncio async def test_test_mapping_resyncs_back_to_live_dmx() -> None: adapter = FakeHomeAssistantAdapter() service = build_service(adapter) frame = DmxFrame(universe=10) frame.set_channel(1, 120, "scene") service.engine.current_frames = {10: frame} await service.list_entities() service._mappings = [ service._normalize_mapping( { "id": 6, "name": "Dimmer", "universe": 10, "start_address": 1, "fixture_type": "dimmer", "entity_id": "light.bar_rgb", "enabled": True, "master_dimmer": False, } ) ] await service.test_mapping(6) await service.dispatch_once() assert adapter.calls[0][3]["brightness"] == 255 assert adapter.calls[-1][3]["brightness"] == 120 @pytest.mark.asyncio async def test_timeout_error_is_tracked_without_blocking() -> None: adapter = FakeHomeAssistantAdapter() adapter.dispatch_error = httpx.ReadTimeout("timeout") service = build_service(adapter) frame = DmxFrame(universe=10) frame.set_channel(1, 180, "scene") service.engine.current_frames = {10: frame} await service.list_entities() service._mappings = [ service._normalize_mapping( { "id": 7, "name": "Dimmer", "universe": 10, "start_address": 1, "fixture_type": "dimmer", "entity_id": "light.bar_rgb", "enabled": True, "master_dimmer": False, } ) ] await service.dispatch_once() mapping = (await service.list_mappings())["items"][0] assert mapping["last_error"] == "Timeout ved kald til Home Assistant" @pytest.mark.asyncio async def test_401_is_reported_from_connection_test() -> None: adapter = FakeHomeAssistantAdapter() request = httpx.Request("GET", "http://ha.local:8123/api/") response = httpx.Response(401, request=request) adapter.connection_error = httpx.HTTPStatusError("401", request=request, response=response) service = build_service(adapter) result = await service.test_connection() assert result["reachable"] is True assert result["auth_ok"] is False assert result["last_error"] == "HTTP 401 fra Home Assistant" @pytest.mark.asyncio async def test_404_entity_not_found_is_tracked() -> None: adapter = FakeHomeAssistantAdapter() request = httpx.Request("POST", "http://ha.local:8123/api/services/light/turn_on") response = httpx.Response(404, request=request) adapter.dispatch_error = httpx.HTTPStatusError("404", request=request, response=response) service = build_service(adapter) frame = DmxFrame(universe=10) frame.set_channel(1, 180, "scene") service.engine.current_frames = {10: frame} await service.list_entities() service._mappings = [ service._normalize_mapping( { "id": 8, "name": "Dimmer", "universe": 10, "start_address": 1, "fixture_type": "dimmer", "entity_id": "light.bar_rgb", "enabled": True, "master_dimmer": False, } ) ] await service.dispatch_once() mapping = (await service.list_mappings())["items"][0] assert mapping["last_error"] == "HTTP 404 fra Home Assistant" @pytest.mark.asyncio async def test_home_assistant_unavailable_is_reported() -> None: adapter = FakeHomeAssistantAdapter() adapter.connection_error = httpx.ConnectError("offline") service = build_service(adapter) result = await service.test_connection() assert result["reachable"] is False assert result["auth_ok"] is False assert result["last_error"] == "Home Assistant kunne ikke kontaktes" @pytest.mark.asyncio async def test_persistence_survives_service_reload() -> None: adapter = FakeHomeAssistantAdapter() service = build_service(adapter) await service.save_config( { "enabled": True, "base_url": "http://ha.local:8123", "token": "persistent-token", "default_universe": 10, } ) await service.create_mapping( { "name": "Persisted RGB", "universe": 10, "start_address": 1, "fixture_type": "rgb", "entity_id": "light.bar_rgb", "enabled": True, "master_dimmer": True, } ) reloaded = HomeAssistantService(service.engine, adapter=adapter) await reloaded._load() config = await reloaded.get_config() mappings = await reloaded.list_mappings() assert config["base_url"] == "http://ha.local:8123" assert config["has_token"] is True assert mappings["items"][0]["entity_id"] == "light.bar_rgb" @pytest.mark.asyncio async def test_slow_dispatch_drops_intermediate_frames_and_keeps_latest() -> None: adapter = FakeHomeAssistantAdapter() service = build_service(adapter) frame = DmxFrame(universe=10) frame.set_channel(1, 50, "scene") service.engine.current_frames = {10: frame} await service.list_entities() service._mappings = [ service._normalize_mapping( { "id": 9, "name": "Dimmer", "universe": 10, "start_address": 1, "fixture_type": "dimmer", "entity_id": "light.bar_rgb", "enabled": True, "master_dimmer": False, "deadband": 1, "rate_limit_hz": 30, } ) ] first_call = asyncio.Event() release_call = asyncio.Event() async def slow_call( base_url: str, token: str, domain: str, service_name: str, data: dict[str, object], ) -> dict[str, object]: adapter.calls.append((base_url, token, f"{domain}.{service_name}", data)) first_call.set() await release_call.wait() return {"ok": True} adapter.call_service = slow_call # type: ignore[method-assign] dispatch_task = asyncio.create_task(service._dispatch_enabled_mappings()) await first_call.wait() frame.set_channel(1, 120, "scene") await service._dispatch_enabled_mappings() frame.set_channel(1, 220, "scene") await service._dispatch_enabled_mappings() release_call.set() await dispatch_task await service._drain_mapping_tasks() assert adapter.calls[0][3]["brightness"] == 50 assert adapter.calls[-1][3]["brightness"] == 220 assert len(adapter.calls) == 2