Initial public release

This commit is contained in:
ajp_anton
2026-05-30 15:44:40 +00:00
commit cc4e0c2c0f
56 changed files with 11840 additions and 0 deletions
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
from pathlib import Path
import sys
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from ha_deconz_bridge.color import Color
from ha_deconz_bridge.lights import ChannelSpec, ControllerModeSpec, ControllerSpec, VirtualLightSpec
from ha_deconz_bridge.settings import Settings
@pytest.fixture
def settings() -> Settings:
return Settings()
def _rgb_controller(zigbee_id: str) -> ControllerSpec:
return ControllerSpec(
zigbee_id=zigbee_id,
name=f"RGB {zigbee_id}",
modes={
"rgb": ControllerModeSpec(
name="rgb",
channels={
"r": ChannelSpec("r", Color.from_rgb((1.0, 0.0, 0.0), brightness=0.9)),
"g": ChannelSpec("g", Color.from_rgb((0.0, 1.0, 0.0), brightness=1.2)),
"b": ChannelSpec("b", Color.from_rgb((0.0, 0.0, 1.0), brightness=0.8)),
},
)
},
)
def _ct_controller(zigbee_id: str) -> ControllerSpec:
return ControllerSpec(
zigbee_id=zigbee_id,
name=f"CT {zigbee_id}",
modes={
"color_temp": ControllerModeSpec(
name="color_temp",
channels={
"ww": ChannelSpec(
"ww",
Color.from_color_temp(500, brightness=1.0, temp_range=(153, 500)),
),
"cw": ChannelSpec(
"cw",
Color.from_color_temp(153, brightness=1.0, temp_range=(153, 500)),
),
},
)
},
)
@pytest.fixture
def rgb_light_spec() -> VirtualLightSpec:
return VirtualLightSpec(
name="rgb_light",
supported_color_modes=("rgb",),
controllers={"1": _rgb_controller("1")},
)
@pytest.fixture
def ct_light_spec() -> VirtualLightSpec:
return VirtualLightSpec(
name="ct_light",
supported_color_modes=("color_temp",),
controllers={"2": _ct_controller("2")},
)
@pytest.fixture
def mixed_light_spec() -> VirtualLightSpec:
shared = ControllerSpec(
zigbee_id="3",
name="Shared",
modes={
"rgb": ControllerModeSpec(
name="rgb",
channels={
"r": ChannelSpec("r", Color.from_rgb((1.0, 0.0, 0.0), brightness=0.6)),
"g": ChannelSpec("g", Color.from_rgb((0.0, 1.0, 0.0), brightness=0.6)),
"b": ChannelSpec("b", Color.from_rgb((0.0, 0.0, 1.0), brightness=0.4)),
},
),
"color_temp": ControllerModeSpec(
name="color_temp",
channels={
"ww": ChannelSpec(
"ww",
Color.from_color_temp(500, brightness=0.9, temp_range=(153, 500)),
),
"cw": ChannelSpec(
"cw",
Color.from_color_temp(153, brightness=0.9, temp_range=(153, 500)),
),
},
),
},
)
onoff = ControllerSpec(
zigbee_id="4",
name="Accent",
modes={
"onoff": ControllerModeSpec(
name="onoff",
channels={"w": ChannelSpec("w", Color.from_rgb((1.0, 1.0, 1.0), brightness=0.3))},
)
},
)
return VirtualLightSpec(
name="mixed_light",
supported_color_modes=("rgb", "color_temp"),
controllers={
"2": _ct_controller("2"),
"3": shared,
"4": onoff,
},
)
@pytest.fixture
def red_only_spec() -> VirtualLightSpec:
return VirtualLightSpec(
name="red_only",
supported_color_modes=("rgb",),
controllers={
"1": ControllerSpec(
zigbee_id="1",
name="Red only",
modes={
"rgb": ControllerModeSpec(
name="rgb",
channels={
"r": ChannelSpec("r", Color.from_rgb((1.0, 0.0, 0.0), brightness=1.0)),
},
)
},
)
},
)
+186
View File
@@ -0,0 +1,186 @@
from __future__ import annotations
from datetime import datetime
from ha_deconz_bridge.automation import (
ActionSpec,
AutomationEngine,
AutomationRule,
RemoteGestureDetector,
RemoteSpec,
TimeCondition,
automation_rules,
)
def test_hue_dimmer_short_press_maps_to_single_and_double() -> None:
remote = RemoteSpec("4", "room", "Room remote", double_press_window=0.5)
detector = RemoteGestureDetector(default_double_press_window=0.45)
first = detector.handle(
remote,
{"buttonevent": 1002, "eventduration": 1},
received_at=10.0,
)
detector.handle(remote, {"buttonevent": 1000, "eventduration": 0}, received_at=10.2)
second = detector.handle(
remote,
{"buttonevent": 1002, "eventduration": 1},
received_at=10.3,
)
assert [(event.button, event.gesture) for event in first] == [("top", "single")]
assert [(event.button, event.gesture) for event in second] == [("top", "double")]
def test_double_press_state_is_per_physical_remote() -> None:
detector = RemoteGestureDetector(default_double_press_window=0.45)
first_remote = RemoteSpec("4", "room", "First")
second_remote = RemoteSpec("5", "room", "Second")
detector.handle(first_remote, {"buttonevent": 1002}, received_at=10.0)
events = detector.handle(second_remote, {"buttonevent": 1002}, received_at=10.2)
assert [event.gesture for event in events] == ["single"]
def test_hue_dimmer_long_hold_maps_to_repeat_and_release() -> None:
remote = RemoteSpec("4", "room", "Room remote")
detector = RemoteGestureDetector(default_double_press_window=0.45)
repeat = detector.handle(
remote,
{"buttonevent": 2001, "eventduration": 8},
received_at=10.8,
)
release = detector.handle(
remote,
{"buttonevent": 2003, "eventduration": 22},
received_at=12.2,
)
assert [(event.button, event.gesture, event.eventduration, event.hold_count) for event in repeat] == [
("up", "hold", 8, 1)
]
assert [(event.button, event.gesture, event.eventduration) for event in release] == [
("up", "long", 22)
]
def test_double_hold_and_double_long_use_same_physical_remote() -> None:
remote = RemoteSpec("4", "room", "Room remote", double_press_window=0.5)
detector = RemoteGestureDetector(default_double_press_window=0.45)
detector.handle(remote, {"buttonevent": 1002}, received_at=10.0)
detector.handle(remote, {"buttonevent": 1000}, received_at=10.2)
first_hold = detector.handle(remote, {"buttonevent": 1001, "eventduration": 8}, received_at=10.4)
repeat_hold = detector.handle(remote, {"buttonevent": 1001, "eventduration": 16}, received_at=11.2)
release = detector.handle(remote, {"buttonevent": 1003, "eventduration": 20}, received_at=11.6)
assert [(event.gesture, event.hold_count) for event in first_hold] == [("double_hold", 1)]
assert [(event.gesture, event.hold_count) for event in repeat_hold] == [("double_hold", 2)]
assert [event.gesture for event in release] == ["double_long"]
def test_double_long_state_is_per_physical_remote() -> None:
detector = RemoteGestureDetector(default_double_press_window=0.45)
first_remote = RemoteSpec("4", "room", "First")
second_remote = RemoteSpec("5", "room", "Second")
detector.handle(first_remote, {"buttonevent": 1002}, received_at=10.0)
detector.handle(second_remote, {"buttonevent": 1000}, received_at=10.1)
events = detector.handle(second_remote, {"buttonevent": 1001}, received_at=10.2)
assert [event.gesture for event in events] == ["hold"]
def test_hold_count_gesture_patterns() -> None:
action = ActionSpec("turn_off", target="Example Living")
engine = AutomationEngine(
remotes={"4": RemoteSpec("4", "room", "Room remote")},
rules=(
AutomationRule("room", "up", "hold", (action,)),
AutomationRule("room", "up", "hold_2", (action,)),
AutomationRule("room", "up", "hold_2_2", (action,)),
AutomationRule("room", "up", "hold_2_3", (action,)),
AutomationRule("room", "up", "hold_3_2", (action,)),
),
default_double_press_window=0.45,
)
first = engine.handle_sensor_event("4", {"buttonevent": 2001}, received_at=10.0)
second = engine.handle_sensor_event("4", {"buttonevent": 2001}, received_at=11.0)
third = engine.handle_sensor_event("4", {"buttonevent": 2001}, received_at=12.0)
fourth = engine.handle_sensor_event("4", {"buttonevent": 2001}, received_at=13.0)
assert first is not None
assert second is not None
assert third is not None
assert fourth is not None
assert len(first[1]) == 1
assert len(second[1]) == 4
assert len(third[1]) == 3
assert len(fourth[1]) == 2
def test_automation_rules_bind_to_internal_remote_id() -> None:
action = ActionSpec("turn_off", target="Example Living")
engine = AutomationEngine(
remotes={
"4": RemoteSpec("4", "room", "First"),
"5": RemoteSpec("5", "room", "Second"),
},
rules=(
AutomationRule(
remote_id="room",
button="bottom",
gesture="single",
actions=(action,),
),
),
default_double_press_window=0.45,
)
first = engine.handle_sensor_event(
"4",
{"buttonevent": 4002},
received_at=10.0,
)
second = engine.handle_sensor_event(
"5",
{"buttonevent": 4002},
received_at=20.0,
)
assert first is not None
assert second is not None
assert first[1] == (action,)
assert second[1] == (action,)
def test_time_condition_handles_midnight_wrap() -> None:
condition = TimeCondition("22:00", "06:00")
assert condition.matches(datetime(2026, 1, 1, 23, 0))
assert condition.matches(datetime(2026, 1, 1, 5, 59))
assert not condition.matches(datetime(2026, 1, 1, 12, 0))
def test_automation_rules_can_be_built_from_nested_config() -> None:
action = ActionSpec("turn_off", target="Example Living")
rules = automation_rules(
{
"remote": {
"bottom": {
"single": (action,),
"hold_2_0": (action,),
},
},
}
)
assert [(rule.remote_id, rule.button, rule.gesture, rule.actions) for rule in rules] == [
("remote", "bottom", "single", (action,)),
("remote", "bottom", "hold_2_0", (action,)),
]
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
import numpy as np
from ha_deconz_bridge.color import Color
def test_rgb_roundtrip_is_stable() -> None:
color = Color.from_rgb((32, 128, 255), brightness=0.5)
roundtrip = Color.from_xy(color.xy, brightness=color.brightness)
assert np.linalg.norm(color.uv - roundtrip.uv) < 0.05
assert roundtrip.brightness == color.brightness
def test_weighted_sum_of_ct_colors_uses_xyz_mixing() -> None:
warm = Color.from_color_temp(450, brightness=0.8)
cool = Color.from_color_temp(200, brightness=0.2)
mixed = Color.weighted_sum((warm, cool), (1.0, 1.0))
assert mixed.mode == "rgb"
assert 200 < mixed.color_temp < 450
assert mixed.brightness == 1.0
def test_weighted_sum_promotes_to_rgb_when_any_rgb_present() -> None:
white = Color.from_color_temp(300, brightness=0.5)
red = Color.from_rgb((1.0, 0.0, 0.0), brightness=0.25)
mixed = Color.weighted_sum((white, red), (1.0, 1.0))
assert mixed.mode == "rgb"
assert mixed.brightness > 0.0
def test_ct_mix_inverts_back_to_midpoint() -> None:
color = Color.from_color_temp(326, brightness=1.0, temp_range=(153, 500))
cool, warm = color.ct_mix((153, 500))
assert 0.0 < cool <= 1.0
assert 0.0 < warm <= 1.0
def test_color_temp_preserves_requested_mireds_for_serialization() -> None:
color = Color.from_color_temp(455, brightness=1.0, temp_range=(153, 500))
dimmed = color.with_brightness(0.2)
assert color.color_temp == 455
assert dimmed.color_temp == 455
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
import pytest
from ha_deconz_bridge.groups import GroupMemberSpec, GroupSpec, derive_group_modes
def test_group_modes_are_member_union(rgb_light_spec, ct_light_spec) -> None:
group = GroupSpec(
name="mixed",
members=(rgb_light_spec.name, ct_light_spec.name),
)
modes = derive_group_modes(
group,
{
rgb_light_spec.name: rgb_light_spec,
ct_light_spec.name: ct_light_spec,
},
)
assert modes == ("rgb", "color_temp")
def test_fixed_color_member_acts_as_brightness_mode(ct_light_spec) -> None:
group = GroupSpec(
name="fixed",
members=(GroupMemberSpec(ct_light_spec.name, fixed_color=500),),
)
assert derive_group_modes(group, {ct_light_spec.name: ct_light_spec}) == ("brightness",)
def test_fixed_brightness_member_acts_as_onoff_mode(ct_light_spec) -> None:
group = GroupSpec(
name="fixed",
members=(GroupMemberSpec(ct_light_spec.name, fixed_color=500, fixed_brightness=0.5),),
)
assert derive_group_modes(group, {ct_light_spec.name: ct_light_spec}) == ("onoff",)
def test_fixed_color_normalizes_mired_kelvin_rgb_and_xy() -> None:
mired_member = GroupMemberSpec("a", fixed_color=500)
kelvin_member = GroupMemberSpec("a", fixed_color=4000)
rgb_member = GroupMemberSpec("a", fixed_color=(255, 128, 0))
xy_member = GroupMemberSpec("a", fixed_color=(0.31, 0.32))
assert mired_member.fixed_color is not None
assert mired_member.fixed_color.kind == "color_temp"
assert mired_member.fixed_color.color_temp == 500
assert kelvin_member.fixed_color is not None
assert kelvin_member.fixed_color.color_temp == 250
assert rgb_member.fixed_color is not None
assert rgb_member.fixed_color.kind == "rgb"
assert max(rgb_member.fixed_color.rgb255) == 255
assert xy_member.fixed_color is not None
assert xy_member.fixed_color.kind == "xy"
def test_fixed_brightness_requires_fixed_color() -> None:
with pytest.raises(ValueError, match="fixed_brightness requires fixed_color"):
GroupMemberSpec("a", fixed_brightness=128)
def test_fixed_brightness_normalizes_relative_and_ha_values() -> None:
relative = GroupMemberSpec("a", fixed_color=500, fixed_brightness=0.5)
ha_value = GroupMemberSpec("a", fixed_color=500, fixed_brightness=127)
assert relative.fixed_brightness == 128
assert ha_value.fixed_brightness == 127
def test_example_groups_reference_existing_lights() -> None:
from configs.example_groups import GROUPS
from configs.example_lights import LIGHTS
missing = {
group_name: [
member.light_name
for member in group.member_specs()
if member.light_name not in LIGHTS
]
for group_name, group in GROUPS.items()
}
assert {name: names for name, names in missing.items() if names} == {}
+125
View File
@@ -0,0 +1,125 @@
from __future__ import annotations
import json
from ha_deconz_bridge.ha import HomeAssistantBridge
from ha_deconz_bridge.lights import ChannelSpec, ControllerModeSpec, ControllerSpec, VirtualLightSpec
from ha_deconz_bridge.light import VirtualLightState
from ha_deconz_bridge.color import Color
class FakeMQTTClient:
def __init__(self) -> None:
self.connected = False
self.on_connect = None
self.on_message = None
self.published: list[tuple[str, str, bool]] = []
self.subscriptions: list[str] = []
def username_pw_set(self, username, password) -> None:
self.username = username
self.password = password
def connect(self, host: str, port: int, keepalive: int) -> None:
self.host = host
self.port = port
self.keepalive = keepalive
def loop_start(self) -> None:
self.connected = True
if self.on_connect is not None:
self.on_connect(self, None, None, 0)
def loop_stop(self) -> None:
self.connected = False
def disconnect(self) -> None:
self.connected = False
def publish(self, topic: str, payload: str, retain: bool = False):
if not self.connected:
raise RuntimeError("publish before MQTT connect")
self.published.append((topic, payload, retain))
def subscribe(self, topic: str) -> None:
if not self.connected:
raise RuntimeError("subscribe before MQTT connect")
self.subscriptions.append(topic)
def test_bridge_waits_for_connect_before_discovery(settings, rgb_light_spec) -> None:
mqtt = FakeMQTTClient()
bridge = HomeAssistantBridge(settings, mqtt_client=mqtt)
bridge.start(on_command=lambda event: None)
bridge.publish_discovery(rgb_light_spec)
bridge.publish_state(
rgb_light_spec,
VirtualLightState(
on=True,
color=rgb_light_spec.controllers["1"].modes["rgb"].channels["r"].color,
ha_mode="rgb",
brightness=128,
exact_chroma_match=True,
),
)
assert mqtt.published[0][0] == "homeassistant/light/rgb_light/config"
assert json.loads(mqtt.published[0][1])["schema"] == "json"
assert json.loads(mqtt.published[0][1])["name"] == "rgb_light"
assert mqtt.subscriptions == ["ha-deconz-bridge/light/rgb_light/set"]
def test_bridge_sanitizes_mqtt_object_id_for_discovery_topics(settings) -> None:
mqtt = FakeMQTTClient()
bridge = HomeAssistantBridge(settings, mqtt_client=mqtt)
bridge.start(on_command=lambda event: None)
spec = VirtualLightSpec(
name="Example Light North",
supported_color_modes=("brightness",),
controllers={
"example_controller": ControllerSpec(
zigbee_id="example_controller",
name="Example controller",
modes={
"brightness": ControllerModeSpec(
name="brightness",
channels={
"w": ChannelSpec(
"w",
Color.from_color_temp(182, brightness=1.0),
semantic_label="color_temp",
)
},
)
},
)
},
)
bridge.publish_discovery(spec)
assert mqtt.published[0][0] == "homeassistant/light/Example_Light_North/config"
payload = json.loads(mqtt.published[0][1])
assert payload["name"] == "Example Light North"
assert payload["unique_id"] == "Example_Light_North"
assert payload["device"]["identifiers"] == ["ha-deconz-bridge:Example_Light_North"]
assert payload["device"]["name"] == "HA deCONZ Bridge: Example Light North"
assert payload["command_topic"] == "ha-deconz-bridge/light/Example_Light_North/set"
assert payload["state_topic"] == "ha-deconz-bridge/light/Example_Light_North/state"
assert mqtt.subscriptions == ["ha-deconz-bridge/light/Example_Light_North/set"]
def test_bridge_resubscribes_to_command_topics_on_reconnect(settings, rgb_light_spec) -> None:
mqtt = FakeMQTTClient()
bridge = HomeAssistantBridge(settings, mqtt_client=mqtt)
bridge.start(on_command=lambda event: None)
bridge.publish_discovery(rgb_light_spec)
mqtt.loop_stop()
mqtt.loop_start()
assert mqtt.subscriptions == [
"ha-deconz-bridge/light/rgb_light/set",
"ha-deconz-bridge/light/rgb_light/set",
]
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
from ha_deconz_bridge.events import DeconzEvent, DeconzSensorEvent, HaCommandEvent
from ha_deconz_bridge.queueing import EventBroker
def test_event_broker_coalesces_latest_ha_command() -> None:
broker = EventBroker()
broker.put_ha(HaCommandEvent("light", on=True, brightness=10))
broker.put_ha(HaCommandEvent("light", on=True, brightness=200))
broker.put_deconz(DeconzEvent("1", {"on": True}))
first = broker.get()
second = broker.get()
assert isinstance(first, HaCommandEvent)
assert isinstance(second, DeconzEvent)
assert first.brightness == 200
def test_event_broker_merges_partial_ha_commands() -> None:
broker = EventBroker()
broker.put_ha(HaCommandEvent("light", on=True, brightness=120, color_temp=400))
broker.put_ha(HaCommandEvent("light", rgb=(10, 20, 30)))
event = broker.get()
assert isinstance(event, HaCommandEvent)
assert event.on is True
assert event.brightness == 120
assert event.rgb == (10, 20, 30)
assert event.color_temp is None
def test_event_broker_moves_merged_ha_command_to_back() -> None:
broker = EventBroker()
broker.put_ha(HaCommandEvent("first", brightness=1))
broker.put_ha(HaCommandEvent("second", brightness=2))
broker.put_ha(HaCommandEvent("first", rgb=(1, 2, 3)))
first = broker.get()
second = broker.get()
assert isinstance(first, HaCommandEvent)
assert isinstance(second, HaCommandEvent)
assert first.light_name == "second"
assert second.light_name == "first"
assert second.brightness == 1
assert second.rgb == (1, 2, 3)
def test_event_broker_deduplicates_pending_deconz_events_by_controller() -> None:
broker = EventBroker()
broker.put_deconz(DeconzEvent("1", {"bri": 10}))
broker.put_deconz(DeconzEvent("2", {"bri": 20}))
broker.put_deconz(DeconzEvent("1", {"bri": 30}))
first = broker.get()
second = broker.get()
assert isinstance(first, DeconzEvent)
assert isinstance(second, DeconzEvent)
assert first.controller_id == "2"
assert first.raw_state == {"bri": 20}
assert second.controller_id == "1"
assert second.raw_state == {"bri": 30}
def test_event_broker_prioritizes_sensor_then_ha_then_light_events() -> None:
broker = EventBroker()
broker.put_ha(HaCommandEvent("light", on=True, brightness=10))
broker.put_deconz(DeconzEvent("1", {"on": True}))
broker.put_sensor(DeconzSensorEvent("4", {"buttonevent": 1002}))
assert isinstance(broker.get(), DeconzSensorEvent)
assert isinstance(broker.get(), HaCommandEvent)
assert isinstance(broker.get(), DeconzEvent)
def test_event_broker_does_not_let_light_readbacks_starve_ha_commands() -> None:
broker = EventBroker()
broker.put_deconz(DeconzEvent("1", {"bri": 10}))
broker.put_deconz(DeconzEvent("2", {"bri": 20}))
broker.put_ha(HaCommandEvent("light", on=True, brightness=255))
event = broker.get()
assert isinstance(event, HaCommandEvent)
assert event.brightness == 255
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
from ha_deconz_bridge.settings import Settings
def test_from_env_uses_dataclass_defaults(monkeypatch) -> None:
monkeypatch.delenv("HA_DECONZ_BRIDGE_DECONZ_RETRY_TIMEOUT", raising=False)
monkeypatch.delenv("HA_DECONZ_BRIDGE_MQTT_HOST", raising=False)
monkeypatch.delenv("HA_DECONZ_BRIDGE_TARGET_CACHE_SCALE", raising=False)
monkeypatch.delenv("HOME_LIGHTS_DECONZ_RETRY_TIMEOUT", raising=False)
monkeypatch.delenv("HOME_LIGHTS_MQTT_HOST", raising=False)
monkeypatch.delenv("HOME_LIGHTS_TARGET_CACHE_SCALE", raising=False)
settings = Settings.from_env()
assert settings.mqtt_host == "homeassistant"
assert settings.deconz_retry_timeout == 2.0
assert settings.target_cache_scale == 10000
def test_from_env_reads_target_cache_scale(monkeypatch) -> None:
monkeypatch.setenv("HA_DECONZ_BRIDGE_TARGET_CACHE_SCALE", "750")
settings = Settings.from_env()
assert settings.target_cache_scale == 750
def test_from_env_reads_warmup_flag(monkeypatch) -> None:
monkeypatch.setenv("HA_DECONZ_BRIDGE_WARMUP_ON_STARTUP", "false")
settings = Settings.from_env()
assert settings.warmup_on_startup is False
def test_from_env_keeps_legacy_prefix_fallback(monkeypatch) -> None:
monkeypatch.delenv("HA_DECONZ_BRIDGE_MQTT_HOST", raising=False)
monkeypatch.setenv("HOME_LIGHTS_MQTT_HOST", "legacy-host")
settings = Settings.from_env()
assert settings.mqtt_host == "legacy-host"
+688
View File
@@ -0,0 +1,688 @@
from __future__ import annotations
from configs.example import GROUPS, LIGHTS
from ha_deconz_bridge.color import Color
from ha_deconz_bridge.debugger.web_debugger import _combine_specs, _group_debug_spec
from ha_deconz_bridge.lights import ChannelSpec, ControllerModeSpec, ControllerSpec, VirtualLightSpec
from ha_deconz_bridge.solver import solve_light
from ha_deconz_bridge.solver_model import compile_light_model
from ha_deconz_bridge.translate import ct_from_mix
def test_solver_reaches_exact_rgb_target(rgb_light_spec) -> None:
model = compile_light_model(rgb_light_spec)
target = Color.from_rgb((255, 0, 0), brightness=1.0)
result = solve_light(model, target, brightness=255, requested_mode="rgb")
assert result.exact_chroma_match is True
assert result.achieved_color.rgb[0] > 0.95
assert result.achieved_color.rgb[1] < 0.2
assert result.controller_modes["1"] == "rgb"
def test_solver_reaches_exact_ct_target(ct_light_spec) -> None:
model = compile_light_model(ct_light_spec)
target = Color.from_color_temp(400, brightness=1.0, temp_range=ct_light_spec.exposed_mireds)
result = solve_light(model, target, brightness=255, requested_mode="color_temp")
assert result.exact_chroma_match is False
assert result.achieved_color.brightness > 1.0
assert result.chroma_error < 0.02
def test_solver_prefers_native_ct_mode(mixed_light_spec) -> None:
model = compile_light_model(mixed_light_spec)
target = Color.from_color_temp(450, brightness=1.0, temp_range=mixed_light_spec.exposed_mireds)
result = solve_light(model, target, brightness=200, requested_mode="color_temp")
assert result.controller_modes["2"] == "color_temp"
assert result.chroma_error < 0.02
def test_solver_clamps_out_of_gamut_without_drift(red_only_spec) -> None:
model = compile_light_model(red_only_spec)
request = Color.from_rgb((0, 255, 0), brightness=1.0)
first = solve_light(model, request, brightness=255, requested_mode="rgb")
assert first.exact_chroma_match is False
second = solve_light(
model,
Color.from_rgb(first.achieved_color.rgb, brightness=1.0),
brightness=255,
requested_mode="rgb",
)
assert second.achieved_color.uv_distance(first.achieved_color) < 1e-6
def test_low_brightness_extreme_ct_uses_extreme_channel(ct_light_spec) -> None:
model = compile_light_model(ct_light_spec)
target = Color.from_color_temp(500, brightness=1.0, temp_range=ct_light_spec.exposed_mireds)
result = solve_light(model, target, brightness=16, requested_mode="color_temp")
assert result.controller_commands["2"].color_temp >= 480
assert result.controller_commands["2"].brightness <= 16
def test_rgb_request_uses_only_rgb_labeled_channels_when_available() -> None:
spec = VirtualLightSpec(
name="mixed_priority",
supported_color_modes=("rgb", "color_temp"),
controllers={
"rgb": ControllerSpec(
zigbee_id="rgb",
name="RGB",
modes={
"rgb": ControllerModeSpec(
name="rgb",
channels={
"r": ChannelSpec("r", Color.from_rgb((1.0, 0.0, 0.0), brightness=0.5), "rgb"),
"g": ChannelSpec("g", Color.from_rgb((0.0, 1.0, 0.0), brightness=1.0), "rgb"),
"b": ChannelSpec("b", Color.from_rgb((0.0, 0.0, 1.0), brightness=0.5), "rgb"),
},
)
},
),
"ct": ControllerSpec(
zigbee_id="ct",
name="CT",
modes={
"color_temp": ControllerModeSpec(
name="color_temp",
channels={
"ww": ChannelSpec(
"ww",
Color.from_color_temp(450, brightness=0.8, temp_range=(153, 500)),
"color_temp",
),
"cw": ChannelSpec(
"cw",
Color.from_color_temp(200, brightness=0.8, temp_range=(153, 500)),
"color_temp",
),
},
)
},
),
},
)
model = compile_light_model(spec)
target = Color.from_rgb((128, 255, 128), brightness=1.0)
low = solve_light(model, target, brightness=96, requested_mode="rgb")
assert low.controller_commands["ct"].on is False
high = solve_light(model, target, brightness=255, requested_mode="rgb")
assert high.controller_commands["ct"].on is False
assert all(".rgb." in channel for channel in high.channel_levels)
def test_rgb_request_can_use_non_rgb_labels_when_no_rgb_labels_exist() -> None:
spec = VirtualLightSpec(
name="miswired_rgb",
supported_color_modes=("rgb",),
controllers={
"rgb": ControllerSpec(
zigbee_id="rgb",
name="Miswired RGB",
modes={
"rgb": ControllerModeSpec(
name="rgb",
channels={
"r": ChannelSpec(
"r",
Color.from_color_temp(500, brightness=1.0, temp_range=(153, 500)),
"color_temp",
),
"g": ChannelSpec(
"g",
Color.from_color_temp(300, brightness=1.0, temp_range=(153, 500)),
"color_temp",
),
"b": ChannelSpec(
"b",
Color.from_color_temp(153, brightness=1.0, temp_range=(153, 500)),
"color_temp",
),
},
)
},
),
},
)
model = compile_light_model(spec)
result = solve_light(model, Color.from_rgb((255, 255, 255), brightness=1.0), brightness=255, requested_mode="rgb")
assert result.controller_commands["rgb"].on is True
assert result.channel_levels
def test_onoff_booster_is_held_back_until_dimmables_are_exhausted() -> None:
spec = VirtualLightSpec(
name="boosters",
supported_color_modes=("brightness",),
controllers={
"dim": ControllerSpec(
zigbee_id="dim",
name="Dim",
modes={
"brightness": ControllerModeSpec(
name="brightness",
channels={
"w": ChannelSpec(
"w",
Color.from_color_temp(350, brightness=1.0, temp_range=(153, 500)),
"color_temp",
)
},
)
},
),
"plug": ControllerSpec(
zigbee_id="plug",
name="Plug",
modes={
"onoff": ControllerModeSpec(
name="onoff",
channels={
"w": ChannelSpec(
"w",
Color.from_color_temp(350, brightness=0.7, temp_range=(153, 500)),
"color_temp",
)
},
)
},
),
},
)
model = compile_light_model(spec)
target = Color.from_color_temp(350, brightness=1.0, temp_range=(153, 500))
low = solve_light(model, target, brightness=96, requested_mode="brightness")
assert low.controller_commands["plug"].on is False
high = solve_light(model, target, brightness=255, requested_mode="brightness")
assert high.controller_commands["plug"].on is True
def test_brightness_mode_compensates_when_onoff_booster_turns_on() -> None:
spec = _combine_specs(LIGHTS, ["Example Plug A", "Example Fixed White A"])
model = compile_light_model(spec)
target = Color.from_rgb((255, 255, 255), brightness=1.0)
before_switch = solve_light(model, target, brightness=120, requested_mode="brightness")
assert before_switch.controller_commands["plug_a"].on is False
assert before_switch.controller_commands["fixed_a"].on is True
after_switch = solve_light(model, target, brightness=200, requested_mode="brightness")
assert after_switch.controller_commands["plug_a"].on is True
assert after_switch.channel_levels["plug_a.onoff.w"] == 1.0
assert after_switch.channel_levels["fixed_a.color_temp.ww"] < 1.0
above_booster = solve_light(model, target, brightness=255, requested_mode="brightness")
assert above_booster.controller_commands["plug_a"].on is True
assert above_booster.controller_commands["fixed_a"].on is True
assert above_booster.channel_levels["fixed_a.color_temp.ww"] == 1.0
def test_ct_request_keeps_native_ct_saturated_while_rgb_boosting() -> None:
spec = VirtualLightSpec(
name="ct_priority",
supported_color_modes=("rgb", "color_temp"),
controllers={
"rgb": ControllerSpec(
zigbee_id="rgb",
name="RGB",
modes={
"rgb": ControllerModeSpec(
name="rgb",
channels={
"r": ChannelSpec("r", Color.from_xy((0.68, 0.31), brightness=180.0), "rgb"),
"g": ChannelSpec("g", Color.from_xy((0.18, 0.70), brightness=360.0), "rgb"),
"b": ChannelSpec("b", Color.from_xy((0.14, 0.05), brightness=110.0), "rgb"),
},
)
},
),
"ct": ControllerSpec(
zigbee_id="ct",
name="CT",
modes={
"color_temp": ControllerModeSpec(
name="color_temp",
channels={
"ww": ChannelSpec(
"ww",
Color.from_color_temp(500, brightness=340.0, temp_range=(153, 500)),
"color_temp",
),
"cw": ChannelSpec(
"cw",
Color.from_color_temp(153, brightness=360.0, temp_range=(153, 500)),
"color_temp",
),
},
)
},
),
},
)
model = compile_light_model(spec)
target = Color.from_color_temp(330, brightness=1.0, temp_range=(153, 500))
low = solve_light(model, target, brightness=160, requested_mode="color_temp")
threshold = solve_light(model, target, brightness=161, requested_mode="color_temp")
assert low.controller_commands["rgb"].on is True
assert threshold.controller_commands["rgb"].on is True
assert low.controller_commands["ct"].brightness == 255
assert threshold.controller_commands["ct"].brightness == 255
assert abs(low.controller_commands["ct"].color_temp - threshold.controller_commands["ct"].color_temp) <= 1
assert abs(low.channel_levels["ct.color_temp.ww"] - threshold.channel_levels["ct.color_temp.ww"]) < 1e-6
assert threshold.channel_levels["ct.color_temp.ww"] > 0.99
assert threshold.channel_levels["ct.color_temp.cw"] > low.channel_levels["ct.color_temp.cw"]
assert low.controller_commands["rgb"].brightness is not None
assert threshold.controller_commands["rgb"].brightness is not None
assert threshold.controller_commands["rgb"].brightness > low.controller_commands["rgb"].brightness
assert threshold.controller_commands["rgb"].brightness < 100
def test_ct_request_does_not_trade_down_warm_extreme_for_tiny_rgb_gain() -> None:
spec = VirtualLightSpec(
name="ct_warm_priority",
supported_color_modes=("rgb", "color_temp"),
controllers={
"rgb": ControllerSpec(
zigbee_id="rgb",
name="RGB",
modes={
"rgb": ControllerModeSpec(
name="rgb",
channels={
"r": ChannelSpec("r", Color.from_xy((0.68, 0.31), brightness=180.0), "rgb"),
"g": ChannelSpec("g", Color.from_xy((0.18, 0.70), brightness=360.0), "rgb"),
"b": ChannelSpec("b", Color.from_xy((0.14, 0.05), brightness=110.0), "rgb"),
},
)
},
),
"ct": ControllerSpec(
zigbee_id="ct",
name="CT",
modes={
"color_temp": ControllerModeSpec(
name="color_temp",
channels={
"ww": ChannelSpec(
"ww",
Color.from_color_temp(500, brightness=340.0, temp_range=(153, 500)),
"color_temp",
),
"cw": ChannelSpec(
"cw",
Color.from_color_temp(153, brightness=360.0, temp_range=(153, 500)),
"color_temp",
),
},
)
},
),
},
)
model = compile_light_model(spec)
target = Color.from_color_temp(505, brightness=1.0, temp_range=(153, 500))
low = solve_light(model, target, brightness=126, requested_mode="color_temp")
assert low.controller_commands["rgb"].on is True
assert low.channel_levels["ct.color_temp.ww"] > 0.9
near_threshold = solve_light(model, target, brightness=127, requested_mode="color_temp")
assert near_threshold.controller_commands["rgb"].on is True
assert near_threshold.channel_levels["ct.color_temp.ww"] >= low.channel_levels["ct.color_temp.ww"]
assert near_threshold.controller_commands["rgb"].brightness is not None
assert low.controller_commands["rgb"].brightness is not None
assert near_threshold.controller_commands["rgb"].brightness >= low.controller_commands["rgb"].brightness
assert near_threshold.controller_commands["ct"].brightness is not None
assert near_threshold.controller_commands["ct"].brightness >= 230
def test_ct_line_balancing_uses_comparable_same_label_channels_stably() -> None:
spec = VirtualLightSpec(
name="ct_stability",
supported_color_modes=("color_temp",),
controllers={
"near": ControllerSpec(
zigbee_id="near",
name="Near CW",
modes={
"color_temp": ControllerModeSpec(
name="color_temp",
channels={
"cw": ChannelSpec(
"cw",
Color.from_color_temp(190, brightness=0.8, temp_range=(153, 500)),
"color_temp",
),
},
)
},
),
"far": ControllerSpec(
zigbee_id="far",
name="Far CW",
modes={
"color_temp": ControllerModeSpec(
name="color_temp",
channels={
"cw": ChannelSpec(
"cw",
Color.from_color_temp(153, brightness=1.0, temp_range=(153, 500)),
"color_temp",
),
},
)
},
),
"warm": ControllerSpec(
zigbee_id="warm",
name="Warm",
modes={
"color_temp": ControllerModeSpec(
name="color_temp",
channels={
"ww": ChannelSpec(
"ww",
Color.from_color_temp(500, brightness=1.0, temp_range=(153, 500)),
"color_temp",
),
},
)
},
),
},
)
model = compile_light_model(spec)
target = Color.from_color_temp(330, brightness=1.0, temp_range=(153, 500))
for brightness in range(96, 181, 7):
result = solve_light(model, target, brightness=brightness, requested_mode="color_temp")
assert result.channel_levels.get("near.color_temp.cw", 0.0) > 0.0
assert result.channel_levels.get("far.color_temp.cw", 0.0) > 0.0
assert abs(result.achieved_color.color_temp - 330) <= 2
def test_rgb_request_does_not_use_white_boost_when_rgb_labeled_channels_exist() -> None:
spec = _combine_specs(LIGHTS, ["Example Fixed White A", "Example Mixed North"])
model = compile_light_model(spec)
target = Color.from_rgb((255, 255, 255), brightness=1.0)
result = solve_light(model, target, brightness=103, requested_mode="rgb")
assert all(".rgb." in channel for channel in result.channel_levels)
assert result.controller_commands["fixed_a"].on is False
assert result.controller_commands["mixed_ct"].on is False
def test_example_fixed_white_controller_uses_full_ct_command_range() -> None:
controller = LIGHTS["Example Fixed White A"].controllers["fixed_a"]
assert controller.native_ct_mireds == (153, 500)
def test_example_standard_ct_controllers_use_configured_ct_command_range() -> None:
for light_name in ["Example Mixed North", "Example Hall Floor", "Example Hall Corner", "Example Hall Cabinets"]:
for controller in LIGHTS[light_name].controllers.values():
if "color_temp" in controller.modes:
assert controller.native_ct_mireds == (153, 500)
def test_ct_request_uses_native_ct_mode_on_rgb_cct_controller() -> None:
spec = LIGHTS["Example Kitchen Counter"]
result = solve_light(
compile_light_model(spec),
Color.from_color_temp(455, brightness=1.0),
brightness=51,
requested_mode="color_temp",
)
assert result.controller_modes == {"kitchen_counter": "color_temp"}
assert result.controller_commands["kitchen_counter"].mode == "color_temp"
peak = max(result.channel_levels["kitchen_counter.color_temp.cw"], result.channel_levels["kitchen_counter.color_temp.ww"])
assert result.controller_commands["kitchen_counter"].color_temp == ct_from_mix(
result.channel_levels["kitchen_counter.color_temp.cw"] / peak,
result.channel_levels["kitchen_counter.color_temp.ww"] / peak,
spec.controllers["kitchen_counter"].native_ct_mireds,
)
def test_ct_slack_prefers_near_ct_over_exact_rgb_when_within_band() -> None:
target = Color.from_color_temp(330, brightness=1.0, temp_range=(153, 500))
spec = VirtualLightSpec(
name="ct_slack",
supported_color_modes=("rgb", "color_temp"),
controllers={
"shared": ControllerSpec(
zigbee_id="shared",
name="Shared",
mireds=(153, 500),
modes={
"rgb": ControllerModeSpec(
name="rgb",
channels={"r": ChannelSpec("r", target, "rgb")},
),
"color_temp": ControllerModeSpec(
name="color_temp",
channels={
"ww": ChannelSpec(
"ww",
Color.from_xy((target.xy[0] + 0.002, target.xy[1]), brightness=5.0),
"color_temp",
),
"cw": ChannelSpec(
"cw",
Color.from_xy((target.xy[0] - 0.002, target.xy[1] + 0.001), brightness=5.0),
"color_temp",
),
},
),
},
)
},
)
result = solve_light(
compile_light_model(spec, approximate_chroma_band=0.01),
target,
brightness=255,
requested_mode="color_temp",
)
assert result.controller_modes == {"shared": "color_temp"}
assert result.exact_chroma_match is False
assert result.chroma_error <= 0.01
def test_ct_slack_does_not_use_near_ct_when_outside_band() -> None:
target = Color.from_color_temp(330, brightness=1.0, temp_range=(153, 500))
spec = VirtualLightSpec(
name="ct_slack_strict",
supported_color_modes=("rgb", "color_temp"),
controllers={
"shared": ControllerSpec(
zigbee_id="shared",
name="Shared",
mireds=(153, 500),
modes={
"rgb": ControllerModeSpec(
name="rgb",
channels={"r": ChannelSpec("r", target, "rgb")},
),
"color_temp": ControllerModeSpec(
name="color_temp",
channels={
"ww": ChannelSpec(
"ww",
Color.from_xy((target.xy[0] + 0.002, target.xy[1]), brightness=5.0),
"color_temp",
),
"cw": ChannelSpec(
"cw",
Color.from_xy((target.xy[0] - 0.002, target.xy[1] + 0.001), brightness=5.0),
"color_temp",
),
},
),
},
)
},
)
result = solve_light(
compile_light_model(spec, approximate_chroma_band=0.0001),
target,
brightness=255,
requested_mode="color_temp",
)
assert result.controller_modes == {"shared": "rgb"}
assert result.exact_chroma_match is True
def test_wc_ct_sweep_stays_near_requested_ct_line_through_middle_range() -> None:
spec = _group_debug_spec(GROUPS["Example Bathroom"], LIGHTS)
model = compile_light_model(spec)
for mireds in [220, 250, 286, 320, 360]:
result = solve_light(
model,
Color.from_color_temp(mireds, brightness=1.0, temp_range=spec.exposed_mireds),
brightness=255,
requested_mode="color_temp",
)
assert result.chroma_error <= model.approximate_chroma_band
assert abs(result.achieved_color.color_temp - mireds) <= 2
assert any(".color_temp." in address for address in result.channel_levels)
def test_wc_low_brightness_ct_can_use_rgb_correction_without_redefining_reference() -> None:
spec = _group_debug_spec(GROUPS["Example Bathroom"], LIGHTS)
model = compile_light_model(spec)
target = Color.from_color_temp(286, brightness=1.0, temp_range=spec.exposed_mireds)
low = solve_light(model, target, brightness=50, requested_mode="color_temp")
high = solve_light(model, target, brightness=255, requested_mode="color_temp")
assert low.reference_color.brightness == high.reference_color.brightness
assert any(".rgb." in address for address in low.channel_levels)
assert low.chroma_error < high.chroma_error
assert abs(high.achieved_color.color_temp - 286) <= 2
def test_wc_katto_low_brightness_ct_can_sacrifice_one_bulb_for_rgb_correction() -> None:
spec = LIGHTS["Example Bathroom Ceiling"]
model = compile_light_model(spec)
target = Color.from_color_temp(286, brightness=1.0, temp_range=spec.exposed_mireds)
low = solve_light(model, target, brightness=50, requested_mode="color_temp")
exact_edge = solve_light(model, target, brightness=83, requested_mode="color_temp")
best_effort = solve_light(model, target, brightness=105, requested_mode="color_temp")
high = solve_light(model, target, brightness=255, requested_mode="color_temp")
assert any(mode == "rgb" for mode in low.controller_modes.values())
assert any(mode == "rgb" for mode in exact_edge.controller_modes.values())
assert any(mode == "rgb" for mode in best_effort.controller_modes.values())
assert all(mode == "color_temp" for mode in high.controller_modes.values())
assert exact_edge.chroma_error <= model.chroma_tolerance
assert best_effort.chroma_error < high.chroma_error
assert low.chroma_error < high.chroma_error
assert abs(high.achieved_color.color_temp - 286) <= 2
def test_wc_katto_balances_equivalent_channels() -> None:
spec = LIGHTS["Example Bathroom Ceiling"]
model = compile_light_model(spec)
target = Color.from_color_temp(300, brightness=1.0, temp_range=spec.exposed_mireds)
rgb_correction = solve_light(model, target, brightness=90, requested_mode="color_temp")
assert rgb_correction.controller_modes["bath_ceiling_1"] == "rgb"
assert rgb_correction.controller_modes["bath_ceiling_2"] == "rgb"
assert abs(rgb_correction.channel_levels["bath_ceiling_1.rgb.r"] - rgb_correction.channel_levels["bath_ceiling_2.rgb.r"]) < 1e-6
assert abs(rgb_correction.channel_levels["bath_ceiling_1.rgb.g"] - rgb_correction.channel_levels["bath_ceiling_2.rgb.g"]) < 1e-6
ct_correction = solve_light(model, target, brightness=99, requested_mode="color_temp")
assert ct_correction.controller_modes["bath_ceiling_2"] == "color_temp"
assert ct_correction.controller_modes["bath_ceiling_3"] == "color_temp"
assert abs(ct_correction.channel_levels["bath_ceiling_2.color_temp.ww"] - ct_correction.channel_levels["bath_ceiling_3.color_temp.ww"]) < 1e-6
assert abs(ct_correction.channel_levels["bath_ceiling_2.color_temp.cw"] - ct_correction.channel_levels["bath_ceiling_3.color_temp.cw"]) < 1e-6
def test_example_all_lights_does_not_branch_on_dimmable_off_states() -> None:
spec = _combine_specs(
LIGHTS,
[
"Example Fixed White A",
"Example Fixed White B",
"Example Mixed North",
"Example Plug A",
"Example Plug B",
"Example Hall Floor",
"Example Hall Corner",
"Example Hall Cabinets",
],
)
model = compile_light_model(spec)
assert len(model.combinations) == 32
def test_example_all_lights_deduplicates_reference_combinations() -> None:
spec = _combine_specs(
LIGHTS,
[
"Example Fixed White A",
"Example Fixed White B",
"Example Mixed North",
"Example Plug A",
"Example Plug B",
"Example Hall Floor",
"Example Hall Corner",
"Example Hall Cabinets",
],
)
model = compile_light_model(spec)
assert len(model.reference_combinations) < len(model.combinations)
def test_example_all_lights_ct_sweep_does_not_use_unused_onoff_reference() -> None:
spec = _combine_specs(
LIGHTS,
[
"Example Fixed White A",
"Example Fixed White B",
"Example Mixed North",
"Example Plug A",
"Example Plug B",
"Example Hall Floor",
"Example Hall Corner",
"Example Hall Cabinets",
],
)
model = compile_light_model(spec)
previous: dict[str, float] | None = None
max_delta = 0.0
for mireds in range(300, 321):
result = solve_light(
model,
Color.from_color_temp(mireds, brightness=1.0),
brightness=181,
requested_mode="color_temp",
)
assert result.channel_levels.get("plug_a.onoff.w", 0.0) == 0.0
assert result.channel_levels.get("plug_b.onoff.w", 0.0) == 0.0
levels = {
channel: level
for channel, level in result.channel_levels.items()
if level > 1e-6
}
if previous is not None:
max_delta = max(
max_delta,
max(abs(levels.get(channel, 0.0) - previous.get(channel, 0.0)) for channel in set(levels) | set(previous)),
)
previous = levels
assert max_delta < 0.9
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import pytest
from ha_deconz_bridge.backends.deconz import DeconzBackend
from ha_deconz_bridge.color import Color
from ha_deconz_bridge.lights import ChannelSpec, ControllerModeSpec, ControllerSpec, VirtualLightSpec
def test_virtual_light_rejects_color_temp_without_native_ct_emitters() -> None:
with pytest.raises(ValueError):
VirtualLightSpec(
name="bad",
supported_color_modes=("color_temp",),
controllers={
"1": ControllerSpec(
zigbee_id="1",
name="RGB only",
modes={
"rgb": ControllerModeSpec(
name="rgb",
channels={
"r": ChannelSpec("r", Color.from_rgb((1.0, 0.0, 0.0))),
"g": ChannelSpec("g", Color.from_rgb((0.0, 1.0, 0.0))),
"b": ChannelSpec("b", Color.from_rgb((0.0, 0.0, 1.0))),
},
)
},
)
},
)
def test_controller_mode_rejects_invalid_role() -> None:
with pytest.raises(ValueError):
ControllerModeSpec(
name="rgb",
channels={"ww": ChannelSpec("ww", Color.from_rgb((1.0, 1.0, 1.0)))},
)
def test_deconz_invalid_ct_range_is_rejected() -> None:
resource = {
"state": {"on": True, "reachable": True, "bri": 254, "ct": 400, "colormode": "ct"},
"ctmin": 65279,
"ctmax": 0,
}
assert DeconzBackend.extract_mireds(resource) is None