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
683 lines
21 KiB
Python
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
|