Files
TuxDMX-WebUI/backend/tests/test_home_assistant_service.py
T
thomas 1f110866f5
CI / backend (pull_request) Canceled after 0s
CI / shell (pull_request) Canceled after 0s
CI / frontend (pull_request) Canceled after 0s
CI / arm64-smoke (pull_request) Canceled after 0s
CI / backend (push) Canceled after 0s
CI / shell (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / arm64-smoke (push) Canceled after 0s
Update docs and screenshots
2026-07-25 10:26:29 +02:00

683 lines
21 KiB
Python

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