From b6790b14eba0902fa170a94d9431dd111c9a2399 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Tue, 11 Apr 2023 19:07:48 -0500 Subject: [PATCH 01/11] Squashed commit of the following: commit 8dd1b5660e4a296663a1762d3642f85229fc9912 Merge: 3b3f56f a888afd Author: Benjamin Auquite Date: Mon Apr 10 12:51:11 2023 -0500 merge origin commit 3b3f56f2d245ce815a3e62ccccff41b221168960 Author: Benjamin Auquite Date: Mon Apr 10 12:39:26 2023 -0500 Update test_switch.py commit 258964d97bace1cc306c2690b4b9cf5f945eae7b Author: Benjamin Auquite Date: Mon Apr 10 12:07:18 2023 -0500 pass flake8 move stuff out of `async_setup_entry` commit 5b6ad02e1d7862c09f2c4a08f28209d8e7799f6d Merge: 251babc fe7bdd3 Author: Benjamin Auquite Date: Sat Apr 8 06:01:44 2023 -0500 Merge branch 'main' into new-service-calls commit 251babce9d7d911cf2f31f5eb967c98ee85b126e Author: Benjamin Auquite Date: Sat Apr 8 05:35:16 2023 -0500 fixes and now passes tests commit d9cd74bef3023bfc6349320e0fd97689da708f7d Author: Benjamin Auquite Date: Sat Apr 8 05:15:17 2023 -0500 initial commit, passes most tests. 'function too complex' --- custom_components/adaptive_lighting/const.py | 28 ++- custom_components/adaptive_lighting/switch.py | 222 +++++++++++++----- tests/test_switch.py | 80 ++++++- 3 files changed, 267 insertions(+), 63 deletions(-) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 6d36ae2e..babea0a5 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -1,7 +1,12 @@ """Constants for the Adaptive Lighting integration.""" from homeassistant.components.light import VALID_TRANSITION -from homeassistant.const import CONF_ENTITY_ID +from homeassistant.const import ( + CONF_ENTITY_ID, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.helpers import selector import homeassistant.helpers.config_validation as cv import voluptuous as vol @@ -201,6 +206,19 @@ DOCS[CONF_USE_DEFAULTS] = ( 'documented defaults), or "configuration" (reverts to switch config defaults). ⚙️' ) +CONF_WHICH_SWITCH, DEFAULT_WHICH_SWITCH = "switch_type", "main" +DOCS[CONF_WHICH_SWITCH] = ( + "Which switch to target in this service call. Options: " + '"main" (default, targets the main switch), "sleep", "brightness", "color"' +) +DOCS[ + SERVICE_TURN_ON +] = "Turn on an Adaptive Lighting main/sleep/brightness/color switch" +DOCS[ + SERVICE_TURN_OFF +] = "Turn off an Adaptive Lighting main/sleep/brightness/color switch" +DOCS[SERVICE_TOGGLE] = "Toggle an Adaptive Lighting main/sleep/brightness/color switch" + TURNING_OFF_DELAY = 5 DOCS_MANUAL_CONTROL = { @@ -341,6 +359,14 @@ def apply_service_schema(initial_transition: int = 1): ) +SERVICE_TOGGLE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, + vol.Optional(CONF_WHICH_SWITCH): cv.string, + } +) + SET_MANUAL_CONTROL_SCHEMA = vol.Schema( { vol.Optional(CONF_ENTITY_ID): cv.entity_ids, diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 3eabf7ff..276b6198 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -61,6 +61,7 @@ from homeassistant.const import ( EVENT_CALL_SERVICE, EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED, + SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, @@ -74,6 +75,7 @@ from homeassistant.core import ( HomeAssistant, ServiceCall, State, + async_get_hass, callback, ) from homeassistant.helpers import entity_platform, entity_registry @@ -134,6 +136,7 @@ from .const import ( CONF_TRANSITION, CONF_TURN_ON_LIGHTS, CONF_USE_DEFAULTS, + CONF_WHICH_SWITCH, CONST_COLOR, DOMAIN, EXTRA_VALIDATION, @@ -144,6 +147,7 @@ from .const import ( SERVICE_APPLY, SERVICE_CHANGE_SWITCH_SETTINGS, SERVICE_SET_MANUAL_CONTROL, + SERVICE_TOGGLE_SCHEMA, SET_MANUAL_CONTROL_SCHEMA, SLEEP_MODE_SWITCH, SUN_EVENT_MIDNIGHT, @@ -428,6 +432,139 @@ async def handle_change_switch_settings( ) +@callback +async def handle_turn_on(service_call: ServiceCall): + """Toggles the specified switch.""" + hass = async_get_hass() + data = service_call.data + _LOGGER.debug( + "Called 'adaptive_lighting.turn_on' service with '%s'", + data, + ) + switches = _get_switches_from_service_call(hass, service_call) + if data[CONF_WHICH_SWITCH] == "sleep": + switches = [s.sleep_mode_switch for s in switches] + elif data[CONF_WHICH_SWITCH] == "brightness": + switches = [s.adapt_brightness_switch for s in switches] + elif data[CONF_WHICH_SWITCH] == "color": + switches = [s.adapt_color_switch for s in switches] + + _LOGGER.debug("Turning on switches [%s]", switches) + for switch in switches: + await switch.async_turn_on() + + +@callback +async def handle_turn_off(service_call: ServiceCall): + """Toggles the specified switch.""" + hass = async_get_hass() + data = service_call.data + _LOGGER.debug( + "Called 'adaptive_lighting.turn_off' service with '%s'", + data, + ) + switches = _get_switches_from_service_call(hass, service_call) + if data[CONF_WHICH_SWITCH] == "sleep": + switches = [s.sleep_mode_switch for s in switches] + elif data[CONF_WHICH_SWITCH] == "brightness": + switches = [s.adapt_brightness_switch for s in switches] + elif data[CONF_WHICH_SWITCH] == "color": + switches = [s.adapt_color_switch for s in switches] + _LOGGER.debug("Turning off switches [%s]", switches) + for switch in switches: + await switch.async_turn_off() + + +@callback +async def handle_toggle(service_call: ServiceCall): + """Toggles the specified switch.""" + hass = async_get_hass() + data = service_call.data + _LOGGER.debug( + "Called 'adaptive_lighting.toggle' service with '%s'", + data, + ) + switches = _get_switches_from_service_call(hass, service_call) + if data[CONF_WHICH_SWITCH] == "sleep": + switches = [s.sleep_mode_switch for s in switches] + elif data[CONF_WHICH_SWITCH] == "brightness": + switches = [s.adapt_brightness_switch for s in switches] + elif data[CONF_WHICH_SWITCH] == "color": + switches = [s.adapt_color_switch for s in switches] + _LOGGER.debug("Toggling switches [%s]", switches) + for switch in switches: + if switch.is_on: + await switch.async_turn_off() + else: + await switch.async_turn_on() + + +@callback +async def handle_apply(service_call: ServiceCall): + """Handle the entity service apply.""" + hass = async_get_hass() + data = service_call.data + _LOGGER.debug( + "Called 'adaptive_lighting.apply' service with '%s'", + data, + ) + switches = _get_switches_from_service_call(hass, service_call) + lights = data[CONF_LIGHTS] + for switch in switches: + if not lights: + all_lights = switch._lights # pylint: disable=protected-access + else: + all_lights = _expand_light_groups(switch.hass, lights) + switch.turn_on_off_listener.lights.update(all_lights) + for light in all_lights: + if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light): + await switch._adapt_light( # pylint: disable=protected-access + light, + data[CONF_TRANSITION], + data[ATTR_ADAPT_BRIGHTNESS], + data[ATTR_ADAPT_COLOR], + data[CONF_PREFER_RGB_COLOR], + force=True, + context=switch.create_context( + "service", parent=service_call.context + ), + ) + + +@callback +async def handle_set_manual_control(service_call: ServiceCall): + """Set or unset lights as 'manually controlled'.""" + hass = async_get_hass() + data = service_call.data + _LOGGER.debug( + "Called 'adaptive_lighting.set_manual_control' service with '%s'", + data, + ) + switches = _get_switches_from_service_call(hass, service_call) + lights = data[CONF_LIGHTS] + for switch in switches: + if not lights: + all_lights = switch._lights # pylint: disable=protected-access + else: + all_lights = _expand_light_groups(switch.hass, lights) + if service_call.data[CONF_MANUAL_CONTROL]: + for light in all_lights: + switch.turn_on_off_listener.mark_as_manual_control(light) + _fire_manual_control_event(switch, light, service_call.context) + else: + switch.turn_on_off_listener.reset(*all_lights) + if switch.is_on: + # pylint: disable=protected-access + await switch._update_attrs_and_maybe_adapt_lights( + all_lights, + transition=switch._initial_transition, + force=True, + context=switch.create_context( + "service", parent=service_call.context + ), + ) + + @callback def _fire_manual_control_event( switch: AdaptiveSwitch, light: str, context: Context, is_async=True @@ -489,67 +626,6 @@ async def async_setup_entry( update_before_add=True, ) - @callback - async def handle_apply(service_call: ServiceCall): - """Handle the entity service apply.""" - data = service_call.data - _LOGGER.debug( - "Called 'adaptive_lighting.apply' service with '%s'", - data, - ) - switches = _get_switches_from_service_call(hass, service_call) - lights = data[CONF_LIGHTS] - for switch in switches: - if not lights: - all_lights = switch._lights # pylint: disable=protected-access - else: - all_lights = _expand_light_groups(switch.hass, lights) - switch.turn_on_off_listener.lights.update(all_lights) - for light in all_lights: - if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light): - await switch._adapt_light( # pylint: disable=protected-access - light, - data[CONF_TRANSITION], - data[ATTR_ADAPT_BRIGHTNESS], - data[ATTR_ADAPT_COLOR], - data[CONF_PREFER_RGB_COLOR], - force=True, - context=switch.create_context( - "service", parent=service_call.context - ), - ) - - @callback - async def handle_set_manual_control(service_call: ServiceCall): - """Set or unset lights as 'manually controlled'.""" - data = service_call.data - _LOGGER.debug( - "Called 'adaptive_lighting.set_manual_control' service with '%s'", - data, - ) - switches = _get_switches_from_service_call(hass, service_call) - lights = data[CONF_LIGHTS] - for switch in switches: - if not lights: - all_lights = switch._lights # pylint: disable=protected-access - else: - all_lights = _expand_light_groups(switch.hass, lights) - if service_call.data[CONF_MANUAL_CONTROL]: - for light in all_lights: - _fire_manual_control_event(switch, light, service_call.context) - else: - switch.turn_on_off_listener.reset(*all_lights) - if switch.is_on: - # pylint: disable=protected-access - await switch._update_attrs_and_maybe_adapt_lights( - all_lights, - transition=switch._initial_transition, - force=True, - context=switch.create_context( - "service", parent=service_call.context - ), - ) - # Register `apply` service hass.services.async_register( domain=DOMAIN, @@ -560,6 +636,30 @@ async def async_setup_entry( ), # pylint: disable=protected-access ) + # Register `turn_on` service + hass.services.async_register( + domain=DOMAIN, + service=SERVICE_TURN_ON, + service_func=handle_turn_on, + schema=SERVICE_TOGGLE_SCHEMA, + ) + + # Register `turn_off` service + hass.services.async_register( + domain=DOMAIN, + service=SERVICE_TURN_OFF, + service_func=handle_turn_off, + schema=SERVICE_TOGGLE_SCHEMA, + ) + + # Register `toggle` service + hass.services.async_register( + domain=DOMAIN, + service=SERVICE_TOGGLE, + service_func=handle_toggle, + schema=SERVICE_TOGGLE_SCHEMA, + ) + # Register `set_manual_control` service hass.services.async_register( domain=DOMAIN, diff --git a/tests/test_switch.py b/tests/test_switch.py index 476e83cc..c569f16d 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -25,6 +25,7 @@ from homeassistant.components.adaptive_lighting.const import ( CONF_TRANSITION, CONF_TURN_ON_LIGHTS, CONF_USE_DEFAULTS, + CONF_WHICH_SWITCH, DEFAULT_MAX_BRIGHTNESS, DEFAULT_NAME, DEFAULT_SLEEP_BRIGHTNESS, @@ -52,7 +53,6 @@ from homeassistant.components.light import ( ATTR_XY_COLOR, ) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.components.light import SERVICE_TURN_OFF from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN import homeassistant.config as config_util from homeassistant.config_entries import ConfigEntryState @@ -64,6 +64,8 @@ from homeassistant.const import ( CONF_NAME, CONF_PLATFORM, EVENT_STATE_CHANGED, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, STATE_ON, @@ -1252,6 +1254,82 @@ async def test_area(hass): assert light.entity_id not in switch.turn_on_off_listener.last_service_data +@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES) +async def test_switch_turn_on_off_toggle(hass): + """Test adaptive_lighting.change_switch_settings service.""" + switch, (_, _, light) = await setup_lights_and_switch(hass) + entity_id = switch.entity_id + assert entity_id not in switch._lights + + async def turn_on(which: str, **kwargs): + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: entity_id, + CONF_WHICH_SWITCH: which, + **kwargs, + }, + blocking=True, + ) + await hass.async_block_till_done() + + async def turn_off(which: str, **kwargs): + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: entity_id, + CONF_WHICH_SWITCH: which, + **kwargs, + }, + blocking=True, + ) + await hass.async_block_till_done() + + async def toggle(which: str, **kwargs): + await hass.services.async_call( + DOMAIN, + SERVICE_TOGGLE, + { + ATTR_ENTITY_ID: entity_id, + CONF_WHICH_SWITCH: which, + **kwargs, + }, + blocking=True, + ) + await hass.async_block_till_done() + + # Test sleep + await turn_on("sleep") + assert switch.sleep_mode_switch.is_on + await turn_off("sleep") + assert not switch.sleep_mode_switch.is_on + await toggle("sleep") + assert switch.sleep_mode_switch.is_on + # Test brightness + await turn_on("brightness") + assert switch.adapt_brightness_switch.is_on + await turn_off("brightness") + assert not switch.adapt_brightness_switch.is_on + await toggle("brightness") + assert switch.adapt_brightness_switch.is_on + # Test color + await turn_on("color") + assert switch.adapt_color_switch.is_on + await turn_off("color") + assert not switch.adapt_color_switch.is_on + await toggle("color") + assert switch.adapt_color_switch.is_on + # Test main + await turn_on("main") + assert switch.is_on + await turn_off("main") + assert not switch.is_on + await toggle("main") + assert switch.is_on + + @pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES) async def test_change_switch_settings_service(hass): """Test adaptive_lighting.change_switch_settings service.""" From 7895075e7635cf866b57b0bce35f4dd93023689a Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Tue, 11 Apr 2023 19:08:49 -0500 Subject: [PATCH 02/11] Squashed commit of the following: commit 7069d693b86cf6db76a04332a8220ad4c8f54c49 Merge: adde5b5 a888afd Author: Benjamin Auquite Date: Mon Apr 10 12:52:19 2023 -0500 Merge branch 'main' into add-watched-lights commit adde5b56459be048dde50ad1191453692af72ac4 Author: Benjamin Auquite Date: Mon Apr 10 10:34:24 2023 -0500 fix pytest commit 2c5eaec2d3abad432292502f9b9b5ef0e5c74956 Author: Benjamin Auquite Date: Mon Apr 10 07:27:25 2023 -0500 Revert "Update const.py" This reverts commit e773af07ca2bf733619721286cdbd16a1f3eec56. commit e773af07ca2bf733619721286cdbd16a1f3eec56 Author: Benjamin Auquite Date: Mon Apr 10 07:20:23 2023 -0500 Update const.py commit c167f59ccdd342e7e29b9571a3f440d2c4851b97 Author: Benjamin Auquite Date: Mon Apr 10 06:09:14 2023 -0500 Update docs commit 6220df57d1c5f682b3eff4db41749d82d9639904 Author: Benjamin Auquite Date: Mon Apr 10 05:59:26 2023 -0500 fix the test commit ff292c2c1bf500a0d864c8e469e0ab9d41f9ef19 Merge: 4fdf42b 19d4468 Author: Benjamin Auquite Date: Mon Apr 10 05:39:17 2023 -0500 Merge branch 'add-watched-lights' of https://github.com/basnijholt/adaptive-lighting into add-watched-lights commit 4fdf42bea142d65c776f08497124ac8eeaf0888e Author: Benjamin Auquite Date: Mon Apr 10 05:39:05 2023 -0500 small fixes commit 19d4468f63ca2585d1c0a1c62943ed81b5f41913 Author: github-actions[bot] Date: Mon Apr 10 10:36:54 2023 +0000 Update README.md, strings.json, and services.yaml commit 70c90181f0fd9caf2cf49e13acbe850db3d143bb Merge: e30b7de b6a9265 Author: Benjamin Auquite Date: Mon Apr 10 10:34:49 2023 +0000 Merge b6a92650a9acdc46774297d0de040f9528711c32 into e30b7debe551e58cc6a738f7d15a4bb75d9bb545 commit b6a92650a9acdc46774297d0de040f9528711c32 Merge: 784eed0 ae04ab4 Author: Benjamin Auquite Date: Mon Apr 10 05:34:43 2023 -0500 Merge branch 'add-watched-lights' of https://github.com/basnijholt/adaptive-lighting into add-watched-lights commit 784eed09b31b891763c792df97dd630151ae4371 Author: Benjamin Auquite Date: Mon Apr 10 05:34:33 2023 -0500 squash merge `alt_detect_method` again commit ae04ab49a8cf8a34e2381728d60b405d7cf3a562 Author: github-actions[bot] Date: Mon Apr 10 10:34:12 2023 +0000 Update README.md, strings.json, and services.yaml commit cfbd65f976177485b13f82ef043d95db53bfd34d Merge: e30b7de 3c5ea73 Author: Benjamin Auquite Date: Mon Apr 10 10:32:34 2023 +0000 Merge 3c5ea73341389e086be8f5f481beb255695cd378 into e30b7debe551e58cc6a738f7d15a4bb75d9bb545 commit 3c5ea73341389e086be8f5f481beb255695cd378 Author: Benjamin Auquite Date: Mon Apr 10 05:32:27 2023 -0500 Update switch.py commit 2f750694d9f73e923eb6f1dfa14b510a0df56eb0 Merge: 429251b 7a9dc8f Author: Benjamin Auquite Date: Mon Apr 10 05:31:13 2023 -0500 Merge branch 'add-watched-lights' of https://github.com/basnijholt/adaptive-lighting into add-watched-lights commit 429251b45f219a856fd31e856a3b8e5fe25f0f9c Author: Benjamin Auquite Date: Mon Apr 10 05:31:04 2023 -0500 update docs commit 7a9dc8f24a0edabbcf19f5a3898e9c3ebf28652f Author: github-actions[bot] Date: Mon Apr 10 10:29:37 2023 +0000 Update README.md, strings.json, and services.yaml commit 37de4244b148a14bb04913b7fcd5eee45bf41b62 Merge: e30b7de d52ea53 Author: Benjamin Auquite Date: Mon Apr 10 10:28:01 2023 +0000 Merge d52ea53dcce038cf19a782dbd0b1c2de0e0dc1c9 into e30b7debe551e58cc6a738f7d15a4bb75d9bb545 commit d52ea53dcce038cf19a782dbd0b1c2de0e0dc1c9 Merge: dbc47c8 e30b7de Author: Benjamin Auquite Date: Mon Apr 10 05:27:43 2023 -0500 Merge branch 'main' into add-watched-lights commit dbc47c8a3693eb73de618f5396eee02fb42008b9 Author: Benjamin Auquite Date: Mon Apr 10 05:26:11 2023 -0500 Update switch.py commit 2db675a434567c535160929dbbc9a0a57bdd8354 Author: Benjamin Auquite Date: Mon Apr 10 05:25:44 2023 -0500 merge with `alt_detect_method` required for this PR commit dced97b5ca40ec159f40ca63764f1f0f47e12359 Author: Benjamin Auquite Date: Mon Apr 10 05:21:51 2023 -0500 merge with `origin\main` --- README.md | 64 +++--- custom_components/adaptive_lighting/const.py | 17 ++ .../adaptive_lighting/strings.json | 2 + custom_components/adaptive_lighting/switch.py | 196 ++++++++++++++---- .../adaptive_lighting/translations/en.json | 2 + tests/test_switch.py | 135 +++++++----- 6 files changed, 294 insertions(+), 122 deletions(-) diff --git a/README.md b/README.md index 9ba7cc03..b16e08f5 100644 --- a/README.md +++ b/README.md @@ -90,37 +90,39 @@ The YAML and frontend configuration methods support all of the options listed be -| Variable name | Description | Default | Type | -|:-------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------|:-------------------------------------| -| `lights` | List of light entity_ids to be controlled (may be empty). 🌟 | `[]` | list of `entity_id`s | -| `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | `False` | `bool` | -| `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 | `False` | `bool` | -| `initial_transition` | Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️ | `1` | `float` 0-6553 | -| `sleep_transition` | Duration of transition when "sleep mode" is toggled in seconds. 😴 | `1` | `float` 0-6553 | -| `transition` | Duration of transition when lights change, in seconds. 🕑 | `45` | `float` 0-6553 | -| `transition_until_sleep` | When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙 | `False` | `bool` | -| `interval` | Frequency to adapt the lights, in seconds. 🔄 | `90` | `int > 0` | -| `min_brightness` | Minimum brightness percentage. 💡 | `1` | `int` 1-100 | -| `max_brightness` | Maximum brightness percentage. 💡 | `100` | `int` 1-100 | -| `min_color_temp` | Warmest color temperature in Kelvin. 🔥 | `2000` | `int` 1000-10000 | -| `max_color_temp` | Coldest color temperature in Kelvin. ❄️ | `5500` | `int` 1000-10000 | -| `sleep_brightness` | Brightness percentage of lights in sleep mode. 😴 | `1` | `int` 1-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 | -| `sunrise_time` | Set a fixed time (HH:MM:SS) for sunrise. 🌅 | `None` | `str` | -| `max_sunrise_time` | Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅 | `None` | `str` | -| `sunrise_offset` | Adjust sunrise time with a positive or negative offset in seconds. ⏰ | `0` | `int` | -| `sunset_time` | Set a fixed time (HH:MM:SS) for sunset. 🌇 | `None` | `str` | -| `min_sunset_time` | Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇 | `None` | `str` | -| `sunset_offset` | Adjust sunset time with a positive or negative offset in seconds. ⏰ | `0` | `int` | -| `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄 | `False` | `bool` | -| `take_over_control` | Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒 | `True` | `bool` | -| `detect_non_ha_changes` | Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️ | `False` | `bool` | -| `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀 | `False` | `bool` | -| `send_split_delay` | Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️ | `0` | `int` 0-10000 | -| `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️ | `0` | `float > 0` | -| `autoreset_control_seconds` | Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️ | `0` | `int` 0-31536000 | +| Variable name | Description | Default | Type | +|:-------------------------------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------|:-------------------------------------| +| `lights` | List of light entity_ids to be controlled (may be empty). 🌟 | `[]` | list of `entity_id`s | +| `watched_lights` | Use this dictionary of lights to check for manually controlled events in addition to the main lights 🌟 Example: {light.watch_light: light.main_light} will fire manually controlled events to light.main_light | `{}` | list of `entity_id`s | +| `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | `False` | `bool` | +| `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 | `False` | `bool` | +| `initial_transition` | Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️ | `1` | `float` 0-6553 | +| `sleep_transition` | Duration of transition when "sleep mode" is toggled in seconds. 😴 | `1` | `float` 0-6553 | +| `transition` | Duration of transition when lights change, in seconds. 🕑 | `45` | `float` 0-6553 | +| `transition_until_sleep` | When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙 | `False` | `bool` | +| `interval` | Frequency to adapt the lights, in seconds. 🔄 | `90` | `int > 0` | +| `min_brightness` | Minimum brightness percentage. 💡 | `1` | `int` 1-100 | +| `max_brightness` | Maximum brightness percentage. 💡 | `100` | `int` 1-100 | +| `min_color_temp` | Warmest color temperature in Kelvin. 🔥 | `2000` | `int` 1000-10000 | +| `max_color_temp` | Coldest color temperature in Kelvin. ❄️ | `5500` | `int` 1000-10000 | +| `sleep_brightness` | Brightness percentage of lights in sleep mode. 😴 | `1` | `int` 1-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 | +| `sunrise_time` | Set a fixed time (HH:MM:SS) for sunrise. 🌅 | `None` | `str` | +| `max_sunrise_time` | Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅 | `None` | `str` | +| `sunrise_offset` | Adjust sunrise time with a positive or negative offset in seconds. ⏰ | `0` | `int` | +| `sunset_time` | Set a fixed time (HH:MM:SS) for sunset. 🌇 | `None` | `str` | +| `min_sunset_time` | Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇 | `None` | `str` | +| `sunset_offset` | Adjust sunset time with a positive or negative offset in seconds. ⏰ | `0` | `int` | +| `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄 | `False` | `bool` | +| `take_over_control` | Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒 | `True` | `bool` | +| `alt_detect_method` | alt_detect_method: When true, will check for any significant changes in the opposite direction of where adaptive-lighting tried to adapt last. This is an alternative to 'detect_non_ha_changes' (default: false) | `False` | `bool` | +| `detect_non_ha_changes` | Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️ | `False` | `bool` | +| `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀 | `False` | `bool` | +| `send_split_delay` | Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️ | `0` | `int` 0-10000 | +| `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️ | `0` | `float > 0` | +| `autoreset_control_seconds` | Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️ | `0` | `int` 0-31536000 | diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index babea0a5..fb8ff9ed 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -38,6 +38,12 @@ DOCS[CONF_DETECT_NON_HA_CHANGES] = ( "Requires `take_over_control`. 🕵️" ) +CONF_ALT_DETECT_METHOD, DEFAULT_ALT_DETECT_METHOD = "alt_detect_method", False +DOCS[CONF_ALT_DETECT_METHOD] = ( + "alt_detect_method: When true, will check for any significant changes in the opposite direction" + " of where adaptive-lighting tried to adapt last." + " This is an alternative to 'detect_non_ha_changes' (default: false)" +) CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, DEFAULT_INCLUDE_CONFIG_IN_ATTRIBUTES = ( "include_config_in_attributes", False, @@ -181,6 +187,15 @@ DOCS[CONF_AUTORESET_CONTROL] = ( "Set to 0 to disable. ⏲️" ) +CONF_WATCHED_LIGHTS, DEFAULT_WATCHED_LIGHTS = "watched_lights", {} +DOCS[CONF_WATCHED_LIGHTS] = ( + "Use this dictionary of lights to check for manually controlled events" + " in addition to the main lights 🌟" + " Requires `alt_detect_method` to be True." + " Example: `watched_lights: {light.watch_light: light.main_light}` will fire" + " manually controlled events to `light.main_light`" +) + SLEEP_MODE_SWITCH = "sleep_mode_switch" ADAPT_COLOR_SWITCH = "adapt_color_switch" ADAPT_BRIGHTNESS_SWITCH = "adapt_brightness_switch" @@ -243,6 +258,7 @@ def int_between(min_int, max_int): VALIDATION_TUPLES = [ (CONF_LIGHTS, DEFAULT_LIGHTS, cv.entity_ids), + (CONF_WATCHED_LIGHTS, DEFAULT_WATCHED_LIGHTS, dict), (CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR, bool), (CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, DEFAULT_INCLUDE_CONFIG_IN_ATTRIBUTES, bool), (CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION), @@ -280,6 +296,7 @@ VALIDATION_TUPLES = [ (CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET, int), (CONF_ONLY_ONCE, DEFAULT_ONLY_ONCE, bool), (CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL, bool), + (CONF_ALT_DETECT_METHOD, DEFAULT_ALT_DETECT_METHOD, bool), (CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES, bool), (CONF_SEPARATE_TURN_ON_COMMANDS, DEFAULT_SEPARATE_TURN_ON_COMMANDS, bool), (CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY, int_between(0, 10000)), diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index 54af5e11..7fb0367a 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -20,6 +20,7 @@ "description": "All settings for a Adaptive Lighting component. The option names correspond with the YAML settings. No options are shown if you have this entry defined in YAML.", "data": { "lights": "lights: List of light entity_ids to be controlled (may be empty). 🌟", + "watched_lights": "watched_lights: Use this dictionary of lights to check for manually controlled events in addition to the main lights 🌟 Example: {light.watch_light: light.main_light} will fire manually controlled events to light.main_light", "prefer_rgb_color": "prefer_rgb_color: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈", "include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝", "initial_transition": "initial_transition: Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️", @@ -43,6 +44,7 @@ "sunset_offset": "sunset_offset: Adjust sunset time with a positive or negative offset in seconds. ⏰", "only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄", "take_over_control": "take_over_control: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒", + "alt_detect_method": "alt_detect_method: alt_detect_method: When true, will check for any significant changes in the opposite direction of where adaptive-lighting tried to adapt last. This is an alternative to 'detect_non_ha_changes' (default: false)", "detect_non_ha_changes": "detect_non_ha_changes: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️", "separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀", "send_split_delay": "send_split_delay: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️", diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 276b6198..1f208f6e 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -106,6 +106,7 @@ from .const import ( ATTR_TURN_ON_OFF_LISTENER, CONF_ADAPT_DELAY, CONF_ADAPT_UNTIL_SLEEP, + CONF_ALT_DETECT_METHOD, CONF_AUTORESET_CONTROL, CONF_DETECT_NON_HA_CHANGES, CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, @@ -137,6 +138,7 @@ from .const import ( CONF_TURN_ON_LIGHTS, CONF_USE_DEFAULTS, CONF_WHICH_SWITCH, + CONF_WATCHED_LIGHTS, CONST_COLOR, DOMAIN, EXTRA_VALIDATION, @@ -805,6 +807,15 @@ def color_difference_redmean( return math.sqrt(red_term + green_term + blue_term) +def check_direction_change(last: int, current: int, last_adapt_value: int) -> bool: + _LOGGER.debug("compare direction: current value %s to last value %s", current, last) + if last_adapt_value < last: # Value adapting down + return current > last or current < last_adapt_value + elif last_adapt_value > last: # Value adapting up + return current < last or current > last_adapt_value + return False + + # All comparisons should be done with RGB since # converting anything to color temp is inaccurate. def _convert_attributes(attributes: dict[str, Any]) -> dict[str, Any]: @@ -847,6 +858,7 @@ def _attributes_have_changed( adapt_brightness: bool, adapt_color: bool, context: Context, + last_adapt_attempt=None, ) -> bool: if adapt_color: old_attributes, new_attributes = _add_missing_attributes( @@ -861,15 +873,33 @@ def _attributes_have_changed( last_brightness = old_attributes[ATTR_BRIGHTNESS] current_brightness = new_attributes[ATTR_BRIGHTNESS] if abs(current_brightness - last_brightness) > BRIGHTNESS_CHANGE: - _LOGGER.debug( - "Brightness of '%s' significantly changed from %s to %s with" - " context.id='%s'", - light, - last_brightness, - current_brightness, - context.id, - ) - return True + if last_adapt_attempt: + changed = check_direction_change( + last_brightness, + current_brightness, + last_adapt_attempt[ATTR_BRIGHTNESS], + ) + _LOGGER.debug( + "altdetect: Brightness of '%s' changed from %s to %s intended %s with" + " context.id='%s' Significant? %s", + light, + last_brightness, + current_brightness, + last_adapt_attempt[ATTR_BRIGHTNESS], + context.id, + changed, + ) + return changed + else: + _LOGGER.debug( + "Brightness of '%s' significantly changed from %s to %s with" + " context.id='%s'", + light, + last_brightness, + current_brightness, + context.id, + ) + return True if ( adapt_color @@ -879,15 +909,33 @@ def _attributes_have_changed( last_color_temp = old_attributes[ATTR_COLOR_TEMP_KELVIN] current_color_temp = new_attributes[ATTR_COLOR_TEMP_KELVIN] if abs(current_color_temp - last_color_temp) > COLOR_TEMP_CHANGE: - _LOGGER.debug( - "Color temperature of '%s' significantly changed from %s to %s with" - " context.id='%s'", - light, - last_color_temp, - current_color_temp, - context.id, - ) - return True + if last_adapt_attempt: + changed = check_direction_change( + last_color_temp, + current_color_temp, + last_adapt_attempt[ATTR_COLOR_TEMP_KELVIN], + ) + _LOGGER.debug( + "altdetect: Color temperature of '%s' changed from %s to %s intended %s with" + " context.id='%s' Significant? %s", + light, + last_color_temp, + current_color_temp, + last_adapt_attempt[ATTR_COLOR_TEMP_KELVIN], + context.id, + changed, + ) + return changed + else: + _LOGGER.debug( + "Color temperature of '%s' significantly changed from %s to %s with" + " context.id='%s'", + light, + last_color_temp, + current_color_temp, + context.id, + ) + return True if ( adapt_color @@ -1007,13 +1055,20 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self._transition = data[CONF_TRANSITION] self._adapt_delay = data[CONF_ADAPT_DELAY] self._send_split_delay = data[CONF_SEND_SPLIT_DELAY] + self._watched_lights = data[CONF_WATCHED_LIGHTS] self._take_over_control = data[CONF_TAKE_OVER_CONTROL] + self._alt_detect_method = data[CONF_ALT_DETECT_METHOD] self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES] - if not data[CONF_TAKE_OVER_CONTROL] and data[CONF_DETECT_NON_HA_CHANGES]: - _LOGGER.warning( - "%s: Config mismatch: 'detect_non_ha_changes: true' " - "requires 'take_over_control' to be enabled. Adjusting config " - "and continuing setup with `take_over_control: true`.", + if not data[CONF_TAKE_OVER_CONTROL] and ( + data[CONF_ALT_DETECT_METHOD] or data[CONF_DETECT_NON_HA_CHANGES] + ): + _LOGGER.warn( + "%s: Config mismatch: 'alt_detect_method: true'" + " OR 'detect_non_ha_changes: true' are set in config, however required" + " variable 'take_over_control' is turned off. Please check your" + " configuration to ensure desired functionality. We will now" + " enable 'take_over_control' and continue setting up the" + " adaptive-lighting integration normally.", self._name, ) self._take_over_control = True @@ -1367,6 +1422,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): transition: int | None, force: bool, context: Context | None, + adapt_brightness: bool | None = None, + adapt_color: bool | None = None, ) -> None: assert context is not None _LOGGER.debug( @@ -1381,13 +1438,17 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): adapt_brightness = self.adapt_brightness_switch.is_on adapt_color = self.adapt_color_switch.is_on - for light in lights: - if not is_on(self.hass, light): + all_lights = {k: k for k in lights} + all_lights.update(self._watched_lights) + + for wlight, mlight in all_lights.items(): + if not is_on(self.hass, mlight): continue manually_controlled = self.turn_on_off_listener.is_manually_controlled( self, - light, + wlight, + mlight, force, adapt_brightness, adapt_color, @@ -1398,7 +1459,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): and not force and await self.turn_on_off_listener.significant_change( self, - light, + wlight, + mlight, adapt_brightness, adapt_color, context, @@ -1410,13 +1472,15 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): _LOGGER.debug( "%s: '%s' is being manually controlled, stop adapting, context.id=%s.", self._name, - light, + mlight, context.id, ) else: - _fire_manual_control_event(self, light, context) + _fire_manual_control_event(self, mlight, context) else: - await self._adapt_light(light, transition, force=force, context=context) + await self._adapt_light( + wlight, transition, force=force, context=context + ) async def _sleep_mode_switch_state_event(self, event: Event) -> None: if not match_switch_state_event(event, (STATE_ON, STATE_OFF)): @@ -2019,18 +2083,19 @@ class TurnOnOffListener: def is_manually_controlled( self, switch: AdaptiveSwitch, - light: str, + wlight: str, + mlight: str, force: bool, adapt_brightness: bool, adapt_color: bool, ) -> bool: """Check if the light has been 'on' and is now manually controlled.""" - manual_control = self.manual_control.setdefault(light, False) + manual_control = self.manual_control.setdefault(wlight, False) if manual_control: # Manually controlled until light is turned on and off return True - turn_on_event = self.turn_on_event.get(light) + turn_on_event = self.turn_on_event.get(wlight) if ( turn_on_event is not None and not is_our_context(turn_on_event.context) @@ -2043,13 +2108,13 @@ class TurnOnOffListener: # Light was already on and 'light.turn_on' was not called by # the adaptive_lighting integration. manual_control = True - _fire_manual_control_event(switch, light, turn_on_event.context) + _fire_manual_control_event(switch, wlight, turn_on_event.context) _LOGGER.debug( "'%s' was already on and 'light.turn_on' was not called by the" " adaptive_lighting integration (context.id='%s'), the Adaptive" " Lighting will stop adapting the light until the switch or the" " light turns off and then on again.", - light, + mlight, turn_on_event.context.id, ) return manual_control @@ -2057,7 +2122,8 @@ class TurnOnOffListener: async def significant_change( self, switch: AdaptiveSwitch, - light: str, + wlight: str, + mlight: str, adapt_brightness: bool, adapt_color: bool, context: Context, @@ -2069,35 +2135,77 @@ class TurnOnOffListener: detected, we mark the light as 'manually controlled' until the light or switch is turned 'off' and 'on' again. """ - last_service_data = self.last_service_data.get(light) + last_service_data = self.last_service_data.get(wlight) if last_service_data is None: return compare_to = functools.partial( _attributes_have_changed, - light=light, + light=wlight, adapt_brightness=adapt_brightness, adapt_color=adapt_color, context=context, ) + if switch._alt_detect_method or wlight != mlight: + old_states: list[State] = self.last_state_change[wlight] + _LOGGER.debug("Total state changes detected: %s", len(old_states)) + _LOGGER.debug( + "%s: 'alt_detect_method: true', check all state changes made to light %s", + switch._name, + wlight, + ) + for index, old_state in enumerate(old_states): + # The first entry of old_states should always be the + # same as last_service_data[light], and can be ignored. + if index <= 1: + continue + _LOGGER.debug( + "%s: checking for a manual change between index %s and %s...", + switch._name, + index, + index - 1, + ) + prior_state = old_states[index - 1] + if compare_to( + old_attributes=prior_state.attributes, + new_attributes=old_state.attributes, + last_adapt_attempt=last_service_data, + ): + _LOGGER.info( + "Found unexpected state_change event for %s nr. %s (context.id=%s)" + " old_state=%s\nprior_state=%s", + wlight, + index, + context.id, + old_state, + prior_state, + ) + _LOGGER.info( + "We will now set %s as manually controlled. (context.id=%s)", + wlight, + context.id, + ) + return True # Update state and check for a manual change not done in HA. # Ensure HASS is correctly updating your light's state with # light.turn_on calls if any problems arise. This # can happen e.g. using zigbee2mqtt with 'report: false' in device settings. if switch._detect_non_ha_changes: + if wlight != mlight: + return # only supported with alt_detect_method _LOGGER.debug( "%s: 'detect_non_ha_changes: true', calling update_entity(%s)" " and check if it's last adapt succeeded.", switch._name, - light, + wlight, ) # This update_entity probably isn't necessary now that we're checking # if transitions finished from our last adapt. - await self.hass.helpers.entity_component.async_update_entity(light) - refreshed_state = self.hass.states.get(light) + await self.hass.helpers.entity_component.async_update_entity(wlight) + refreshed_state = self.hass.states.get(wlight) _LOGGER.debug( "%s: Current state of %s: %s", switch._name, - light, + wlight, refreshed_state, ) changed = compare_to( @@ -2107,7 +2215,7 @@ class TurnOnOffListener: if changed: _LOGGER.debug( "State of '%s' didn't change wrt 'last_service_data' (context.id=%s)", - light, + wlight, context.id, ) return True @@ -2115,7 +2223,7 @@ class TurnOnOffListener: "%s: Light '%s' correctly matches our last adapt's service data, continuing..." " context.id=%s.", switch._name, - light, + wlight, context.id, ) return False diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index 38fb7b0e..91b59d6a 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -21,6 +21,7 @@ "description": "All settings for a Adaptive Lighting component. The option names correspond with the YAML settings. No options are shown if you have the adaptive_lighting entry defined in your YAML configuration.", "data": { "lights": "lights: List of light entity_ids to be controlled (may be empty). 🌟", + "watched_lights": "watched_lights: Use this dictionary of lights to check for manually controlled events in addition to the main lights 🌟 Example: {light.watch_light: light.main_light} will fire manually controlled events to light.main_light", "prefer_rgb_color": "prefer_rgb_color: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈", "include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝", "initial_transition": "initial_transition: Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️", @@ -44,6 +45,7 @@ "sunset_offset": "sunset_offset: Adjust sunset time with a positive or negative offset in seconds. ⏰", "only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄", "take_over_control": "take_over_control: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒", + "alt_detect_method": "alt_detect_method: alt_detect_method: When true, will check for any significant changes in the opposite direction of where adaptive-lighting tried to adapt last. This is an alternative to 'detect_non_ha_changes' (default: false)", "detect_non_ha_changes": "detect_non_ha_changes: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️", "separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀", "send_split_delay": "send_split_delay: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️", diff --git a/tests/test_switch.py b/tests/test_switch.py index c569f16d..f6083bc3 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -11,6 +11,7 @@ from homeassistant.components.adaptive_lighting.const import ( ADAPT_BRIGHTNESS_SWITCH, ADAPT_COLOR_SWITCH, ATTR_TURN_ON_OFF_LISTENER, + CONF_ALT_DETECT_METHOD, CONF_AUTORESET_CONTROL, CONF_DETECT_NON_HA_CHANGES, CONF_INITIAL_TRANSITION, @@ -22,6 +23,7 @@ from homeassistant.components.adaptive_lighting.const import ( CONF_SUNRISE_OFFSET, CONF_SUNRISE_TIME, CONF_SUNSET_TIME, + CONF_TAKE_OVER_CONTROL, CONF_TRANSITION, CONF_TURN_ON_LIGHTS, CONF_USE_DEFAULTS, @@ -214,7 +216,9 @@ async def setup_lights_and_switch(hass, extra_conf=None): CONF_SUNSET_TIME: datetime.time(SUNSET.hour), CONF_INITIAL_TRANSITION: 0, CONF_TRANSITION: 0, - CONF_DETECT_NON_HA_CHANGES: True, + CONF_ALT_DETECT_METHOD: False, + CONF_DETECT_NON_HA_CHANGES: False, + CONF_TAKE_OVER_CONTROL: True, CONF_PREFER_RGB_COLOR: False, CONF_MIN_COLOR_TEMP: 2500, # to not coincide with sleep_color_temp **(extra_conf or {}), @@ -668,7 +672,7 @@ async def test_manual_control(hass): @pytest.mark.dependency(depends=[*GLOBAL_TEST_DEPENDENCIES, "test_manual_control"]) async def test_auto_reset_manual_control(hass): switch, (light, *_) = await setup_lights_and_switch( - hass, {CONF_AUTORESET_CONTROL: 0.1} + hass, {CONF_AUTORESET_CONTROL: 0.2} ) context = switch.create_context("test") # needs to be passed to update method manual_control = switch.turn_on_off_listener.manual_control @@ -913,7 +917,7 @@ async def test_state_change_handlers(hass): # [Config options]: transition_used = 2 - total_events = 5 + total_events = 6 async def set_brightness(val: int): # 'Unsafe' set but we know what we're doing. @@ -1034,58 +1038,95 @@ async def test_state_change_handlers(hass): assert listener.transition_timers.get(ENTITY_LIGHT) # 5. Execute some checks during a transition - _LOGGER.debug("Test detect_non_ha_changes:") - switch._take_over_control = True - assert switch._take_over_control - switch._detect_non_ha_changes = True - assert switch._detect_non_ha_changes - await asyncio.sleep(transition_used / 3) - # Ensure the timer still exists - timer = listener.transition_timers.get(ENTITY_LIGHT) - assert timer and timer.is_running() - last_service_data = deepcopy(current_service_data) - await update() - assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] - await update() - assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] - timer = listener.transition_timers.get(ENTITY_LIGHT) - assert timer and timer.is_running() - # Ensure the light did not adapt during the transition. - assert last_service_data == current_service_data + + for i in range(2): + if i == 0: + _LOGGER.debug("Test detect_non_ha_changes before a transition:") + switch._take_over_control = True + assert switch._take_over_control + switch._detect_non_ha_changes = True + assert switch._detect_non_ha_changes + switch._alt_detect_method = False + assert not switch._alt_detect_method + elif i == 1: + _LOGGER.debug("Test alt_detect_method before a transition:") + switch._take_over_control = True + assert switch._take_over_control + switch._detect_non_ha_changes = False + assert not switch._detect_non_ha_changes + switch._alt_detect_method = True + assert switch._alt_detect_method + await asyncio.sleep(transition_used / 3) + # Ensure the timer still exists + timer = listener.transition_timers.get(ENTITY_LIGHT) + assert timer and timer.is_running() + last_service_data = deepcopy(current_service_data) + await update() + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + await update() + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + timer = listener.transition_timers.get(ENTITY_LIGHT) + assert timer and timer.is_running() + # Ensure the light did not adapt during the transition. + assert last_service_data == current_service_data # 6. Assert everything after the transition finishes. await asyncio.sleep(transition_used) - assert listener.last_state_change.get(ENTITY_LIGHT) - assert len(listener.last_state_change[ENTITY_LIGHT]) == total_events - # Timer should be done and reset now. - # This is the assert that I can't fix. - timer = listener.transition_timers.get(ENTITY_LIGHT) - assert not timer or not timer.is_running() - # build last service data - await update(force=False) + for i in range(2): + if i == 0: + _LOGGER.debug("Test detect_non_ha_changes after a transition:") + switch._take_over_control = True + assert switch._take_over_control + switch._detect_non_ha_changes = True + assert switch._detect_non_ha_changes + switch._alt_detect_method = False + assert not switch._alt_detect_method + elif i == 1: + _LOGGER.debug("Test alt_detect_method after a transition:") + switch._take_over_control = True + assert switch._take_over_control + switch._detect_non_ha_changes = False + assert not switch._detect_non_ha_changes + switch._alt_detect_method = True + assert switch._alt_detect_method - # force=True should not reset manual control. - await turn_light(True, brightness=40) - await turn_light(True, brightness=20) - await update(force=False) - assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] - await update(force=True) - assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + assert listener.last_state_change.get(ENTITY_LIGHT) + if i == 1: + total_events = 2 + assert len(listener.last_state_change[ENTITY_LIGHT]) == total_events + # Timer should be done and reset now. + timer = listener.transition_timers.get(ENTITY_LIGHT) + assert not timer or not timer.is_running() - # turn light off then on should reset manual control. - await turn_light(False) - await turn_light(True) - assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + # build last service data + await update(force=False) - await turn_light(True, brightness=50) - _LOGGER.debug("Test: Brightness set to %s", 50) + # force=True should not reset manual control. + await turn_light(True, brightness=40) + await turn_light(True, brightness=20) + await update(force=False) + assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + await update(force=True) + assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] - # On next update ENTITY_LIGHT should be marked as manually controlled - await update(force=False) - assert switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None - assert switch.turn_on_off_listener.last_state_change.get(ENTITY_LIGHT) is not None - assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + # turn light off then on should reset manual control. + await turn_light(False) + await turn_light(True) + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + + await turn_light(True, brightness=50) + _LOGGER.debug("Test: Brightness set to %s", 50) + + # On next update ENTITY_LIGHT should be marked as manually controlled + await update(force=False) + assert ( + switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None + ) + assert ( + switch.turn_on_off_listener.last_state_change.get(ENTITY_LIGHT) is not None + ) + assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] @pytest.mark.dependency( From d1a7b9f9709d61905e2bf89673c41c49dcb9e8f6 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Tue, 11 Apr 2023 19:09:38 -0500 Subject: [PATCH 03/11] Squashed commit of the following: commit aad2eaf595e21d1aec83772c935507957c1c9708 Merge: 090c0ab a888afd Author: Benjamin Auquite Date: Mon Apr 10 12:52:37 2023 -0500 Merge branch 'main' into add_flat_limits_to_configuration commit 090c0abc6d9e16d5cc41af52daeecb2bc3891be1 Author: github-actions[bot] Date: Mon Apr 10 09:30:40 2023 +0000 Update README.md, strings.json, and services.yaml commit cb27264d6a6f956a844039fdd11e8941bd6f48d1 Merge: e30b7de f18c5f1 Author: Benjamin Auquite Date: Mon Apr 10 09:28:50 2023 +0000 Merge f18c5f161b5b6a6bbe6420fce745b57743e0faa6 into e30b7debe551e58cc6a738f7d15a4bb75d9bb545 commit f18c5f161b5b6a6bbe6420fce745b57743e0faa6 Author: Benjamin Auquite Date: Mon Apr 10 03:59:33 2023 -0500 initial commit --- custom_components/adaptive_lighting/const.py | 9 +++++++++ custom_components/adaptive_lighting/strings.json | 1 + custom_components/adaptive_lighting/switch.py | 11 ++++++++++- .../adaptive_lighting/translations/en.json | 1 + 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index fb8ff9ed..c61187c3 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -79,6 +79,14 @@ DOCS[CONF_MIN_BRIGHTNESS] = "Minimum brightness percentage. 💡" CONF_MIN_COLOR_TEMP, DEFAULT_MIN_COLOR_TEMP = "min_color_temp", 2000 DOCS[CONF_MIN_COLOR_TEMP] = "Warmest color temperature in Kelvin. 🔥" +CONF_FLAT_LIMITS, DEFAULT_FLAT_LIMITS = "flat_limits", False +DOCS[CONF_FLAT_LIMITS] = ( + "When True, will not calculate between the" + " max/min supported limits of your light. Example: when adapting brightness to 50% while " + + CONF_MAX_BRIGHTNESS + + " is set to 80%, Adaptive Lighting will use 80% instead of 90%" +) + CONF_ONLY_ONCE, DEFAULT_ONLY_ONCE = "only_once", False DOCS[CONF_ONLY_ONCE] = ( "Adapt lights only when they are turned on (`true`) or keep adapting them " @@ -295,6 +303,7 @@ VALIDATION_TUPLES = [ (CONF_MIN_SUNSET_TIME, NONE_STR, str), (CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET, int), (CONF_ONLY_ONCE, DEFAULT_ONLY_ONCE, bool), + (CONF_FLAT_LIMITS, DEFAULT_FLAT_LIMITS, bool), (CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL, bool), (CONF_ALT_DETECT_METHOD, DEFAULT_ALT_DETECT_METHOD, bool), (CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES, bool), diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index 7fb0367a..869456c2 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -43,6 +43,7 @@ "min_sunset_time": "min_sunset_time: Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇", "sunset_offset": "sunset_offset: Adjust sunset time with a positive or negative offset in seconds. ⏰", "only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄", + "flat_limits": "flat_limits: When True, will not calculate between the max/min supported limits of your light. Example: when adapting brightness to 50% while max_brightness is set to 80%, Adaptive Lighting will use 80% instead of 90%", "take_over_control": "take_over_control: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒", "alt_detect_method": "alt_detect_method: alt_detect_method: When true, will check for any significant changes in the opposite direction of where adaptive-lighting tried to adapt last. This is an alternative to 'detect_non_ha_changes' (default: false)", "detect_non_ha_changes": "detect_non_ha_changes: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️", diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 1f208f6e..3c4942cd 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -109,6 +109,7 @@ from .const import ( CONF_ALT_DETECT_METHOD, CONF_AUTORESET_CONTROL, CONF_DETECT_NON_HA_CHANGES, + CONF_FLAT_LIMITS, CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, CONF_INITIAL_TRANSITION, CONF_INTERVAL, @@ -1086,6 +1087,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): name=self._name, astral_location=location, adapt_until_sleep=data[CONF_ADAPT_UNTIL_SLEEP], + flat_limits=data[CONF_FLAT_LIMITS], max_brightness=data[CONF_MAX_BRIGHTNESS], max_color_temp=data[CONF_MAX_COLOR_TEMP], min_brightness=data[CONF_MIN_BRIGHTNESS], @@ -1634,6 +1636,7 @@ class SunLightSettings: name: str astral_location: astral.Location adapt_until_sleep: bool + flat_limits: bool max_brightness: int max_color_temp: int min_brightness: int @@ -1773,8 +1776,14 @@ class SunLightSettings: return self.sleep_brightness if percent > 0: return self.max_brightness - delta_brightness = self.max_brightness - self.min_brightness percent = 1 + percent + if self.flat_limits: + if percent * 100 > self.max_brightness: + return self.max_brightness + elif percent * 100 < self.min_brightness: + return self.min_brightness + return percent * 100 + delta_brightness = self.max_brightness - self.min_brightness return (delta_brightness * percent) + self.min_brightness def calc_color_temp_kelvin(self, percent: float) -> int: diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index 91b59d6a..27873dba 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -44,6 +44,7 @@ "min_sunset_time": "min_sunset_time: Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇", "sunset_offset": "sunset_offset: Adjust sunset time with a positive or negative offset in seconds. ⏰", "only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄", + "flat_limits": "flat_limits: When True, will not calculate between the max/min supported limits of your light. Example: when adapting brightness to 50% while max_brightness is set to 80%, Adaptive Lighting will use 80% instead of 90%", "take_over_control": "take_over_control: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒", "alt_detect_method": "alt_detect_method: alt_detect_method: When true, will check for any significant changes in the opposite direction of where adaptive-lighting tried to adapt last. This is an alternative to 'detect_non_ha_changes' (default: false)", "detect_non_ha_changes": "detect_non_ha_changes: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️", From f0b487320ddfcf0f9178362db451d4e930ebf798 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Tue, 11 Apr 2023 19:10:37 -0500 Subject: [PATCH 04/11] Squashed commit of the following: commit 5b927dbb020443b7272f7353fd056d512afe61a4 Merge: 78395ee a888afd Author: Benjamin Auquite Date: Mon Apr 10 12:52:46 2023 -0500 Merge branch 'main' into adjust_adapt_until_sleep commit 78395eea6503bad462ab38826882e480199bcdef Author: github-actions[bot] Date: Mon Apr 10 09:38:11 2023 +0000 Update README.md, strings.json, and services.yaml commit bc39f5e977b12faf03e6403fb476a396635300d4 Merge: e30b7de c4b5067 Author: Benjamin Auquite Date: Mon Apr 10 09:36:39 2023 +0000 Merge c4b5067b052527cefbfb014a1dd024550865434e into e30b7debe551e58cc6a738f7d15a4bb75d9bb545 commit c4b5067b052527cefbfb014a1dd024550865434e Merge: 7244a5f 91d6065 Author: Benjamin Auquite Date: Mon Apr 10 04:36:25 2023 -0500 Merge branch 'adjust_adapt_until_sleep' of https://github.com/basnijholt/adaptive-lighting into adjust_adapt_until_sleep commit 7244a5f48374b9c0f54ee20af5b857e94faf03eb Author: Benjamin Auquite Date: Mon Apr 10 04:36:14 2023 -0500 Update const.py commit 91d606586fd48bb9d27025f7f84e1aac50b9a926 Author: github-actions[bot] Date: Mon Apr 10 09:28:58 2023 +0000 Update README.md, strings.json, and services.yaml commit 4509c0a0f1b809d5821d003c1f336afd395e7837 Merge: e30b7de e46d867 Author: Benjamin Auquite Date: Mon Apr 10 09:27:02 2023 +0000 Merge e46d867854ad0ebe000868f4e82be03111104d7a into e30b7debe551e58cc6a738f7d15a4bb75d9bb545 commit e46d867854ad0ebe000868f4e82be03111104d7a Merge: e7e266b 3b61f49 Author: Benjamin Auquite Date: Mon Apr 10 04:26:56 2023 -0500 Merge branch 'adjust_adapt_until_sleep' of https://github.com/basnijholt/adaptive-lighting into adjust_adapt_until_sleep commit e7e266b5a19385cee711ff6ed920addf6eefb6d7 Author: Benjamin Auquite Date: Mon Apr 10 04:26:47 2023 -0500 Update const.py commit 3b61f49fb395343788530f95bf1bd4fe0cce85ee Author: github-actions[bot] Date: Mon Apr 10 09:26:09 2023 +0000 Update README.md, strings.json, and services.yaml commit 7f952e06585b62d1d3ca770a0cae3f85a5b0f8e8 Merge: e30b7de a4d5f86 Author: Benjamin Auquite Date: Mon Apr 10 09:24:27 2023 +0000 Merge a4d5f861ff2e0cadcfc33bef1a78dc3c840cdbcc into e30b7debe551e58cc6a738f7d15a4bb75d9bb545 commit a4d5f861ff2e0cadcfc33bef1a78dc3c840cdbcc Author: Benjamin Auquite Date: Mon Apr 10 04:24:20 2023 -0500 autofill workaround commit 06dd2a5c9a9d5ca983996c32123864b3ca6be41c Author: Benjamin Auquite Date: Mon Apr 10 04:23:01 2023 -0500 Revert "Merge branch 'adjust_adapt_until_sleep' of https://github.com/basnijholt/adaptive-lighting into adjust_adapt_until_sleep" This reverts commit bca9f1bd3d9428e09c5695082059e493e7085c35, reversing changes made to 840aab6158229875e04911de4a45549ad244074e. commit bca9f1bd3d9428e09c5695082059e493e7085c35 Merge: 840aab6 2d74a31 Author: Benjamin Auquite Date: Mon Apr 10 04:22:19 2023 -0500 Merge branch 'adjust_adapt_until_sleep' of https://github.com/basnijholt/adaptive-lighting into adjust_adapt_until_sleep commit 840aab6158229875e04911de4a45549ad244074e Author: Benjamin Auquite Date: Mon Apr 10 04:22:07 2023 -0500 add `adapt_color_temp_until_sleep` supports backward-compatibility commit 2d74a313bb7b6ae7d5c6eb8fb2191db0c44c69db Author: github-actions[bot] Date: Mon Apr 10 09:14:31 2023 +0000 Update README.md, strings.json, and services.yaml commit 553265a1e8cab8ab38bba57e926edd033e21f0cd Merge: e30b7de b53a5af Author: Benjamin Auquite Date: Mon Apr 10 09:12:53 2023 +0000 Merge b53a5aff68f06501c41f64070e2cc9f327f250c2 into e30b7debe551e58cc6a738f7d15a4bb75d9bb545 commit b53a5aff68f06501c41f64070e2cc9f327f250c2 Author: Benjamin Auquite Date: Mon Apr 10 04:11:48 2023 -0500 add new config option commit 4e1c769feb0a78188ed2dc49143147f634ad0a94 Author: Benjamin Auquite Date: Mon Apr 10 04:06:09 2023 -0500 initial commit --- custom_components/adaptive_lighting/const.py | 19 ++++++++++++++++++- .../adaptive_lighting/strings.json | 4 +++- custom_components/adaptive_lighting/switch.py | 12 +++++++++++- .../adaptive_lighting/translations/en.json | 4 +++- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index c61187c3..af5bdb0a 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -173,9 +173,24 @@ CONF_ADAPT_UNTIL_SLEEP, DEFAULT_ADAPT_UNTIL_SLEEP = ( False, ) DOCS[CONF_ADAPT_UNTIL_SLEEP] = ( + "This option ignores the current state of the sleep switch. " "When enabled, Adaptive Lighting will treat sleep settings as the minimum, " - "transitioning to these values after sunset. 🌙" + "transitioning color temperature to these values after sunset. 🌙" ) +CONF_ADAPT_COLOR_TEMP_UNTIL_SLEEP, DEFAULT_ADAPT_COLOR_TEMP_UNTIL_SLEEP = ( + "adapt_color_temp_until_sleep", + True, +) +DOCS[ + CONF_ADAPT_COLOR_TEMP_UNTIL_SLEEP +] = "Only active when `transition_until_sleep` is true." +CONF_ADAPT_BRIGHTNESS_UNTIL_SLEEP, DEFAULT_ADAPT_BRIGHTNESS_UNTIL_SLEEP = ( + "adapt_brightness_until_sleep", + False, +) +DOCS[ + CONF_ADAPT_BRIGHTNESS_UNTIL_SLEEP +] = "Only active when `transition_until_sleep` is true." CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY = "adapt_delay", 0 DOCS[CONF_ADAPT_DELAY] = ( @@ -273,6 +288,8 @@ VALIDATION_TUPLES = [ (CONF_SLEEP_TRANSITION, DEFAULT_SLEEP_TRANSITION, VALID_TRANSITION), (CONF_TRANSITION, DEFAULT_TRANSITION, VALID_TRANSITION), (CONF_ADAPT_UNTIL_SLEEP, DEFAULT_ADAPT_UNTIL_SLEEP, bool), + (CONF_ADAPT_BRIGHTNESS_UNTIL_SLEEP, DEFAULT_ADAPT_BRIGHTNESS_UNTIL_SLEEP, bool), + (CONF_ADAPT_COLOR_TEMP_UNTIL_SLEEP, DEFAULT_ADAPT_COLOR_TEMP_UNTIL_SLEEP, bool), (CONF_INTERVAL, DEFAULT_INTERVAL, cv.positive_int), (CONF_MIN_BRIGHTNESS, DEFAULT_MIN_BRIGHTNESS, int_between(1, 100)), (CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS, int_between(1, 100)), diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index 869456c2..53fafd98 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -26,7 +26,9 @@ "initial_transition": "initial_transition: Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️", "sleep_transition": "sleep_transition: Duration of transition when \"sleep mode\" is toggled in seconds. 😴", "transition": "transition: Duration of transition when lights change, in seconds. 🕑", - "transition_until_sleep": "transition_until_sleep: When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙", + "transition_until_sleep": "transition_until_sleep: This option ignores the current state of the sleep switch. When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning color temperature to these values after sunset. 🌙", + "adapt_brightness_until_sleep": "adapt_brightness_until_sleep: Only active when `transition_until_sleep` is true.", + "adapt_color_temp_until_sleep": "adapt_color_temp_until_sleep: Only active when `transition_until_sleep` is true.", "interval": "interval: Frequency to adapt the lights, in seconds. 🔄", "min_brightness": "min_brightness: Minimum brightness percentage. 💡", "max_brightness": "max_brightness: Maximum brightness percentage. 💡", diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 3c4942cd..6437b192 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -104,6 +104,8 @@ from .const import ( ATTR_ADAPT_BRIGHTNESS, ATTR_ADAPT_COLOR, ATTR_TURN_ON_OFF_LISTENER, + CONF_ADAPT_BRIGHTNESS_UNTIL_SLEEP, + CONF_ADAPT_COLOR_TEMP_UNTIL_SLEEP, CONF_ADAPT_DELAY, CONF_ADAPT_UNTIL_SLEEP, CONF_ALT_DETECT_METHOD, @@ -1086,6 +1088,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self._sun_light_settings = SunLightSettings( name=self._name, astral_location=location, + adapt_brightness_until_sleep=data[CONF_ADAPT_BRIGHTNESS_UNTIL_SLEEP], + adapt_color_temp_until_sleep=data[CONF_ADAPT_COLOR_TEMP_UNTIL_SLEEP], adapt_until_sleep=data[CONF_ADAPT_UNTIL_SLEEP], flat_limits=data[CONF_FLAT_LIMITS], max_brightness=data[CONF_MAX_BRIGHTNESS], @@ -1635,6 +1639,8 @@ class SunLightSettings: name: str astral_location: astral.Location + adapt_brightness_until_sleep: bool + adapt_color_temp_until_sleep: bool adapt_until_sleep: bool flat_limits: bool max_brightness: int @@ -1776,6 +1782,10 @@ class SunLightSettings: return self.sleep_brightness if percent > 0: return self.max_brightness + if self.adapt_until_sleep and self.adapt_brightness_until_sleep and percent < 0: + delta_brightness = abs(self.min_brightness - self.sleep_brightness) + return (delta_brightness * abs(1 + percent)) + self.sleep_brightness + delta_brightness = self.max_brightness - self.min_brightness percent = 1 + percent if self.flat_limits: if percent * 100 > self.max_brightness: @@ -1794,7 +1804,7 @@ class SunLightSettings: return 5 * round(ct / 5) # round to nearest 5 if percent == 0 or not self.adapt_until_sleep: return self.min_color_temp - if self.adapt_until_sleep and percent < 0: + if self.adapt_until_sleep and self.adapt_color_temp_until_sleep and percent < 0: delta = abs(self.min_color_temp - self.sleep_color_temp) ct = (delta * abs(1 + percent)) + self.sleep_color_temp return 5 * round(ct / 5) # round to nearest 5 diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index 27873dba..5e4f6193 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -27,7 +27,9 @@ "initial_transition": "initial_transition: Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️", "sleep_transition": "sleep_transition: Duration of transition when \"sleep mode\" is toggled in seconds. 😴", "transition": "transition: Duration of transition when lights change, in seconds. 🕑", - "transition_until_sleep": "transition_until_sleep: When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙", + "transition_until_sleep": "transition_until_sleep: This option ignores the current state of the sleep switch. When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning color temperature to these values after sunset. 🌙", + "adapt_brightness_until_sleep": "adapt_brightness_until_sleep: Only active when `transition_until_sleep` is true.", + "adapt_color_temp_until_sleep": "adapt_color_temp_until_sleep: Only active when `transition_until_sleep` is true.", "interval": "interval: Frequency to adapt the lights, in seconds. 🔄", "min_brightness": "min_brightness: Minimum brightness percentage. 💡", "max_brightness": "max_brightness: Maximum brightness percentage. 💡", From ce4e04358e925cb2e114d3606e8074f1b44eda63 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Tue, 11 Apr 2023 19:11:28 -0500 Subject: [PATCH 05/11] Squashed commit of the following: commit 1ddae7ac4854c9cb8cfedfea829e9b660cc8366d Merge: 0a4c7ed a888afd Author: Benjamin Auquite Date: Mon Apr 10 12:52:56 2023 -0500 Merge branch 'main' into compare-service-datas-with-ignore-fields commit 0a4c7ed1dd3aa2d411bf6bb3a70fb5aacd6c8240 Author: Benjamin Auquite Date: Sun Apr 9 23:26:41 2023 -0500 Revert "keep line length under 100" This reverts commit 461efb5fed6bf42a8d65be41dae761410d3d9dcb. commit 461efb5fed6bf42a8d65be41dae761410d3d9dcb Author: Benjamin Auquite Date: Sun Apr 9 22:38:04 2023 -0500 keep line length under 100 commit beece5c06f3f80b6148e5a5aa8d4ea7dfb39c497 Author: Benjamin Auquite Date: Sun Apr 9 22:33:50 2023 -0500 ignore ATTR_TRANSITION in checks # Conflicts: # README.md --- custom_components/adaptive_lighting/switch.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 6437b192..749dd7e0 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1327,12 +1327,16 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): elif supports_colors and adapt_color: _LOGGER.debug("%s: Setting rgb_color of light %s", self._name, light) service_data[ATTR_RGB_COLOR] = self._settings["rgb_color"] - - context = context or self.create_context("adapt_lights") - - # See #80. Doesn't check if transitions differ but it does the job. - last_service_data = self.turn_on_off_listener.last_service_data - if not force and last_service_data.get(light) == service_data: + # Check if service data differs from the last. See #80. + listener = self.turn_on_off_listener + last_service_data = listener.last_service_data.get(light) + ignore_fields = {ATTR_TRANSITION} + if ( + not force + and last_service_data + and {k for k, _ in last_service_data.items() ^ service_data.items()} + == ignore_fields + ): _LOGGER.debug( "%s: Cancelling adapt to light %s, there's no new values to set (context.id='%s')", self._name, @@ -1340,8 +1344,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): context.id, ) return - else: - self.turn_on_off_listener.last_service_data[light] = service_data + listener.last_service_data[light] = service_data + + context = context or self.create_context("adapt_lights") async def turn_on(service_data): _LOGGER.debug( From 9bec45b63c95d63796c7ca223ec0fee3145b55c6 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Tue, 11 Apr 2023 19:12:20 -0500 Subject: [PATCH 06/11] Squashed commit of the following: commit 1edaf1d7ed2a47fd326ef3163428e3c035322231 Merge: 0e7e06e a888afd Author: Benjamin Auquite Date: Mon Apr 10 12:57:30 2023 -0500 Merge branch 'main' into unconventional_sunrise_sunset_times commit 0e7e06e8a46158d078e2b15742af1de9dec2be3e Merge: 8fa554a fe7bdd3 Author: Benjamin Auquite Date: Sat Apr 8 20:23:20 2023 -0500 Merge branch 'main' into unconventional_sunrise_sunset_times commit 8fa554a4e7610fb462a49ade1a1f5a642ae5e090 Merge: ec64eda 39e9d0e Author: Benjamin Auquite Date: Sat Apr 8 00:50:16 2023 -0500 merge commit ec64eda6bff4c1887bc79c8cdb46d78685a45f90 Merge: 8353db5 744e43f Author: Benjamin Auquite Date: Tue Apr 4 21:08:54 2023 -0500 Merge branch 'main' into unconventional_sunrise_sunset_times commit 8353db54cc1f0a64c481bbc6430b5cd3185dece4 Author: Benjamin Auquite Date: Tue Apr 4 00:56:24 2023 -0500 tests are unfinished commit 6cb24116c01a3ede391168d1c3e1aa3782667c88 Author: Benjamin Auquite Date: Mon Apr 3 21:17:01 2023 -0500 Update switch.py --- .../workflows/install_dependencies/action.yml | 3 +- custom_components/adaptive_lighting/switch.py | 20 ++- tests/test_switch.py | 154 ++++++++++++++++++ 3 files changed, 172 insertions(+), 5 deletions(-) diff --git a/.github/workflows/install_dependencies/action.yml b/.github/workflows/install_dependencies/action.yml index df0bee8a..a092a8a0 100644 --- a/.github/workflows/install_dependencies/action.yml +++ b/.github/workflows/install_dependencies/action.yml @@ -34,9 +34,10 @@ runs: - name: Install dependencies shell: bash run: | - echo "::warning::### WARNING! Deprecation warnings muted with option '--use-pep517' please address this at some point in pytest.yaml. ###" + echo "::warning::### WARNING! Deprecation warnings muted with option '--use-pep517' please address this at some point in '\workflows\install_dependencies\action.yml'. ###" pip install -r core/requirements.txt --use-pep517 pip install -r core/requirements_test.txt --use-pep517 pip install -e core/ --use-pep517 pip install ulid-transform # this is in Adaptive-lighting's manifest.json pip install $(python test_dependencies.py) --use-pep517 + pip install jaraco --use-pep517 diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 749dd7e0..d952b3c3 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1682,15 +1682,27 @@ class SunLightSettings: def calculate_noon_and_midnight( sunset: datetime.datetime, sunrise: datetime.datetime ) -> tuple[datetime.datetime, datetime.datetime]: - middle = abs(sunset - sunrise) / 2 + total = abs(sunset - sunrise) + middle = total / 2 + total = ( + total.total_seconds() / 60 / 60 * (2 / 3) + ) # about 12 hours normally. + _LOGGER.debug( + "Calculate noon/midnight. Total diff: %s, middle: %s", total, middle + ) if sunset > sunrise: noon = sunrise + middle - midnight = noon + timedelta(hours=12) * (1 if noon.hour < 12 else -1) + midnight = noon + timedelta(hours=total) * ( + 1 if noon.hour < total else -1 + ) else: midnight = sunset + middle - noon = midnight + timedelta(hours=12) * ( - 1 if midnight.hour < 12 else -1 + noon = midnight + timedelta(hours=total) * ( + 1 if midnight.hour < total else -1 ) + _LOGGER.debug( + "Calculate noon/midnight. Noon: %s Midnight: %s", noon, midnight + ) return noon, midnight location = self.astral_location diff --git a/tests/test_switch.py b/tests/test_switch.py index f6083bc3..5303f9cb 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -501,6 +501,160 @@ async def test_light_settings(hass): assert_expected_color_temp(state) +async def test_unconventional_sun_events(hass): + """Test unconventional sunrise/noon/sunset/midnight times.""" + + async def change_switch_settings(switch_entity_id, service_data): + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE_SWITCH_SETTINGS, + { + ATTR_ENTITY_ID: switch_entity_id, + **service_data, + }, + blocking=True, + ) + await hass.async_block_till_done() + + switch, _ = await setup_lights_and_switch(hass) + + # Set config options for the test. + # change_switch_settings( + # switch.unique_id, + # { + # "sunrise_time": "03:00:00", + # "sunset_time": "05:00:00", + # "transition_until_sleep": True, + # }, + # ) + + lights = switch._lights + + # Turn on "sleep mode" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_SLEEP_MODE_SWITCH}, + blocking=True, + ) + await hass.async_block_till_done() + light_states = [hass.states.get(light) for light in lights] + for state in light_states: + assert state.attributes[ATTR_BRIGHTNESS] == round( + 255 * switch._settings[ATTR_BRIGHTNESS_PCT] / 100 + ) + last_service_data = switch.turn_on_off_listener.last_service_data[ + state.entity_id + ] + assert state.attributes[ATTR_BRIGHTNESS] == last_service_data[ATTR_BRIGHTNESS] + assert ( + state.attributes[ATTR_COLOR_TEMP_KELVIN] + == last_service_data[ATTR_COLOR_TEMP_KELVIN] + ) + + # Turn off "sleep mode" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_SLEEP_MODE_SWITCH}, + blocking=True, + ) + await hass.async_block_till_done() + + # Test with different times + + context = switch.create_context("test") # needs to be passed to update method + + # Test with different times + sun_events = ( + switch._sun_light_settings.get_sun_events( # pylint: disable=protected-access + dt_util.utcnow() + ) + ) + + # get our custom sun events. + sunrise = sun_events[0][1] + noon = sun_events[1][1] + sunset = sun_events[2][1] + midnight = sun_events[3][1] + assert sunrise < noon + assert noon < sunset + assert sunset < midnight + + total = sunset - sunrise + # quarter = total / 4 + + before_sunset = datetime.datetime.fromtimestamp(sunset - (total / 12)) + after_sunset = datetime.datetime.fromtimestamp(sunset + (total / 12)) + before_sunrise = datetime.datetime.fromtimestamp(sunrise - (total / 12)) + after_sunrise = datetime.datetime.fromtimestamp(sunrise + (total / 12)) + + test_date = datetime.datetime.strptime("10/17/2020", "%m/%d/%Y").date() + sunrise_time = ( + switch._sun_light_settings.sunrise_time + ) # pylint: disable=protected-access + sunset_time = ( + switch._sun_light_settings.sunrise_time + ) # pylint: disable=protected-access + sunrise_dt = datetime.datetime.combine(test_date, sunrise_time) + sunset_dt = datetime.datetime.combine(test_date, sunset_time) + + async def patch_time_and_get_updated_states(time): + with patch("homeassistant.util.dt.utcnow", return_value=time): + await switch._update_attrs_and_maybe_adapt_lights( + transition=0, context=context, force=True + ) + await hass.async_block_till_done() + return [hass.states.get(light) for light in lights] + + def assert_expected_color_temp(state): + last_service_data = switch.turn_on_off_listener.last_service_data[ + state.entity_id + ] + assert ( + state.attributes[ATTR_COLOR_TEMP_KELVIN] + == last_service_data[ATTR_COLOR_TEMP_KELVIN] + ) + + # At sunset the brightness should be max and color_temp at the smallest value + light_states = await patch_time_and_get_updated_states(sunset_dt) + for state in light_states: + assert state.attributes[ATTR_BRIGHTNESS] == 255 + assert_expected_color_temp(state) + + # One hour before sunset the brightness should be max and color_temp + # not at the smallest value yet. + light_states = await patch_time_and_get_updated_states(before_sunset) + for state in light_states: + assert state.attributes[ATTR_BRIGHTNESS] == 255 + assert_expected_color_temp(state) + + # One hour after sunset the brightness should be down + light_states = await patch_time_and_get_updated_states(after_sunset) + for state in light_states: + assert state.attributes[ATTR_BRIGHTNESS] < 255 + assert_expected_color_temp(state) + + # At sunrise the brightness should be max and color_temp at the smallest value + light_states = await patch_time_and_get_updated_states(sunrise_dt) + for state in light_states: + assert state.attributes[ATTR_BRIGHTNESS] == 255 + assert_expected_color_temp(state) + + # One hour before sunrise the brightness should smaller than max + # and color_temp at the min value. + light_states = await patch_time_and_get_updated_states(before_sunrise) + for state in light_states: + assert state.attributes[ATTR_BRIGHTNESS] < 255 + assert_expected_color_temp(state) + + # One hour after sunrise the brightness should be up + light_states = await patch_time_and_get_updated_states(after_sunrise) + for state in light_states: + assert state.attributes[ATTR_BRIGHTNESS] == 255 + assert_expected_color_temp(state) + + @pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES) async def test_turn_on_off_listener_not_tracking_untracked_lights(hass): """Test that lights that are not in a Adaptive Lighting switch aren't tracked.""" From ea2e533186fb7ca56dcd3ad5dfae7691ea19f5d0 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Tue, 11 Apr 2023 19:16:56 -0500 Subject: [PATCH 07/11] squash 'flexible service calls' --- custom_components/adaptive_lighting/const.py | 49 ++-- custom_components/adaptive_lighting/switch.py | 254 +++++++++--------- tests/test_switch.py | 33 ++- 3 files changed, 182 insertions(+), 154 deletions(-) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index af5bdb0a..6f73f86d 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -239,9 +239,9 @@ DOCS[CONF_TURN_ON_LIGHTS] = "Whether to turn on lights that are currently off. SERVICE_CHANGE_SWITCH_SETTINGS = "change_switch_settings" CONF_USE_DEFAULTS = "use_defaults" DOCS[CONF_USE_DEFAULTS] = ( - "Sets the default values not specified in this service call. Options: " + "Where to autofill config options that are not passed to this service. Options: " '"current" (default, retains current values), "factory" (resets to ' - 'documented defaults), or "configuration" (reverts to switch config defaults). ⚙️' + 'documented defaults), or "configuration" (reverts to original user config). ⚙️' ) CONF_WHICH_SWITCH, DEFAULT_WHICH_SWITCH = "switch_type", "main" @@ -384,25 +384,19 @@ _DOMAIN_SCHEMA = vol.Schema( ) -def apply_service_schema(initial_transition: int = 1): - """Return the schema for the apply service.""" - return vol.Schema( - { - vol.Optional(CONF_ENTITY_ID): cv.entity_ids, - vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, - vol.Optional( - CONF_TRANSITION, - default=initial_transition, - ): VALID_TRANSITION, - vol.Optional(ATTR_ADAPT_BRIGHTNESS, default=True): cv.boolean, - vol.Optional(ATTR_ADAPT_COLOR, default=True): cv.boolean, - vol.Optional(CONF_PREFER_RGB_COLOR, default=False): cv.boolean, - vol.Optional(CONF_TURN_ON_LIGHTS, default=False): cv.boolean, - } - ) +SCHEMA_APPLY = vol.Schema( + { + vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, + vol.Optional(CONF_TRANSITION): VALID_TRANSITION, + vol.Optional(ATTR_ADAPT_BRIGHTNESS, default=True): cv.boolean, + vol.Optional(ATTR_ADAPT_COLOR, default=True): cv.boolean, + vol.Optional(CONF_PREFER_RGB_COLOR, default=False): cv.boolean, + vol.Optional(CONF_TURN_ON_LIGHTS, default=False): cv.boolean, + } +) - -SERVICE_TOGGLE_SCHEMA = vol.Schema( +SCHEMA_SERVICE_TOGGLE = vol.Schema( { vol.Optional(CONF_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, @@ -410,10 +404,23 @@ SERVICE_TOGGLE_SCHEMA = vol.Schema( } ) -SET_MANUAL_CONTROL_SCHEMA = vol.Schema( +SCHEMA_SET_MANUAL_CONTROL = vol.Schema( { vol.Optional(CONF_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, vol.Optional(CONF_MANUAL_CONTROL, default=True): cv.boolean, } ) + +SCHEMA_CHANGE_SWITCH_SETTINGS = vol.Schema( + { + vol.Optional(CONF_USE_DEFAULTS): cv.string, + vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + vol.Required(CONF_LIGHTS, default=[]): [], + **{ + vol.Optional(k): valid + for k, _, valid in VALIDATION_TUPLES + if k not in [CONF_INTERVAL, CONF_NAME, CONF_LIGHTS] + }, + } +) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index d952b3c3..64831526 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -78,7 +78,7 @@ from homeassistant.core import ( async_get_hass, callback, ) -from homeassistant.helpers import entity_platform, entity_registry +from homeassistant.helpers import entity_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import ( async_track_state_change_event, @@ -96,7 +96,6 @@ from homeassistant.util.color import ( ) import homeassistant.util.dt as dt_util import ulid_transform -import voluptuous as vol from .const import ( ADAPT_BRIGHTNESS_SWITCH, @@ -140,8 +139,8 @@ from .const import ( CONF_TRANSITION, CONF_TURN_ON_LIGHTS, CONF_USE_DEFAULTS, - CONF_WHICH_SWITCH, CONF_WATCHED_LIGHTS, + CONF_WHICH_SWITCH, CONST_COLOR, DOMAIN, EXTRA_VALIDATION, @@ -149,17 +148,18 @@ from .const import ( ICON_COLOR_TEMP, ICON_MAIN, ICON_SLEEP, + SCHEMA_APPLY, + SCHEMA_CHANGE_SWITCH_SETTINGS, + SCHEMA_SERVICE_TOGGLE, + SCHEMA_SET_MANUAL_CONTROL, SERVICE_APPLY, SERVICE_CHANGE_SWITCH_SETTINGS, SERVICE_SET_MANUAL_CONTROL, - SERVICE_TOGGLE_SCHEMA, - SET_MANUAL_CONTROL_SCHEMA, SLEEP_MODE_SWITCH, SUN_EVENT_MIDNIGHT, SUN_EVENT_NOON, TURNING_OFF_DELAY, VALIDATION_TUPLES, - apply_service_schema, replace_none_str, ) @@ -311,9 +311,9 @@ def _split_service_data(service_data, adapt_brightness, adapt_color): def _get_switches_with_lights( - hass: HomeAssistant, lights: list[str] + hass: HomeAssistant, lights: list[str] | None = None ) -> list[AdaptiveSwitch]: - """Get all switches that control at least one of the lights passed.""" + """Get all switches. If lights is defined, return only switches found with these lights.""" config_entries = hass.config_entries.async_entries(DOMAIN) data = hass.data[DOMAIN] switches = [] @@ -322,10 +322,13 @@ def _get_switches_with_lights( if entry is None: # entry might be disabled and therefore missing continue switch = data[config.entry_id]["instance"] - all_check_lights = _expand_light_groups(hass, lights) - switch._expand_light_groups() - # Check if any of the lights are in the switch's lights - if set(switch._lights) & set(all_check_lights): + if lights: + all_check_lights = _expand_light_groups(hass, lights) + switch._expand_light_groups() + # Check if any of the lights are in the switch's lights + if set(switch._lights) & set(all_check_lights): + switches.append(switch) + else: switches.append(switch) return switches @@ -367,13 +370,7 @@ def _get_switches_from_service_call( switch_entity_ids: list[str] | None = data.get("entity_id") if not lights and not switch_entity_ids: - raise ValueError( - "adaptive-lighting: Neither a switch nor a light was provided in the service call." - " If you intend to adapt all lights on all switches, please inform the developers at" - " https://github.com/basnijholt/adaptive-lighting about your use case." - " Currently, you must pass either an adaptive-lighting switch or the lights to an" - " `adaptive_lighting` service call." - ) + return _get_switches_with_lights(hass) if switch_entity_ids is not None: if len(switch_entity_ids) > 1 and lights: @@ -399,39 +396,117 @@ def _get_switches_from_service_call( ) -async def handle_change_switch_settings( - switch: AdaptiveSwitch, service_call: ServiceCall -) -> None: - """Allows HASS to change config values via a service call.""" +@callback +async def handle_apply(service_call: ServiceCall): + """Handle the entity service apply.""" + hass = async_get_hass() data = service_call.data - - which = data.get(CONF_USE_DEFAULTS, "current") - if which == "current": # use whatever we're already using. - defaults = switch._current_settings # pylint: disable=protected-access - elif which == "factory": # use actual defaults listed in the documentation - defaults = {key: default for key, default, _ in VALIDATION_TUPLES} - elif which == "configuration": - # use whatever's in the config flow or configuration.yaml - defaults = switch._config_backup # pylint: disable=protected-access - else: - defaults = None - - switch._set_changeable_settings( - data=data, - defaults=defaults, + _LOGGER.debug( + "Called 'adaptive_lighting.apply' service with '%s'", + data, ) + switches = _get_switches_from_service_call(hass, service_call) + lights = data[CONF_LIGHTS] + for switch in switches: + if not lights: + all_lights = switch._lights # pylint: disable=protected-access + else: + all_lights = _expand_light_groups(switch.hass, lights) + switch.turn_on_off_listener.lights.update(all_lights) + for light in all_lights: + transition = data.get(CONF_TRANSITION) + if not data[CONF_TURN_ON_LIGHTS]: + if not is_on(hass, light): + continue + if not transition: + transition = switch._transition # pylint: disable=protected-access + elif not transition: + transition = ( + switch._initial_transition + ) # pylint: disable=protected-access + await switch._adapt_light( # pylint: disable=protected-access + light, + transition, + data[ATTR_ADAPT_BRIGHTNESS], + data[ATTR_ADAPT_COLOR], + data[CONF_PREFER_RGB_COLOR], + force=True, + context=switch.create_context("service", parent=service_call.context), + ) + +@callback +async def handle_set_manual_control(service_call: ServiceCall): + """Set or unset lights as 'manually controlled'.""" + hass = async_get_hass() + data = service_call.data + _LOGGER.debug( + "Called 'adaptive_lighting.set_manual_control' service with '%s'", + data, + ) + switches = _get_switches_from_service_call(hass, service_call) + lights = data[CONF_LIGHTS] + for switch in switches: + if not lights: + all_lights = switch._lights # pylint: disable=protected-access + else: + all_lights = _expand_light_groups(switch.hass, lights) + if service_call.data[CONF_MANUAL_CONTROL]: + for light in all_lights: + _fire_manual_control_event(switch, light, service_call.context) + else: + switch.turn_on_off_listener.reset(*all_lights) + if switch.is_on: + # pylint: disable=protected-access + await switch._update_attrs_and_maybe_adapt_lights( + all_lights, + transition=switch._initial_transition, + force=True, + context=switch.create_context( + "service", parent=service_call.context + ), + ) + + +@callback +async def handle_change_switch_settings(service_call: ServiceCall) -> None: + """Allows HASS to change config values via a service call.""" + hass = async_get_hass() + data = service_call.data _LOGGER.debug( "Called 'adaptive_lighting.change_switch_settings' service with '%s'", data, ) - all_lights = switch._lights # pylint: disable=protected-access - switch.turn_on_off_listener.reset(*all_lights, reset_manual_control=False) - if switch.is_on: + switches = _get_switches_from_service_call(hass, service_call) + for switch in switches: + # which denotes where to autofill blank config options. + which = data.get(CONF_USE_DEFAULTS, "current") + if which == "current": + # use whatever we're already using. + defaults = switch._current_settings # pylint: disable=protected-access + elif which == "factory": + # use actual defaults listed in the documentation + defaults = {key: default for key, default, _ in VALIDATION_TUPLES} + elif which == "configuration": + # use whatever's in the config flow or configuration.yaml + defaults = switch._config_backup # pylint: disable=protected-access + else: + defaults = None + + switch._set_changeable_settings( + data=data, + defaults=defaults, + ) + + all_lights = switch._lights # pylint: disable=protected-access + switch.turn_on_off_listener.reset(*all_lights, reset_manual_control=False) + + if not switch.is_on: + continue await switch._update_attrs_and_maybe_adapt_lights( # pylint: disable=protected-access all_lights, - transition=switch._initial_transition, + transition=switch._transition, force=True, context=switch.create_context("service", parent=service_call.context), ) @@ -504,72 +579,6 @@ async def handle_toggle(service_call: ServiceCall): await switch.async_turn_on() -@callback -async def handle_apply(service_call: ServiceCall): - """Handle the entity service apply.""" - hass = async_get_hass() - data = service_call.data - _LOGGER.debug( - "Called 'adaptive_lighting.apply' service with '%s'", - data, - ) - switches = _get_switches_from_service_call(hass, service_call) - lights = data[CONF_LIGHTS] - for switch in switches: - if not lights: - all_lights = switch._lights # pylint: disable=protected-access - else: - all_lights = _expand_light_groups(switch.hass, lights) - switch.turn_on_off_listener.lights.update(all_lights) - for light in all_lights: - if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light): - await switch._adapt_light( # pylint: disable=protected-access - light, - data[CONF_TRANSITION], - data[ATTR_ADAPT_BRIGHTNESS], - data[ATTR_ADAPT_COLOR], - data[CONF_PREFER_RGB_COLOR], - force=True, - context=switch.create_context( - "service", parent=service_call.context - ), - ) - - -@callback -async def handle_set_manual_control(service_call: ServiceCall): - """Set or unset lights as 'manually controlled'.""" - hass = async_get_hass() - data = service_call.data - _LOGGER.debug( - "Called 'adaptive_lighting.set_manual_control' service with '%s'", - data, - ) - switches = _get_switches_from_service_call(hass, service_call) - lights = data[CONF_LIGHTS] - for switch in switches: - if not lights: - all_lights = switch._lights # pylint: disable=protected-access - else: - all_lights = _expand_light_groups(switch.hass, lights) - if service_call.data[CONF_MANUAL_CONTROL]: - for light in all_lights: - switch.turn_on_off_listener.mark_as_manual_control(light) - _fire_manual_control_event(switch, light, service_call.context) - else: - switch.turn_on_off_listener.reset(*all_lights) - if switch.is_on: - # pylint: disable=protected-access - await switch._update_attrs_and_maybe_adapt_lights( - all_lights, - transition=switch._initial_transition, - force=True, - context=switch.create_context( - "service", parent=service_call.context - ), - ) - - @callback def _fire_manual_control_event( switch: AdaptiveSwitch, light: str, context: Context, is_async=True @@ -636,9 +645,7 @@ async def async_setup_entry( domain=DOMAIN, service=SERVICE_APPLY, service_func=handle_apply, - schema=apply_service_schema( - switch._initial_transition - ), # pylint: disable=protected-access + schema=SCHEMA_APPLY, ) # Register `turn_on` service @@ -646,7 +653,7 @@ async def async_setup_entry( domain=DOMAIN, service=SERVICE_TURN_ON, service_func=handle_turn_on, - schema=SERVICE_TOGGLE_SCHEMA, + schema=SCHEMA_SERVICE_TOGGLE, ) # Register `turn_off` service @@ -654,7 +661,7 @@ async def async_setup_entry( domain=DOMAIN, service=SERVICE_TURN_OFF, service_func=handle_turn_off, - schema=SERVICE_TOGGLE_SCHEMA, + schema=SCHEMA_SERVICE_TOGGLE, ) # Register `toggle` service @@ -662,7 +669,7 @@ async def async_setup_entry( domain=DOMAIN, service=SERVICE_TOGGLE, service_func=handle_toggle, - schema=SERVICE_TOGGLE_SCHEMA, + schema=SCHEMA_SERVICE_TOGGLE, ) # Register `set_manual_control` service @@ -670,20 +677,15 @@ async def async_setup_entry( domain=DOMAIN, service=SERVICE_SET_MANUAL_CONTROL, service_func=handle_set_manual_control, - schema=SET_MANUAL_CONTROL_SCHEMA, + schema=SCHEMA_SET_MANUAL_CONTROL, ) - args = {vol.Optional(CONF_USE_DEFAULTS, default="current"): cv.string} - # Modifying these after init isn't possible - skip = (CONF_INTERVAL, CONF_NAME, CONF_LIGHTS) - for k, _, valid in VALIDATION_TUPLES: - if k not in skip: - args[vol.Optional(k)] = valid - platform = entity_platform.current_platform.get() - platform.async_register_entity_service( - SERVICE_CHANGE_SWITCH_SETTINGS, - args, - handle_change_switch_settings, + # Register `change_switch_settings` service + hass.services.async_register( + domain=DOMAIN, + service=SERVICE_CHANGE_SWITCH_SETTINGS, + service_func=handle_change_switch_settings, + schema=SCHEMA_CHANGE_SWITCH_SETTINGS, ) diff --git a/tests/test_switch.py b/tests/test_switch.py index 5303f9cb..d59fdb67 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -1529,6 +1529,9 @@ async def test_switch_turn_on_off_toggle(hass): async def test_change_switch_settings_service(hass): """Test adaptive_lighting.change_switch_settings service.""" switch, (_, _, light) = await setup_lights_and_switch(hass) + switch2, (_, light2, _) = await setup_lights_and_switch( + hass, {CONF_NAME: "second_switch"} + ) entity_id = light.entity_id assert entity_id not in switch._lights @@ -1537,7 +1540,6 @@ async def test_change_switch_settings_service(hass): DOMAIN, SERVICE_CHANGE_SWITCH_SETTINGS, { - ATTR_ENTITY_ID: ENTITY_SWITCH, **kwargs, }, blocking=True, @@ -1546,12 +1548,16 @@ async def test_change_switch_settings_service(hass): # Test changing sunrise offset assert switch._sun_light_settings.sunrise_offset.total_seconds() == 0 - await change_switch_settings(**{CONF_SUNRISE_OFFSET: 10}) + await change_switch_settings( + **{ATTR_ENTITY_ID: ENTITY_SWITCH, CONF_SUNRISE_OFFSET: 10} + ) assert switch._sun_light_settings.sunrise_offset.total_seconds() == 10 # Test changing max brightness assert switch._sun_light_settings.max_brightness == 100 - await change_switch_settings(**{CONF_MAX_BRIGHTNESS: 50}) + await change_switch_settings( + **{ATTR_ENTITY_ID: ENTITY_SWITCH, CONF_MAX_BRIGHTNESS: 50} + ) assert switch._sun_light_settings.max_brightness == 50 # Test changing to illegal max brightness @@ -1559,20 +1565,33 @@ async def test_change_switch_settings_service(hass): voluptuous.error.MultipleInvalid, match="value must be at most 100 for dictionary", ): - await change_switch_settings(**{CONF_MAX_BRIGHTNESS: 5000}) + await change_switch_settings( + **{ATTR_ENTITY_ID: ENTITY_SWITCH, CONF_MAX_BRIGHTNESS: 5000} + ) # Change CONF_MIN_COLOR_TEMP, the factory default is 2000, but setup_lights_and_switch # sets it to 2500 assert switch._sun_light_settings.min_color_temp == 2500 # testing with "factory" should change it to 2000 - await change_switch_settings(**{CONF_USE_DEFAULTS: "factory"}) + await change_switch_settings( + **{ATTR_ENTITY_ID: ENTITY_SWITCH, CONF_USE_DEFAULTS: "factory"} + ) assert switch._sun_light_settings.min_color_temp == 2000 # testing with "current" should not change things - await change_switch_settings(**{CONF_USE_DEFAULTS: "current"}) + await change_switch_settings( + **{ATTR_ENTITY_ID: ENTITY_SWITCH, CONF_USE_DEFAULTS: "current"} + ) assert switch._sun_light_settings.min_color_temp == 2000 # testing with "configuration" should revert back to 2500 - await change_switch_settings(**{CONF_USE_DEFAULTS: "configuration"}) + await change_switch_settings( + **{ATTR_ENTITY_ID: ENTITY_SWITCH, CONF_USE_DEFAULTS: "configuration"} + ) assert switch._sun_light_settings.min_color_temp == 2500 + + # testing with no switches or lights defined. + assert switch2._sun_light_settings.max_brightness == 100 + await change_switch_settings(**{CONF_MAX_BRIGHTNESS: 50}) + assert switch2._sun_light_settings.max_brightness == 50 From 77812de08ca8b19a838ffae81c7ef0cc421697a5 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Tue, 11 Apr 2023 19:30:26 -0500 Subject: [PATCH 08/11] remove unconventional sun events check never finished --- tests/test_switch.py | 154 ------------------------------------------- 1 file changed, 154 deletions(-) diff --git a/tests/test_switch.py b/tests/test_switch.py index d59fdb67..ce0f17be 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -501,160 +501,6 @@ async def test_light_settings(hass): assert_expected_color_temp(state) -async def test_unconventional_sun_events(hass): - """Test unconventional sunrise/noon/sunset/midnight times.""" - - async def change_switch_settings(switch_entity_id, service_data): - await hass.services.async_call( - DOMAIN, - SERVICE_CHANGE_SWITCH_SETTINGS, - { - ATTR_ENTITY_ID: switch_entity_id, - **service_data, - }, - blocking=True, - ) - await hass.async_block_till_done() - - switch, _ = await setup_lights_and_switch(hass) - - # Set config options for the test. - # change_switch_settings( - # switch.unique_id, - # { - # "sunrise_time": "03:00:00", - # "sunset_time": "05:00:00", - # "transition_until_sleep": True, - # }, - # ) - - lights = switch._lights - - # Turn on "sleep mode" - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_SLEEP_MODE_SWITCH}, - blocking=True, - ) - await hass.async_block_till_done() - light_states = [hass.states.get(light) for light in lights] - for state in light_states: - assert state.attributes[ATTR_BRIGHTNESS] == round( - 255 * switch._settings[ATTR_BRIGHTNESS_PCT] / 100 - ) - last_service_data = switch.turn_on_off_listener.last_service_data[ - state.entity_id - ] - assert state.attributes[ATTR_BRIGHTNESS] == last_service_data[ATTR_BRIGHTNESS] - assert ( - state.attributes[ATTR_COLOR_TEMP_KELVIN] - == last_service_data[ATTR_COLOR_TEMP_KELVIN] - ) - - # Turn off "sleep mode" - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: ENTITY_SLEEP_MODE_SWITCH}, - blocking=True, - ) - await hass.async_block_till_done() - - # Test with different times - - context = switch.create_context("test") # needs to be passed to update method - - # Test with different times - sun_events = ( - switch._sun_light_settings.get_sun_events( # pylint: disable=protected-access - dt_util.utcnow() - ) - ) - - # get our custom sun events. - sunrise = sun_events[0][1] - noon = sun_events[1][1] - sunset = sun_events[2][1] - midnight = sun_events[3][1] - assert sunrise < noon - assert noon < sunset - assert sunset < midnight - - total = sunset - sunrise - # quarter = total / 4 - - before_sunset = datetime.datetime.fromtimestamp(sunset - (total / 12)) - after_sunset = datetime.datetime.fromtimestamp(sunset + (total / 12)) - before_sunrise = datetime.datetime.fromtimestamp(sunrise - (total / 12)) - after_sunrise = datetime.datetime.fromtimestamp(sunrise + (total / 12)) - - test_date = datetime.datetime.strptime("10/17/2020", "%m/%d/%Y").date() - sunrise_time = ( - switch._sun_light_settings.sunrise_time - ) # pylint: disable=protected-access - sunset_time = ( - switch._sun_light_settings.sunrise_time - ) # pylint: disable=protected-access - sunrise_dt = datetime.datetime.combine(test_date, sunrise_time) - sunset_dt = datetime.datetime.combine(test_date, sunset_time) - - async def patch_time_and_get_updated_states(time): - with patch("homeassistant.util.dt.utcnow", return_value=time): - await switch._update_attrs_and_maybe_adapt_lights( - transition=0, context=context, force=True - ) - await hass.async_block_till_done() - return [hass.states.get(light) for light in lights] - - def assert_expected_color_temp(state): - last_service_data = switch.turn_on_off_listener.last_service_data[ - state.entity_id - ] - assert ( - state.attributes[ATTR_COLOR_TEMP_KELVIN] - == last_service_data[ATTR_COLOR_TEMP_KELVIN] - ) - - # At sunset the brightness should be max and color_temp at the smallest value - light_states = await patch_time_and_get_updated_states(sunset_dt) - for state in light_states: - assert state.attributes[ATTR_BRIGHTNESS] == 255 - assert_expected_color_temp(state) - - # One hour before sunset the brightness should be max and color_temp - # not at the smallest value yet. - light_states = await patch_time_and_get_updated_states(before_sunset) - for state in light_states: - assert state.attributes[ATTR_BRIGHTNESS] == 255 - assert_expected_color_temp(state) - - # One hour after sunset the brightness should be down - light_states = await patch_time_and_get_updated_states(after_sunset) - for state in light_states: - assert state.attributes[ATTR_BRIGHTNESS] < 255 - assert_expected_color_temp(state) - - # At sunrise the brightness should be max and color_temp at the smallest value - light_states = await patch_time_and_get_updated_states(sunrise_dt) - for state in light_states: - assert state.attributes[ATTR_BRIGHTNESS] == 255 - assert_expected_color_temp(state) - - # One hour before sunrise the brightness should smaller than max - # and color_temp at the min value. - light_states = await patch_time_and_get_updated_states(before_sunrise) - for state in light_states: - assert state.attributes[ATTR_BRIGHTNESS] < 255 - assert_expected_color_temp(state) - - # One hour after sunrise the brightness should be up - light_states = await patch_time_and_get_updated_states(after_sunrise) - for state in light_states: - assert state.attributes[ATTR_BRIGHTNESS] == 255 - assert_expected_color_temp(state) - - @pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES) async def test_turn_on_off_listener_not_tracking_untracked_lights(hass): """Test that lights that are not in a Adaptive Lighting switch aren't tracked.""" From b87ee215b3a3a0b96831b7d804620a6ef561e38b Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Tue, 11 Apr 2023 19:37:36 -0500 Subject: [PATCH 09/11] didn't mean to include this --- .github/workflows/install_dependencies/action.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/install_dependencies/action.yml b/.github/workflows/install_dependencies/action.yml index a092a8a0..97e671c1 100644 --- a/.github/workflows/install_dependencies/action.yml +++ b/.github/workflows/install_dependencies/action.yml @@ -40,4 +40,3 @@ runs: pip install -e core/ --use-pep517 pip install ulid-transform # this is in Adaptive-lighting's manifest.json pip install $(python test_dependencies.py) --use-pep517 - pip install jaraco --use-pep517 From 9a2943db5060fb3c2ac19f7e1283dae0dbf40fca Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Tue, 11 Apr 2023 19:45:40 -0500 Subject: [PATCH 10/11] Revert a888afd6dcdfd335a22e45f8b6be0bbd959ebd87 --- custom_components/adaptive_lighting/const.py | 2 - custom_components/adaptive_lighting/switch.py | 95 ++++++------------- 2 files changed, 31 insertions(+), 66 deletions(-) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 6f73f86d..6f300b52 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -334,8 +334,6 @@ VALIDATION_TUPLES = [ ), ] -CONST_COLOR = "color" - def timedelta_as_int(value): """Convert a `datetime.timedelta` object to an integer. diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 64831526..d0b1492f 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -23,11 +23,7 @@ from homeassistant.components.light import ( ATTR_COLOR_NAME, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_MAX_COLOR_TEMP_KELVIN, - ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, - ATTR_RGBW_COLOR, - ATTR_RGBWW_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, ATTR_XY_COLOR, @@ -36,7 +32,6 @@ from homeassistant.components.light import ( COLOR_MODE_HS, COLOR_MODE_RGB, COLOR_MODE_RGBW, - COLOR_MODE_RGBWW, COLOR_MODE_XY, ) from homeassistant.components.light import ( @@ -141,7 +136,6 @@ from .const import ( CONF_USE_DEFAULTS, CONF_WATCHED_LIGHTS, CONF_WHICH_SWITCH, - CONST_COLOR, DOMAIN, EXTRA_VALIDATION, ICON_BRIGHTNESS, @@ -170,16 +164,6 @@ _SUPPORT_OPTS = { "transition": SUPPORT_TRANSITION, } -VALID_COLOR_MODES = { - COLOR_MODE_BRIGHTNESS: ATTR_BRIGHTNESS, - COLOR_MODE_COLOR_TEMP: ATTR_COLOR_TEMP_KELVIN, - COLOR_MODE_HS: ATTR_HS_COLOR, - COLOR_MODE_RGB: ATTR_RGB_COLOR, - COLOR_MODE_RGBW: ATTR_RGBW_COLOR, - COLOR_MODE_RGBWW: ATTR_RGBWW_COLOR, - COLOR_MODE_XY: ATTR_XY_COLOR, -} - _ORDER = (SUN_EVENT_SUNRISE, SUN_EVENT_NOON, SUN_EVENT_SUNSET, SUN_EVENT_MIDNIGHT) _ALLOWED_ORDERS = {_ORDER[i:] + _ORDER[:i] for i in range(len(_ORDER))} @@ -746,51 +730,33 @@ def _expand_light_groups(hass: HomeAssistant, lights: list[str]) -> list[str]: return list(all_lights) -def _supported_to_attributes(supported): - supported_attributes = {} - supports_colors = False - for mode, attr in VALID_COLOR_MODES.items(): - if mode not in supported: - continue - supported_attributes[attr] = True - if ( - not supports_colors - and mode != COLOR_MODE_BRIGHTNESS - and mode != COLOR_MODE_COLOR_TEMP - ): - supports_colors = True - return supported_attributes, supports_colors - - def _supported_features(hass: HomeAssistant, light: str): state = hass.states.get(light) - legacy_supported_features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - legacy_supported = { - key for key, value in _SUPPORT_OPTS.items() if legacy_supported_features & value + supported_features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported = { + key for key, value in _SUPPORT_OPTS.items() if supported_features & value } supported_color_modes = state.attributes.get(ATTR_SUPPORTED_COLOR_MODES, set()) - supported, supports_colors = _supported_to_attributes( - legacy_supported.union(supported_color_modes) - ) - min_kelvin = state.attributes.get(ATTR_MIN_COLOR_TEMP_KELVIN) - max_kelvin = state.attributes.get(ATTR_MAX_COLOR_TEMP_KELVIN) - supported.update( - { - ATTR_MIN_COLOR_TEMP_KELVIN: min_kelvin, - ATTR_MAX_COLOR_TEMP_KELVIN: max_kelvin, - } - ) - if supports_colors: + if COLOR_MODE_RGB in supported_color_modes: + supported.add("color") # Adding brightness here, see # comment https://github.com/basnijholt/adaptive-lighting/issues/112#issuecomment-836944011 - supported[ATTR_BRIGHTNESS] = True - if CONST_COLOR not in legacy_supported: - # supports_colors = False - _LOGGER.debug( - "'supported_color_modes' supports color but the legacy 'supported_features'" - " bitfield says we do not. Despite this we'll assume light '%s' supports colors", - ) - return supported, supports_colors + supported.add("brightness") + if COLOR_MODE_RGBW in supported_color_modes: + supported.add("color") + supported.add("brightness") # see above url + if COLOR_MODE_XY in supported_color_modes: + supported.add("color") + supported.add("brightness") # see above url + if COLOR_MODE_HS in supported_color_modes: + supported.add("color") + supported.add("brightness") # see above url + if COLOR_MODE_COLOR_TEMP in supported_color_modes: + supported.add("color_temp") + supported.add("brightness") # see above url + if COLOR_MODE_BRIGHTNESS in supported_color_modes: + supported.add("brightness") + return supported def color_difference_redmean( @@ -1301,12 +1267,12 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): # Build service data. service_data = {ATTR_ENTITY_ID: light} - features, supports_colors = _supported_features(self.hass, light) + features = _supported_features(self.hass, light) # Check transition == 0 to fix #378 - if ATTR_TRANSITION in features and transition > 0: + if "transition" in features and transition > 0: service_data[ATTR_TRANSITION] = transition - if ATTR_BRIGHTNESS in features and adapt_brightness: + if "brightness" in features and adapt_brightness: brightness = round(255 * self._settings["brightness_pct"] / 100) service_data[ATTR_BRIGHTNESS] = brightness @@ -1315,18 +1281,19 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): and self._sun_light_settings.sleep_rgb_or_color_temp == "rgb_color" ) if ( - ATTR_COLOR_TEMP_KELVIN in features + "color_temp" in features and adapt_color - and not (prefer_rgb_color and supports_colors) - and not (sleep_rgb and supports_colors) + and not (prefer_rgb_color and "color" in features) + and not (sleep_rgb and "color" in features) ): _LOGGER.debug("%s: Setting color_temp of light %s", self._name, light) - min_kelvin = features[ATTR_MIN_COLOR_TEMP_KELVIN] - max_kelvin = features[ATTR_MAX_COLOR_TEMP_KELVIN] + attributes = self.hass.states.get(light).attributes + min_kelvin = attributes["min_color_temp_kelvin"] + max_kelvin = attributes["max_color_temp_kelvin"] color_temp_kelvin = self._settings["color_temp_kelvin"] color_temp_kelvin = max(min(color_temp_kelvin, max_kelvin), min_kelvin) service_data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin - elif supports_colors and adapt_color: + elif "color" in features and adapt_color: _LOGGER.debug("%s: Setting rgb_color of light %s", self._name, light) service_data[ATTR_RGB_COLOR] = self._settings["rgb_color"] # Check if service data differs from the last. See #80. From fdedba9b40c75f621fde53e29b2ca1315920f256 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 29 Apr 2023 22:08:18 +0000 Subject: [PATCH 11/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- custom_components/adaptive_lighting/switch.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 9f7e8de4..b5b9f55c 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -739,6 +739,7 @@ def _expand_light_groups(hass: HomeAssistant, lights: list[str]) -> list[str]: all_lights.add(light) return list(all_lights) + def _supported_to_attributes(supported): supported_attributes = {} supports_colors = False