diff --git a/README.md b/README.md index 3afc9a0d..c5c52b73 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ The YAML and frontend configuration methods support all of the options listed be | `min_color_temp` | Warmest color temperature in Kelvin. 🔥 | `2000` | `int` 1000-10000 | | `max_color_temp` | Coldest color temperature in Kelvin. ❄️ | `5500` | `int` 1000-10000 | | `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | `False` | `bool` | -| `sleep_brightness` | Brightness percentage of lights in sleep mode. 😴 | `1` | `int` 1-100 | +| `sleep_brightness` | Brightness percentage of lights in sleep mode. Set to 0 to turn lights off when brightness adaptation runs in sleep mode. 😴 | `1` | `int` 0-100 | | `sleep_rgb_or_color_temp` | Use either `"rgb_color"` or `"color_temp"` in sleep mode. 🌙 | `color_temp` | one of `['color_temp', 'rgb_color']` | | `sleep_color_temp` | Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴 | `1000` | `int` 1000-10000 | | `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 | `[255, 56, 0]` | RGB color | diff --git a/custom_components/adaptive_lighting/adaptation_utils.py b/custom_components/adaptive_lighting/adaptation_utils.py index bfd3f76c..b4d6c3bd 100644 --- a/custom_components/adaptive_lighting/adaptation_utils.py +++ b/custom_components/adaptive_lighting/adaptation_utils.py @@ -130,6 +130,10 @@ def _is_attribute_satisfied(key: str, value: Any, attributes: dict[str, Any]) -> if not isinstance(current, (int, float)) or not isinstance(value, (int, float)): return value == current if key == ATTR_BRIGHTNESS: + # Zero is an off command, so it must not be treated as equivalent to a + # nearby nonzero brightness in either direction. + if value == 0 or current == 0: + return value == current return abs(value - current) <= BRIGHTNESS_TOLERANCE if key == ATTR_COLOR_TEMP_KELVIN and value > 0 and current > 0: # Compare in mired space: most integrations quantize color temperature @@ -283,6 +287,14 @@ def prepare_adaptation_data( entity_id, service_data, ) + if service_data.get(ATTR_BRIGHTNESS) == 0: + # Home Assistant treats brightness zero as turn-off. Make it terminal: + # color is meaningless while off and must not become a later turn-on. + service_data = { + key: service_data[key] + for key in (ATTR_ENTITY_ID, ATTR_BRIGHTNESS, ATTR_TRANSITION) + if key in service_data + } service_datas = _split_service_call_data(service_data) if split else [service_data] service_datas_length = len(service_datas) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 3ea633e4..7618c03f 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -117,7 +117,8 @@ DOCS[CONF_SEPARATE_TURN_ON_COMMANDS] = ( CONF_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_BRIGHTNESS = "sleep_brightness", 1 DOCS[CONF_SLEEP_BRIGHTNESS] = ( - "Brightness percentage of lights in sleep mode. Set to 0 to prevent lights from turning on. 😴" + "Brightness percentage of lights in sleep mode. Set to 0 to turn lights off " + "when brightness adaptation runs in sleep mode. 😴" ) CONF_SLEEP_COLOR_TEMP, DEFAULT_SLEEP_COLOR_TEMP = "sleep_color_temp", 1000 diff --git a/custom_components/adaptive_lighting/services.yaml b/custom_components/adaptive_lighting/services.yaml index b41ef316..e7f954c0 100644 --- a/custom_components/adaptive_lighting/services.yaml +++ b/custom_components/adaptive_lighting/services.yaml @@ -152,7 +152,7 @@ change_switch_settings: selector: boolean: null sleep_brightness: - description: Brightness percentage of lights in sleep mode. Set to 0 to prevent lights from turning on. 😴 + description: Brightness percentage of lights in sleep mode. Set to 0 to turn lights off when brightness adaptation runs in sleep mode. 😴 required: false example: 1 selector: diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index ff576a2b..04a214a3 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -39,7 +39,7 @@ "data_description": { "interval": "Frequency to adapt the lights, in seconds. 🔄", "transition": "Duration of transition when lights change, in seconds. 🕑", - "sleep_brightness": "Brightness percentage of lights in sleep mode. 😴", + "sleep_brightness": "Brightness percentage of lights in sleep mode. Set to 0 to turn lights off when brightness adaptation runs in sleep mode. 😴", "sleep_color_temp": "Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴" }, "sections": { @@ -219,7 +219,7 @@ "name": "send_split_delay" }, "sleep_brightness": { - "description": "Brightness percentage of lights in sleep mode. 😴", + "description": "Brightness percentage of lights in sleep mode. Set to 0 to turn lights off when brightness adaptation runs in sleep mode. 😴", "name": "sleep_brightness" }, "sleep_rgb_or_color_temp": { diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index 688bb984..5dcde6ff 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -40,7 +40,7 @@ "data_description": { "interval": "Frequency to adapt the lights, in seconds. 🔄", "transition": "Duration of transition when lights change, in seconds. 🕑", - "sleep_brightness": "Brightness percentage of lights in sleep mode. 😴", + "sleep_brightness": "Brightness percentage of lights in sleep mode. Set to 0 to turn lights off when brightness adaptation runs in sleep mode. 😴", "sleep_color_temp": "Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴" }, "sections": { @@ -220,7 +220,7 @@ "name": "send_split_delay" }, "sleep_brightness": { - "description": "Brightness percentage of lights in sleep mode. 😴", + "description": "Brightness percentage of lights in sleep mode. Set to 0 to turn lights off when brightness adaptation runs in sleep mode. 😴", "name": "sleep_brightness" }, "sleep_rgb_or_color_temp": { diff --git a/docs/configuration.md b/docs/configuration.md index 7edaac57..7bd02c13 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -43,7 +43,7 @@ All configuration options are listed below with their default values. These opti | `min_color_temp` | Warmest color temperature in Kelvin. 🔥 | `2000` | `int` 1000-10000 | | `max_color_temp` | Coldest color temperature in Kelvin. ❄️ | `5500` | `int` 1000-10000 | | `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | `False` | `bool` | -| `sleep_brightness` | Brightness percentage of lights in sleep mode. 😴 | `1` | `int` 1-100 | +| `sleep_brightness` | Brightness percentage of lights in sleep mode. Set to 0 to turn lights off when brightness adaptation runs in sleep mode. 😴 | `1` | `int` 0-100 | | `sleep_rgb_or_color_temp` | Use either `"rgb_color"` or `"color_temp"` in sleep mode. 🌙 | `color_temp` | one of `['color_temp', 'rgb_color']` | | `sleep_color_temp` | Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴 | `1000` | `int` 1000-10000 | | `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 | `[255, 56, 0]` | RGB color | diff --git a/tests/test_adaptation_utils.py b/tests/test_adaptation_utils.py index e0ec07de..3cb24dae 100644 --- a/tests/test_adaptation_utils.py +++ b/tests/test_adaptation_utils.py @@ -108,6 +108,26 @@ async def test_split_service_call_data(input_data, expected_data_list): State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 227}), {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 230, ATTR_TRANSITION: 2}, ), + ( + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 0, ATTR_TRANSITION: 2}, + State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 1}), + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 0, ATTR_TRANSITION: 2}, + ), + ( + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 0, ATTR_TRANSITION: 2}, + State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 2}), + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 0, ATTR_TRANSITION: 2}, + ), + ( + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 1, ATTR_TRANSITION: 2}, + State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 0}), + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 1, ATTR_TRANSITION: 2}, + ), + ( + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 0, ATTR_TRANSITION: 2}, + State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 0}), + {ATTR_ENTITY_ID: "light.test", ATTR_TRANSITION: 2}, + ), ( { ATTR_ENTITY_ID: "light.test", @@ -165,6 +185,10 @@ async def test_split_service_call_data(input_data, expected_data_list): "keep attributes whose values differ from the state", "remove brightness within quantization tolerance (0-99 device scale)", "keep brightness outside quantization tolerance", + "keep zero target across power boundary from brightness one", + "keep zero target across power boundary from brightness two", + "keep positive target across power boundary from zero", + "remove zero target when brightness is already zero", "remove color temp within one mired (round-converting integration)", "remove color temp within one mired (floor-converting HA core helpers)", "remove color temp within one mired (6500 K)", @@ -347,6 +371,24 @@ async def test_create_service_call_data_iterator( ], 0.7, ), + ( + { + ATTR_ENTITY_ID: "light.test", + ATTR_BRIGHTNESS: 0, + ATTR_COLOR_TEMP_KELVIN: 4000, + ATTR_TRANSITION: 10, + }, + True, + False, + [ + { + ATTR_ENTITY_ID: "light.test", + ATTR_BRIGHTNESS: 0, + ATTR_TRANSITION: 10, + }, + ], + 1.2, + ), ( { ATTR_ENTITY_ID: "light.test", @@ -383,6 +425,7 @@ async def test_create_service_call_data_iterator( ids=[ "service data passed through", "service data split", + "zero brightness is one terminal command", "service data filtered", "service data split and filtered", ], @@ -416,6 +459,31 @@ async def test_prepare_adaptation_data( assert generated_service_datas == service_datas_expected +async def test_prepare_zero_after_shared_brightness_was_applied(hass_states_mock): + """A shared zero-brightness call leaves no per-light color follow-up.""" + data = prepare_adaptation_data( + hass_states_mock, + "light.test", + Context(id="test-id"), + 10, + 0.2, + { + ATTR_ENTITY_ID: "light.test", + ATTR_BRIGHTNESS: 0, + ATTR_COLOR_TEMP_KELVIN: 4000, + ATTR_TRANSITION: 10, + }, + split=True, + filter_by_state=False, + force=False, + already_applied=LightControlAttributes.BRIGHTNESS, + ) + + assert [item async for item in data.service_call_datas] == [] + assert data.max_length == 0 + assert data.attributes is LightControlAttributes.NONE + + @pytest.fixture(name="hass_states_mock") def fixture_hass_states_mock(): """Mocks a HA state machine which returns a mock state.""" diff --git a/tests/test_switch.py b/tests/test_switch.py index 03f0066c..02cc8f40 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -47,6 +47,7 @@ from homeassistant.components.adaptive_lighting.const import ( CONF_RESET_MANUAL_CONTROL_ON_SLEEP_MODE_CHANGE, CONF_SEPARATE_TURN_ON_COMMANDS, CONF_SKIP_REDUNDANT_COMMANDS, + CONF_SLEEP_BRIGHTNESS, CONF_SLEEP_RGB_OR_COLOR_TEMP, CONF_SLEEP_TRANSITION, CONF_SUNRISE_OFFSET, @@ -4969,3 +4970,246 @@ async def test_forced_split_apply_stays_off(hass, off_action, cleanup): assert ATTR_BRIGHTNESS in turn_on_events[0].data["service_data"] assert ATTR_COLOR_TEMP_KELVIN not in turn_on_events[0].data["service_data"] assert hass.states.get(ENTITY_LIGHT_3).state == STATE_OFF + + +async def _finish_zero_sleep_adaptations(hass, switch): + """Wait until zero-sleep adaptation calls have settled.""" + await hass.async_block_till_done(wait_background_tasks=True) + if switch.manager.adaptation_tasks: + await asyncio.gather(*tuple(switch.manager.adaptation_tasks)) + await hass.async_block_till_done(wait_background_tasks=True) + + +def _light_turn_on_payloads(events, entity_id): + """Return turn-on payloads that target one light.""" + payloads = [] + for event in events: + if ( + event.data["domain"] != LIGHT_DOMAIN + or event.data["service"] != SERVICE_TURN_ON + ): + continue + service_data = event.data["service_data"] + target = service_data[ATTR_ENTITY_ID] + if target == entity_id or (not isinstance(target, str) and entity_id in target): + payloads.append(service_data) + return payloads + + +@pytest.mark.parametrize("intercept", [False, True]) +@pytest.mark.parametrize("split", [False, True]) +async def test_zero_sleep_bare_turn_on_is_terminal(hass, intercept, split): + """A bare turn-on ends with one color-free zero-brightness command.""" + switch, (light, *_) = await setup_lights_and_switch( + hass, + { + CONF_INTERCEPT: intercept, + CONF_SEPARATE_TURN_ON_COMMANDS: split, + CONF_SLEEP_BRIGHTNESS: 0, + }, + ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: light.entity_id}, + blocking=True, + ) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: switch.sleep_mode_switch.entity_id}, + blocking=True, + ) + await _finish_zero_sleep_adaptations(hass, switch) + + events = [] + remove_listener = hass.bus.async_listen(EVENT_CALL_SERVICE, events.append) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: light.entity_id}, + blocking=True, + ) + await _finish_zero_sleep_adaptations(hass, switch) + remove_listener() + + payloads = _light_turn_on_payloads(events, light.entity_id) + zero_payloads = [data for data in payloads if data.get(ATTR_BRIGHTNESS) == 0] + if intercept: + assert payloads == [{ATTR_ENTITY_ID: light.entity_id}] + else: + assert payloads[0] == {ATTR_ENTITY_ID: light.entity_id} + assert len(payloads) == 2 + assert zero_payloads == [ + {ATTR_ENTITY_ID: light.entity_id, ATTR_BRIGHTNESS: 0}, + ] + assert all(ATTR_COLOR_TEMP_KELVIN not in data for data in payloads) + assert all(ATTR_RGB_COLOR not in data for data in payloads) + assert hass.states.get(light.entity_id).state == STATE_OFF + + +@pytest.mark.parametrize("split", [False, True]) +async def test_zero_sleep_enter_and_exit_does_not_restore_light(hass, split): + """Entering zero sleep turns a light off; exiting leaves it off.""" + switch, (light, *_) = await setup_lights_and_switch( + hass, + { + CONF_SEPARATE_TURN_ON_COMMANDS: split, + CONF_SLEEP_BRIGHTNESS: 0, + }, + ) + assert hass.states.get(light.entity_id).state == STATE_ON + + events = [] + remove_listener = hass.bus.async_listen(EVENT_CALL_SERVICE, events.append) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: switch.sleep_mode_switch.entity_id}, + blocking=True, + ) + await _finish_zero_sleep_adaptations(hass, switch) + enter_payloads = _light_turn_on_payloads(events, light.entity_id) + assert len(enter_payloads) == 1 + assert enter_payloads[0][ATTR_BRIGHTNESS] == 0 + assert hass.states.get(light.entity_id).state == STATE_OFF + + events.clear() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: switch.sleep_mode_switch.entity_id}, + blocking=True, + ) + await _finish_zero_sleep_adaptations(hass, switch) + remove_listener() + assert _light_turn_on_payloads(events, light.entity_id) == [] + assert hass.states.get(light.entity_id).state == STATE_OFF + + +@pytest.mark.parametrize("brightness", [1, 2]) +@pytest.mark.parametrize("split", [False, True]) +async def test_zero_sleep_is_not_redundant_with_dimmed_light( + hass, + brightness, + split, +): + """Brightness quantization tolerance does not suppress a required off.""" + switch, (light, *_) = await setup_lights_and_switch( + hass, + { + CONF_SEPARATE_TURN_ON_COMMANDS: split, + CONF_SKIP_REDUNDANT_COMMANDS: True, + CONF_SLEEP_BRIGHTNESS: 0, + }, + ) + set_light_brightness(light, brightness) + light.async_write_ha_state() + await hass.async_block_till_done() + + events = [] + remove_listener = hass.bus.async_listen(EVENT_CALL_SERVICE, events.append) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: switch.sleep_mode_switch.entity_id}, + blocking=True, + ) + await _finish_zero_sleep_adaptations(hass, switch) + remove_listener() + + payloads = _light_turn_on_payloads(events, light.entity_id) + assert [data[ATTR_BRIGHTNESS] for data in payloads] == [0] + assert hass.states.get(light.entity_id).state == STATE_OFF + + +@pytest.mark.parametrize("split", [False, True]) +async def test_zero_sleep_command_keeps_full_transition(hass, split, cleanup): + """The terminal zero command keeps the configured sleep transition.""" + switch, lights = await setup_lights_and_switch( + hass, + { + CONF_SEPARATE_TURN_ON_COMMANDS: split, + CONF_SLEEP_BRIGHTNESS: 0, + CONF_SLEEP_TRANSITION: 1, + }, + all_lights=True, + ) + light = lights[2] + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: light.entity_id}, + blocking=True, + ) + await _finish_zero_sleep_adaptations(hass, switch) + + events = [] + remove_listener = hass.bus.async_listen(EVENT_CALL_SERVICE, events.append) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: switch.sleep_mode_switch.entity_id}, + blocking=True, + ) + await _finish_zero_sleep_adaptations(hass, switch) + remove_listener() + + payloads = _light_turn_on_payloads(events, light.entity_id) + assert payloads == [ + { + ATTR_ENTITY_ID: light.entity_id, + ATTR_BRIGHTNESS: 0, + ATTR_TRANSITION: 1, + }, + ] + + +async def test_zero_sleep_multi_light_intercept_has_no_follow_up(hass): + """A shared intercepted zero command needs no per-light color call.""" + lights = await setup_lights(hass) + managed = [lights[0].entity_id, lights[1].entity_id] + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: managed}, + blocking=True, + ) + _, switch = await setup_switch( + hass, + { + CONF_LIGHTS: managed, + CONF_INTERCEPT: True, + CONF_MULTI_LIGHT_INTERCEPT: True, + CONF_SEPARATE_TURN_ON_COMMANDS: True, + CONF_INITIAL_TRANSITION: 0, + CONF_SLEEP_BRIGHTNESS: 0, + }, + ) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: switch.sleep_mode_switch.entity_id}, + blocking=True, + ) + await _finish_zero_sleep_adaptations(hass, switch) + + events = [] + remove_listener = hass.bus.async_listen(EVENT_CALL_SERVICE, events.append) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: managed}, + blocking=True, + ) + await _finish_zero_sleep_adaptations(hass, switch) + remove_listener() + + payloads = [ + event.data["service_data"] + for event in events + if event.data["domain"] == LIGHT_DOMAIN + and event.data["service"] == SERVICE_TURN_ON + ] + assert payloads == [{ATTR_ENTITY_ID: managed}] + assert all(hass.states.get(entity_id).state == STATE_OFF for entity_id in managed)