diff --git a/.github/workflows/install_dependencies/action.yml b/.github/workflows/install_dependencies/action.yml index 3041698b..c87c69b2 100644 --- a/.github/workflows/install_dependencies/action.yml +++ b/.github/workflows/install_dependencies/action.yml @@ -34,7 +34,7 @@ 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 # because they decided to pull codecov the package from PyPI... sed -i '/codecov/d' core/requirements_test.txt 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 6d36ae2e..6f300b52 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 @@ -33,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, @@ -68,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 " @@ -154,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] = ( @@ -176,6 +210,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" @@ -196,11 +239,24 @@ 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" +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 = { @@ -225,12 +281,15 @@ 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), (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)), @@ -261,7 +320,9 @@ 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), (CONF_SEPARATE_TURN_ON_COMMANDS, DEFAULT_SEPARATE_TURN_ON_COMMANDS, bool), (CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY, int_between(0, 10000)), @@ -273,8 +334,6 @@ VALIDATION_TUPLES = [ ), ] -CONST_COLOR = "color" - def timedelta_as_int(value): """Convert a `datetime.timedelta` object to an integer. @@ -323,28 +382,43 @@ _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, + } +) +SCHEMA_SERVICE_TOGGLE = 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( +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/strings.json b/custom_components/adaptive_lighting/strings.json index 54af5e11..53fafd98 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -20,12 +20,15 @@ "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. ⏲️", "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. 💡", @@ -42,7 +45,9 @@ "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`. 🕵️", "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 f933f068..b5b9f55c 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 ( @@ -61,6 +56,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,9 +70,10 @@ from homeassistant.core import ( HomeAssistant, ServiceCall, State, + 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, @@ -94,7 +91,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, @@ -102,10 +98,14 @@ 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, CONF_AUTORESET_CONTROL, CONF_DETECT_NON_HA_CHANGES, + CONF_FLAT_LIMITS, CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, CONF_INITIAL_TRANSITION, CONF_INTERVAL, @@ -134,23 +134,26 @@ from .const import ( CONF_TRANSITION, CONF_TURN_ON_LIGHTS, CONF_USE_DEFAULTS, - CONST_COLOR, + CONF_WATCHED_LIGHTS, + CONF_WHICH_SWITCH, DOMAIN, EXTRA_VALIDATION, ICON_BRIGHTNESS, 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, - SET_MANUAL_CONTROL_SCHEMA, SLEEP_MODE_SWITCH, SUN_EVENT_MIDNIGHT, SUN_EVENT_NOON, TURNING_OFF_DELAY, VALIDATION_TUPLES, - apply_service_schema, replace_none_str, ) @@ -161,7 +164,6 @@ _SUPPORT_OPTS = { ATTR_TRANSITION: SUPPORT_TRANSITION, } - VALID_COLOR_MODES = { COLOR_MODE_BRIGHTNESS: ATTR_BRIGHTNESS, COLOR_MODE_COLOR_TEMP: ATTR_COLOR_TEMP_KELVIN, @@ -303,9 +305,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 = [] @@ -314,10 +316,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 @@ -359,13 +364,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: @@ -391,44 +390,189 @@ 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), ) +@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 def _fire_manual_control_event( switch: AdaptiveSwitch, light: str, context: Context, is_async=True @@ -490,75 +634,36 @@ 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, 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 + hass.services.async_register( + domain=DOMAIN, + service=SERVICE_TURN_ON, + service_func=handle_turn_on, + schema=SCHEMA_SERVICE_TOGGLE, + ) + + # Register `turn_off` service + hass.services.async_register( + domain=DOMAIN, + service=SERVICE_TURN_OFF, + service_func=handle_turn_off, + schema=SCHEMA_SERVICE_TOGGLE, + ) + + # Register `toggle` service + hass.services.async_register( + domain=DOMAIN, + service=SERVICE_TOGGLE, + service_func=handle_toggle, + schema=SCHEMA_SERVICE_TOGGLE, ) # Register `set_manual_control` service @@ -566,20 +671,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, ) @@ -658,37 +758,6 @@ def _supported_to_attributes(supported): 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_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: - # 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 - - def color_difference_redmean( rgb1: tuple[float, float, float], rgb2: tuple[float, float, float] ) -> float: @@ -708,6 +777,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]: @@ -750,6 +828,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( @@ -764,15 +843,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 @@ -782,15 +879,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 @@ -910,13 +1025,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 @@ -933,7 +1055,10 @@ 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], max_color_temp=data[CONF_MAX_COLOR_TEMP], min_brightness=data[CONF_MIN_BRIGHTNESS], @@ -1141,12 +1266,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 @@ -1155,26 +1280,31 @@ 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"] - - 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, @@ -1182,8 +1312,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( @@ -1270,6 +1401,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( @@ -1284,13 +1417,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, @@ -1301,7 +1438,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, @@ -1313,13 +1451,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)): @@ -1472,7 +1612,10 @@ 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 max_color_temp: int min_brightness: int @@ -1507,15 +1650,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 @@ -1612,8 +1767,18 @@ 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: + 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: @@ -1624,7 +1789,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 @@ -1922,18 +2087,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) @@ -1946,13 +2112,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 @@ -1960,7 +2126,8 @@ class TurnOnOffListener: async def significant_change( self, switch: AdaptiveSwitch, - light: str, + wlight: str, + mlight: str, adapt_brightness: bool, adapt_color: bool, context: Context, @@ -1972,35 +2139,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( @@ -2010,7 +2219,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 @@ -2018,7 +2227,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..5e4f6193 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -21,12 +21,15 @@ "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. ⏲️", "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. 💡", @@ -43,7 +46,9 @@ "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`. 🕵️", "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 884b796b..bc11f5d1 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -12,6 +12,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, @@ -23,9 +24,11 @@ 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, + CONF_WHICH_SWITCH, CONST_COLOR, DEFAULT_MAX_BRIGHTNESS, DEFAULT_NAME, @@ -61,7 +64,6 @@ from homeassistant.components.light import ( COLOR_MODE_BRIGHTNESS, ) 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 @@ -73,6 +75,8 @@ from homeassistant.const import ( CONF_NAME, CONF_PLATFORM, EVENT_STATE_CHANGED, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, STATE_ON, @@ -221,7 +225,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 {}), @@ -748,7 +754,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 @@ -993,7 +999,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. @@ -1114,58 +1120,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( @@ -1334,10 +1377,89 @@ 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.""" 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 @@ -1346,7 +1468,6 @@ async def test_change_switch_settings_service(hass): DOMAIN, SERVICE_CHANGE_SWITCH_SETTINGS, { - ATTR_ENTITY_ID: ENTITY_SWITCH, **kwargs, }, blocking=True, @@ -1355,12 +1476,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 @@ -1368,20 +1493,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