Files

713 lines
29 KiB
Python

from __future__ import annotations
from lights_config import GROUPS, LIGHTS
from ha_deconz_bridge.color import Color
from ha_deconz_bridge.debugger.web_debugger import _combine_specs, _group_debug_spec, _solve_group_request
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, ["Floor Lamp Plug", "Plant Light Left"])
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, ["Plant Light Left", "Living Room Ceiling"])
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["Plant Light Left"].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 ["Living Room Ceiling", "Hallway Floor Light", "Hallway Corner Light", "Hallway Cabinet Lights"]:
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["Kitchen Counter Lights"]
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["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["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["Bathroom Ceiling Light"]
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["Bathroom Ceiling Light"]
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,
[
"Plant Light Left",
"Plant Light Right",
"Living Room Ceiling",
"Floor Lamp Plug",
"Desk Lamp Plug",
"Hallway Floor Light",
"Hallway Corner Light",
"Hallway Cabinet Lights",
],
)
model = compile_light_model(spec)
assert len(model.combinations) == 32
def test_example_all_lights_deduplicates_reference_combinations() -> None:
spec = _combine_specs(
LIGHTS,
[
"Plant Light Left",
"Plant Light Right",
"Living Room Ceiling",
"Floor Lamp Plug",
"Desk Lamp Plug",
"Hallway Floor Light",
"Hallway Corner Light",
"Hallway Cabinet Lights",
],
)
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,
[
"Plant Light Left",
"Plant Light Right",
"Living Room Ceiling",
"Floor Lamp Plug",
"Desk Lamp Plug",
"Hallway Floor Light",
"Hallway Corner Light",
"Hallway Cabinet Lights",
],
)
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
def test_debugger_group_low_brightness_keeps_onoff_members_off() -> None:
group = GROUPS["Living Room"]
group_spec = _group_debug_spec(group, LIGHTS)
models = {
name: compile_light_model(spec)
for name, spec in LIGHTS.items()
}
result = _solve_group_request(
group,
LIGHTS,
group_spec,
{"mode": "color_temp", "brightness": 1, "color_temp": 500},
model_for_light=models.__getitem__,
)
assert result["commands"]["plug_a"]["on"] is False
assert result["commands"]["plug_b"]["on"] is False
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
assert result["channel_levels"].get("fixed_a.color_temp.ww", 0.0) == 0.0
assert result["channel_levels"].get("fixed_b.color_temp.ww", 0.0) == 0.0