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