Initial public release
This commit is contained in:
@@ -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)),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -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,)),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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} == {}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user