From 2d805c7a174ced22923111ebebdb825b296335db Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 26 Sep 2020 16:36:42 +0200 Subject: [PATCH 001/165] Add adaptive_lighting component The development of this code happened in https://github.com/claytonjn/hass-circadian_lighting/pull/117 With prework done in https://github.com/claytonjn/hass-circadian_lighting/pull/114 and https://github.com/claytonjn/hass-circadian_lighting/pull/107. --- CODEOWNERS | 1 + .../components/adaptive_lighting/__init__.py | 104 ++++ .../adaptive_lighting/config_flow.py | 107 ++++ .../components/adaptive_lighting/const.py | 126 +++++ .../adaptive_lighting/manifest.json | 9 + .../adaptive_lighting/services.yaml | 18 + .../components/adaptive_lighting/strings.json | 50 ++ .../components/adaptive_lighting/switch.py | 487 ++++++++++++++++++ .../adaptive_lighting/translations/en.json | 50 ++ homeassistant/generated/config_flows.py | 1 + 10 files changed, 953 insertions(+) create mode 100644 homeassistant/components/adaptive_lighting/__init__.py create mode 100644 homeassistant/components/adaptive_lighting/config_flow.py create mode 100644 homeassistant/components/adaptive_lighting/const.py create mode 100644 homeassistant/components/adaptive_lighting/manifest.json create mode 100644 homeassistant/components/adaptive_lighting/services.yaml create mode 100644 homeassistant/components/adaptive_lighting/strings.json create mode 100644 homeassistant/components/adaptive_lighting/switch.py create mode 100644 homeassistant/components/adaptive_lighting/translations/en.json diff --git a/CODEOWNERS b/CODEOWNERS index e5d67234f6f887..87675302e1d8e9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -22,6 +22,7 @@ homeassistant/scripts/check_config.py @kellerza homeassistant/components/abode/* @shred86 homeassistant/components/accuweather/* @bieniu homeassistant/components/acmeda/* @atmurray +homeassistant/components/adaptive_lighting/* @claytonjn @basnijholt homeassistant/components/adguard/* @frenck homeassistant/components/advantage_air/* @Bre77 homeassistant/components/agent_dvr/* @ispysoftware diff --git a/homeassistant/components/adaptive_lighting/__init__.py b/homeassistant/components/adaptive_lighting/__init__.py new file mode 100644 index 00000000000000..9e6f5c9c070afe --- /dev/null +++ b/homeassistant/components/adaptive_lighting/__init__.py @@ -0,0 +1,104 @@ +"""Adaptive Lighting Component in Home-Assistant. + +This component calculates color temperature and brightness to synchronize +your color-changing lights with the perceived color temperature of the sky +throughout the day. This gives your environment a more natural feel, with +cooler whites during the midday and warmer tints near twilight and dawn. + +Additionally, the component sets your lights to a nice warm white at 1% in +"Sleep mode", which is far brighter than starlight but won't reset your +circadian rhythm or break down too much rhodopsin in your eyes. + +Human circadian rhythms are heavily influenced by ambient light levels and +hues. Hormone production, brainwave activity, mood, and wakefulness are +just some of the cognitive functions tied to cyclical natural light. + +Resources: +- http://en.wikipedia.org/wiki/Zeitgeber +- http://www.cambridgeincolour.com/tutorials/sunrise-sunset-calculator.htm +- http://en.wikipedia.org/wiki/Color_temperature + +## Notes +* Only your location is taken into account to calculate the the sun's position. +* Weather and altitude are not considered. +* The component does not calculate a true "Blue Hour" -- it just sets the + lights to 2700K (warm white) until your hub goes into "Sleep mode". +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +import homeassistant.helpers.config_validation as cv + +from .const import _DOMAIN_SCHEMA, CONF_NAME, DOMAIN, UNDO_UPDATE_LISTENER + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["switch"] + + +def _all_unique_names(value): + """Validate that all enties have a unique profile name.""" + hosts = [device[CONF_NAME] for device in value] + schema = vol.Schema(vol.Unique()) + schema(hosts) + return value + + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [_DOMAIN_SCHEMA], _all_unique_names)}, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Import integration from config.""" + + if DOMAIN in config: + for entry in config[DOMAIN]: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=entry + ) + ) + return True + + +async def async_setup_entry(hass, config_entry: ConfigEntry): + """Set up the component.""" + hass.data.setdefault(DOMAIN, {}) + + undo_listener = config_entry.add_update_listener(async_update_options) + hass.data[DOMAIN][config_entry.entry_id] = {UNDO_UPDATE_LISTENER: undo_listener} + + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, platform) + ) + + return True + + +async def async_update_options(hass, config_entry: ConfigEntry): + """Update options.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + +async def async_unload_entry(hass, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, platform) + for platform in PLATFORMS + ] + ) + ) + hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() + + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/adaptive_lighting/config_flow.py b/homeassistant/components/adaptive_lighting/config_flow.py new file mode 100644 index 00000000000000..a20725319a6bf5 --- /dev/null +++ b/homeassistant/components/adaptive_lighting/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow for Coronavirus integration.""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_DISABLE_ENTITY, + CONF_LIGHTS, + CONF_SLEEP_ENTITY, + DOMAIN, + EXTRA_VALIDATION, + NONE_STR, + VALIDATION_TUPLES, +) + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Adaptive Lighting.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if user_input is not None: + await self.async_set_unique_id(user_input["name"]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=user_input["name"], data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required("name"): str}), + errors=errors, + ) + + async def async_step_import(self, user_input=None): + """Handle configuration by yaml file.""" + await self.async_set_unique_id(user_input["name"]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=user_input["name"], data=user_input) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +def validate_options(user_input, errors): + """Validate the options in the OptionsFlow. + + This is an extra validation step because the validators + in `EXTRA_VALIDATION` cannot be serialized to json. + """ + for key, (validate, _) in EXTRA_VALIDATION.items(): + # these are unserializable validators + try: + value = user_input.get(key) + if value is not None and value != NONE_STR: + validate(value) + except vol.Invalid: + _LOGGER.exception("Configuration option %s=%s is incorrect", key, value) + errors["base"] = "option_error" + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for Adaptive Lighting.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + conf = self.config_entry + if conf.source == config_entries.SOURCE_IMPORT: + return self.async_show_form(step_id="init", data_schema={}) + errors = {} + if user_input is not None: + validate_options(user_input, errors) + if not errors: + return self.async_create_entry(title="", data=user_input) + + all_lights = sorted(self.hass.states.async_entity_ids("light")) + all_entities = sorted(self.hass.states.async_entity_ids()) + to_replace = { + CONF_LIGHTS: cv.multi_select(all_lights), + CONF_DISABLE_ENTITY: vol.In([NONE_STR] + all_entities), + CONF_SLEEP_ENTITY: vol.In([NONE_STR] + all_entities), + } + + options_schema = {} + for name, default, validation in VALIDATION_TUPLES: + key = vol.Optional(name, default=conf.options.get(name, default)) + value = to_replace.get(name, validation) + options_schema[key] = value + + return self.async_show_form( + step_id="init", data_schema=vol.Schema(options_schema), errors=errors + ) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py new file mode 100644 index 00000000000000..ed6c717e9373c8 --- /dev/null +++ b/homeassistant/components/adaptive_lighting/const.py @@ -0,0 +1,126 @@ +"""Constants for the Adaptive Lighting Component in Home-Assistant.""" +import voluptuous as vol + +from homeassistant.components.light import VALID_TRANSITION +import homeassistant.helpers.config_validation as cv + +ICON = "mdi:theme-light-dark" + +DOMAIN = "adaptive_lighting" +SUN_EVENT_NOON = "solar_noon" +SUN_EVENT_MIDNIGHT = "solar_midnight" + +CONF_NAME, DEFAULT_NAME = "name", "default" +CONF_LIGHTS, DEFAULT_LIGHTS = "lights", [] +CONF_DISABLE_BRIGHTNESS_ADJUST, DEFAULT_DISABLE_BRIGHTNESS_ADJUST = ( + "disable_brightness_adjust", + False, +) +CONF_DISABLE_ENTITY = "disable_entity" +CONF_DISABLE_STATE = "disable_state" +CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION = "initial_transition", 1 +CONF_INTERVAL, DEFAULT_INTERVAL = "interval", 90 +CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS = "max_brightness", 100 +CONF_MAX_COLOR_TEMP, DEFAULT_MAX_COLOR_TEMP = "max_color_temp", 5500 +CONF_MIN_BRIGHTNESS, DEFAULT_MIN_BRIGHTNESS = "min_brightness", 1 +CONF_MIN_COLOR_TEMP, DEFAULT_MIN_COLOR_TEMP = "min_color_temp", 2500 +CONF_ONLY_ONCE, DEFAULT_ONLY_ONCE = "only_once", False +CONF_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_BRIGHTNESS = "sleep_brightness", 1 +CONF_SLEEP_COLOR_TEMP, DEFAULT_SLEEP_COLOR_TEMP = "sleep_color_temp", 1000 +CONF_SLEEP_ENTITY = "sleep_entity" +CONF_SLEEP_STATE = "sleep_state" +CONF_SUNRISE_OFFSET, DEFAULT_SUNRISE_OFFSET = "sunrise_offset", 0 +CONF_SUNRISE_TIME = "sunrise_time" +CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET = "sunset_offset", 0 +CONF_SUNSET_TIME = "sunset_time" +CONF_TRANSITION, DEFAULT_TRANSITION = "transition", 60 + +UNDO_UPDATE_LISTENER = "undo_update_listener" +NONE_STR = "None" # TODO: use `from homeassistant.const import ENTITY_MATCH_NONE`? + +SERVICE_APPLY = "apply" +CONF_COLORS_ONLY = "colors_only" +CONF_ON_LIGHTS_ONLY = "on_lights_only" + + +def int_between(a, b): + """Return an integer between 'a' and 'b'.""" + return vol.All(vol.Coerce(int), vol.Range(min=a, max=b)) + + +VALIDATION_TUPLES = [ + (CONF_LIGHTS, DEFAULT_LIGHTS, cv.entity_ids), + (CONF_DISABLE_BRIGHTNESS_ADJUST, DEFAULT_DISABLE_BRIGHTNESS_ADJUST, bool), + (CONF_DISABLE_ENTITY, NONE_STR, cv.entity_id), + (CONF_DISABLE_STATE, NONE_STR, str), + (CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION), + (CONF_INTERVAL, DEFAULT_INTERVAL, cv.positive_int), + (CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS, int_between(1, 100)), + (CONF_MAX_COLOR_TEMP, DEFAULT_MAX_COLOR_TEMP, int_between(1000, 10000)), + (CONF_MIN_BRIGHTNESS, DEFAULT_MIN_BRIGHTNESS, int_between(1, 100)), + (CONF_MIN_COLOR_TEMP, DEFAULT_MIN_COLOR_TEMP, int_between(1000, 10000)), + (CONF_ONLY_ONCE, DEFAULT_ONLY_ONCE, bool), + (CONF_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_BRIGHTNESS, int_between(1, 100)), + (CONF_SLEEP_COLOR_TEMP, DEFAULT_SLEEP_COLOR_TEMP, int_between(1000, 10000)), + (CONF_SLEEP_ENTITY, NONE_STR, cv.entity_id), + (CONF_SLEEP_STATE, NONE_STR, str), + (CONF_SUNRISE_OFFSET, DEFAULT_SUNRISE_OFFSET, int), + (CONF_SUNRISE_TIME, NONE_STR, str), + (CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET, int), + (CONF_SUNSET_TIME, NONE_STR, str), + (CONF_TRANSITION, DEFAULT_TRANSITION, VALID_TRANSITION), +] + + +def timedelta_as_int(value): + """Convert a `datetime.timedelta` object to an integer. + + This integer can be serialized to json but a timedelta cannot. + """ + return value.total_seconds() + + +def join_strings(lst): + """Join a list to comma-separated values string.""" + return ",".join(lst) + + +# conf_option: (validator, coerce) tuples +# these validators cannot be serialized but can be serialized when coerced by coerce. +EXTRA_VALIDATION = { + CONF_DISABLE_ENTITY: (cv.entity_id, str), + CONF_DISABLE_STATE: (vol.All(cv.ensure_list_csv, [cv.string]), join_strings), + CONF_INTERVAL: (cv.time_period, timedelta_as_int), + CONF_SLEEP_ENTITY: (cv.entity_id, str), + CONF_SLEEP_STATE: (vol.All(cv.ensure_list_csv, [cv.string]), join_strings), + CONF_SUNRISE_OFFSET: (cv.time_period, timedelta_as_int), + CONF_SUNRISE_TIME: (cv.time, str), + CONF_SUNSET_OFFSET: (cv.time_period, timedelta_as_int), + CONF_SUNSET_TIME: (cv.time, str), +} + + +def maybe_coerce(key, validation): + """Coerce the validation into a json serializable type.""" + validation, coerce = EXTRA_VALIDATION.get(key, (validation, None)) + if coerce is not None: + return vol.All(validation, vol.Coerce(coerce)) + return validation + + +def replace_none_str(value, replace_with=None): + """Replace "None" -> replace_with.""" + return value if value != NONE_STR else replace_with + + +_yaml_validation_tuples = [ + (key, default, maybe_coerce(key, validation)) + for key, default, validation in VALIDATION_TUPLES +] + [(CONF_NAME, DEFAULT_NAME, cv.string)] + +_DOMAIN_SCHEMA = vol.Schema( + { + vol.Optional(key, default=replace_none_str(default, vol.UNDEFINED)): validation + for key, default, validation in _yaml_validation_tuples + } +) diff --git a/homeassistant/components/adaptive_lighting/manifest.json b/homeassistant/components/adaptive_lighting/manifest.json new file mode 100644 index 00000000000000..ceae225ddd99b0 --- /dev/null +++ b/homeassistant/components/adaptive_lighting/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "adaptive_lighting", + "name": "Adaptive Lighting", + "documentation": "https://github.com/basnijholt/adaptive_lighting", + "config_flow": true, + "dependencies": [], + "codeowners": ["@claytonjn", "@basnijholt"], + "requirements": [] +} diff --git a/homeassistant/components/adaptive_lighting/services.yaml b/homeassistant/components/adaptive_lighting/services.yaml new file mode 100644 index 00000000000000..e5b2aa18cd13a4 --- /dev/null +++ b/homeassistant/components/adaptive_lighting/services.yaml @@ -0,0 +1,18 @@ +apply: + description: Applies the current Adaptive Lighting settings to lights. + fields: + entity_id: + description: entity_id of the Adaptive Lighting switch. + example: switch.adaptive_lighting_default + lights: + description: entity_id(s) of lights. + example: light.bedroom_ceiling + transition: + description: Transition of the lights. + example: 10 + colors_only: + description: Only change the color of the lights and leave the brightness as is. + example: false + on_lights_only: + description: Only adjust the lights that are already on, otherwise turn the lights on. + example: false diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json new file mode 100644 index 00000000000000..1bea6ab512e127 --- /dev/null +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -0,0 +1,50 @@ +{ + "title": "Adaptive Lighting", + "config": { + "step": { + "user": { + "title": "Choose a name for the Adaptive Lighting", + "description": "Every instance can contain multiple lights!", + "data": { + "name": "Name" + } + } + }, + "abort": { + "already_configured": "This name is already configured." + } + }, + "options": { + "step": { + "init": { + "title": "Adaptive Lighting options", + "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", + "disable_brightness_adjust": "disable_brightness_adjust", + "disable_entity": "disable_entity", + "disable_state": "disable_state", + "initial_transition": "initial_transition, the transition of the lights when turning them on or when 'disable_state' or 'sleep_state' change", + "interval": "interval", + "max_brightness": "max_brightness", + "max_color_temp": "max_color_temp", + "min_brightness": "min_brightness", + "min_color_temp": "min_color_temp", + "only_once": "only_once", + "sleep_brightness": "sleep_brightness", + "sleep_color_temp": "sleep_color_temp", + "sleep_entity": "sleep_entity", + "sleep_state": "sleep_state", + "sunrise_offset": "sunrise_offset", + "sunrise_time": "sunrise_time", + "sunset_offset": "sunset_offset", + "sunset_time": "sunset_time", + "transition": "transition" + } + } + }, + "error": { + "option_error": "Invalid option" + } + } +} diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py new file mode 100644 index 00000000000000..48743fd34ac0e1 --- /dev/null +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -0,0 +1,487 @@ +"""Adaptive Lighting Component for Home-Assistant.""" + +import asyncio +import bisect +from copy import deepcopy +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS_PCT, + ATTR_COLOR_TEMP, + ATTR_RGB_COLOR, + ATTR_TRANSITION, + DOMAIN as LIGHT_DOMAIN, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, + SUPPORT_TRANSITION, + VALID_TRANSITION, + is_on, +) +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_NAME, + SERVICE_TURN_ON, + STATE_ON, + SUN_EVENT_SUNRISE, + SUN_EVENT_SUNSET, +) +from homeassistant.helpers import entity_platform +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import ( + async_track_state_change, + async_track_time_interval, +) +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.sun import get_astral_location +from homeassistant.util import slugify +from homeassistant.util.color import ( + color_RGB_to_xy, + color_temperature_kelvin_to_mired, + color_temperature_to_rgb, + color_xy_to_hs, +) +import homeassistant.util.dt as dt_util + +from .const import ( + CONF_COLORS_ONLY, + CONF_DISABLE_BRIGHTNESS_ADJUST, + CONF_DISABLE_ENTITY, + CONF_DISABLE_STATE, + CONF_INITIAL_TRANSITION, + CONF_INTERVAL, + CONF_LIGHTS, + CONF_MAX_BRIGHTNESS, + CONF_MAX_COLOR_TEMP, + CONF_MIN_BRIGHTNESS, + CONF_MIN_COLOR_TEMP, + CONF_ON_LIGHTS_ONLY, + CONF_ONLY_ONCE, + CONF_SLEEP_BRIGHTNESS, + CONF_SLEEP_COLOR_TEMP, + CONF_SLEEP_ENTITY, + CONF_SLEEP_STATE, + CONF_SUNRISE_OFFSET, + CONF_SUNRISE_TIME, + CONF_SUNSET_OFFSET, + CONF_SUNSET_TIME, + CONF_TRANSITION, + DOMAIN, + EXTRA_VALIDATION, + ICON, + SERVICE_APPLY, + SUN_EVENT_MIDNIGHT, + SUN_EVENT_NOON, + VALIDATION_TUPLES, + replace_none_str, +) + +_SUPPORT_OPTS = { + "brightness": SUPPORT_BRIGHTNESS, + "color_temp": SUPPORT_COLOR_TEMP, + "color": SUPPORT_COLOR, + "transition": SUPPORT_TRANSITION, +} + +_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))} + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=10) + + +async def handle_apply(switch, service_call): + """Handle the entity service apply.""" + if not isinstance(switch, AdaptiveSwitch): + raise ValueError("Apply can only be called for a AdaptiveSwitch.") + data = service_call.data + tasks = [ + await switch._adjust_light( + light, data[CONF_TRANSITION], data[CONF_COLORS_ONLY], + ) + for light in data[CONF_LIGHTS] + if not data[CONF_ON_LIGHTS_ONLY] or is_on(switch.hass, light) + ] + if tasks: + await asyncio.wait(tasks) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the AdaptiveLighting switch.""" + switch = AdaptiveSwitch(hass, config_entry) + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + name = config_entry.data[CONF_NAME] + hass.data[DOMAIN][name] = switch + + # Register `apply` service + platform = entity_platform.current_platform.get() + platform.async_register_entity_service( + SERVICE_APPLY, + { + vol.Required(CONF_LIGHTS): cv.entity_ids, + vol.Optional( + CONF_TRANSITION, default=switch._initial_transition + ): VALID_TRANSITION, + vol.Optional(CONF_COLORS_ONLY, default=False): cv.boolean, + vol.Optional(CONF_ON_LIGHTS_ONLY, default=False): cv.boolean, + }, + handle_apply, + ) + async_add_entities([switch], update_before_add=True) + + +def validate(config_entry): + """Get the options and data from the config_entry and add defaults.""" + defaults = {key: default for key, default, _ in VALIDATION_TUPLES} + data = deepcopy(defaults) + data.update(config_entry.options) # come from options flow + data.update(config_entry.data) # all yaml settings come from data + data = {key: replace_none_str(value) for key, value in data.items()} + for key, (validate, _) in EXTRA_VALIDATION.items(): + value = data.get(key) + if value is not None: + data[key] = validate(value) # Fix the types of the inputs + return data + + +class AdaptiveSwitch(SwitchEntity, RestoreEntity): + """Representation of a Adaptive Lighting switch.""" + + def __init__(self, hass, config_entry): + """Initialize the Adaptive Lighting switch.""" + self.hass = hass + + data = validate(config_entry) + self._name = data[CONF_NAME] + self._lights = data[CONF_LIGHTS] + self._disable_brightness_adjust = data[CONF_DISABLE_BRIGHTNESS_ADJUST] + self._disable_entity = data[CONF_DISABLE_ENTITY] + self._disable_state = data[CONF_DISABLE_STATE] + self._initial_transition = data[CONF_INITIAL_TRANSITION] + self._interval = data[CONF_INTERVAL] + self._max_brightness = data[CONF_MAX_BRIGHTNESS] + self._max_color_temp = data[CONF_MAX_COLOR_TEMP] + self._min_brightness = data[CONF_MIN_BRIGHTNESS] + self._min_color_temp = data[CONF_MIN_COLOR_TEMP] + self._only_once = data[CONF_ONLY_ONCE] + self._sleep_brightness = data[CONF_SLEEP_BRIGHTNESS] + self._sleep_color_temp = data[CONF_SLEEP_COLOR_TEMP] + self._sleep_entity = data[CONF_SLEEP_ENTITY] + self._sleep_state = data[CONF_SLEEP_STATE] + self._sunrise_offset = data[CONF_SUNRISE_OFFSET] + self._sunrise_time = data[CONF_SUNRISE_TIME] + self._sunset_offset = data[CONF_SUNSET_OFFSET] + self._sunset_time = data[CONF_SUNSET_TIME] + self._transition = data[CONF_TRANSITION] + + # Set other attributes + self._icon = ICON + self._entity_id = f"switch.{DOMAIN}_{slugify(self._name)}" + + # Initialize attributes that will be set in self._update_attrs + self._percent = None + self._brightness = None + self._color_temp_kelvin = None + self._color_temp_mired = None + self._rgb_color = None + self._xy_color = None + self._hs_color = None + + # Set and unset tracker in async_turn_on and async_turn_off + self.unsub_tracker = None + _LOGGER.debug( + "Setting up with '%s'," + " config_entry.data: '%s'," + " config_entry.options: '%s', converted to '%s'.", + self._lights, + config_entry.data, + config_entry.options, + data, + ) + + @property + def entity_id(self): + """Return the entity ID of the switch.""" + return self._entity_id + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def is_on(self): + """Return true if adaptive lighting is on.""" + return self.unsub_tracker is not None + + def _supported_features(self, light): + state = self.hass.states.get(light) + supported_features = state.attributes["supported_features"] + return { + key for key, value in _SUPPORT_OPTS.items() if supported_features & value + } + + def _unpack_light_groups(self, lights): + all_lights = [] + for light in lights: + state = self.hass.states.get(light) + if state is None: + _LOGGER.debug("State of %s is None", light) + # TODO: make sure that the lights are loaded when doing this + all_lights.append(light) + elif "entity_id" in state.attributes: # it's a light group + group = state.attributes["entity_id"] + self.debug("Unpacked %s to %s", group) + all_lights.extend(group) + else: + all_lights.append(light) + return all_lights + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + if self._lights: + async_track_state_change( + self.hass, + self._unpack_light_groups(self._lights), + self._light_state_changed, + to_state="on", + from_state="off", + ) + track_kwargs = dict(hass=self.hass, action=self._state_changed) + if self._sleep_entity is not None: + sleep_kwargs = dict(track_kwargs, entity_ids=self._sleep_entity) + async_track_state_change(**sleep_kwargs, to_state=self._sleep_state) + async_track_state_change(**sleep_kwargs, from_state=self._sleep_state) + + if self._disable_entity is not None: + disable_kwargs = dict(track_kwargs, entity_ids=self._disable_entity) + async_track_state_change( + **disable_kwargs, from_state=self._disable_state + ) + async_track_state_change(**disable_kwargs, to_state=self._disable_state) + + last_state = await self.async_get_last_state() + if last_state and last_state.state == STATE_ON: + await self.async_turn_on() + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def device_state_attributes(self): + """Return the attributes of the switch.""" + attrs = { + "percent": self._percent, + "brightness": self._brightness, + "color_temp_kelvin": self._color_temp_kelvin, + "color_temp_mired": self._color_temp_mired, + "rgb_color": self._rgb_color, + "xy_color": self._xy_color, + "hs_color": self._hs_color, + } + if not self.is_on: + return {key: None for key in attrs.keys()} + return attrs + + async def async_turn_on(self, **kwargs): + """Turn on adaptive lighting.""" + await self._update_lights(transition=self._initial_transition, force=True) + self.unsub_tracker = async_track_time_interval( + self.hass, self._async_update_at_interval, self._interval + ) + + async def async_turn_off(self, **kwargs): + """Turn off adaptive lighting.""" + if self.is_on: + self.unsub_tracker() + self.unsub_tracker = None + + async def _update_attrs(self): + """Update Adaptive Values.""" + # Setting all values because this method takes <0.5ms to execute. + self._percent = self._calc_percent() + self._brightness = self._calc_brightness() + self._color_temp_kelvin = self._calc_color_temp_kelvin() + self._color_temp_mired = color_temperature_kelvin_to_mired( + self._color_temp_kelvin + ) + self._rgb_color = color_temperature_to_rgb(self._color_temp_kelvin) + self._xy_color = color_RGB_to_xy(*self._rgb_color) + self._hs_color = color_xy_to_hs(*self._xy_color) + self.async_write_ha_state() + _LOGGER.debug("'_update_attrs' called for %s", self._name) + + async def _async_update_at_interval(self, now=None): + await self._update_lights(force=False) + + async def _update_lights(self, lights=None, transition=None, force=False): + await self._update_attrs() + if self._only_once and not force: + return + await self._adjust_lights(lights or self._lights, transition) + + def _get_sun_events(self, date): + def _replace_time(date, key): + other_date = getattr(self, f"_{key}_time") + return date.replace( + hour=other_date.hour, + minute=other_date.minute, + second=other_date.second, + microsecond=other_date.microsecond, + ) + + location = get_astral_location(self.hass) + sunrise = ( + location.sunrise(date, local=False) + if self._sunrise_time is None + else _replace_time(date, "sunrise") + ) + self._sunrise_offset + sunset = ( + location.sunset(date, local=False) + if self._sunset_time is None + else _replace_time(date, "sunset") + ) + self._sunset_offset + + if self._sunrise_time is None and self._sunset_time is None: + solar_noon = location.solar_noon(date, local=False) + solar_midnight = location.solar_midnight(date, local=False) + else: + solar_noon = sunrise + (sunset - sunrise) / 2 + solar_midnight = sunset + ((sunrise + timedelta(days=1)) - sunset) / 2 + + events = [ + (SUN_EVENT_SUNRISE, sunrise.timestamp()), + (SUN_EVENT_SUNSET, sunset.timestamp()), + (SUN_EVENT_NOON, solar_noon.timestamp()), + (SUN_EVENT_MIDNIGHT, solar_midnight.timestamp()), + ] + # Check whether order is correct + events = sorted(events, key=lambda x: x[1]) + events_names, _ = zip(*events) + assert events_names in _ALLOWED_ORDERS, events_names + + return events + + def _relevant_events(self, now): + events = [ + self._get_sun_events(now + timedelta(days=days)) for days in [-1, 0, 1] + ] + events = sum(events, []) # flatten lists + events = sorted(events, key=lambda x: x[1]) + i_now = bisect.bisect([ts for _, ts in events], now.timestamp()) + return events[i_now - 1 : i_now + 1] + + def _calc_percent(self): + now = dt_util.utcnow() + now_ts = now.timestamp() + today = self._relevant_events(now) + (prev_event, prev_ts), (next_event, next_ts) = today + h, x = ( + (prev_ts, next_ts) + if next_event in (SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE) + else (next_ts, prev_ts) + ) + k = 1 if next_event in (SUN_EVENT_SUNSET, SUN_EVENT_NOON) else -1 + percentage = (0 - k) * ((now_ts - h) / (h - x)) ** 2 + k + return percentage + + def _is_sleep(self): + return ( + self._sleep_entity is not None + and self.hass.states.get(self._sleep_entity).state in self._sleep_state + ) + + def _calc_color_temp_kelvin(self): + if self._is_sleep(): + return self._sleep_color_temp + if self._percent > 0: + delta = self._max_color_temp - self._min_color_temp + return (delta * self._percent) + self._min_color_temp + return self._min_color_temp + + def _calc_brightness(self) -> float: + if self._disable_brightness_adjust: + return + if self._is_sleep(): + return self._sleep_brightness + if self._percent > 0: + return self._max_brightness + delta_brightness = self._max_brightness - self._min_brightness + percent = 1 + self._percent + return (delta_brightness * percent) + self._min_brightness + + def _is_disabled(self): + return ( + self._disable_entity is not None + and self.hass.states.get(self._disable_entity).state in self._disable_state + ) + + async def _adjust_light(self, light, transition, colors_only=False): + service_data = {ATTR_ENTITY_ID: light} + features = self._supported_features(light) + + if "transition" in features: + if transition is None: + transition = self._transition + service_data[ATTR_TRANSITION] = transition + + if ( + self._brightness is not None + and "brightness" in features + and not colors_only + ): + service_data[ATTR_BRIGHTNESS_PCT] = self._brightness + + if "color" in features: + service_data[ATTR_RGB_COLOR] = self._rgb_color + elif "color_temp" in features: + service_data[ATTR_COLOR_TEMP] = self._color_temp_mired + + _LOGGER.debug( + "Scheduling 'light.turn_on' with the following 'service_data': %s", + service_data, + ) + return self.hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_ON, service_data + ) + + def _should_adjust(self): + if not self._lights or not self.is_on or self._is_disabled(): + return False + return True + + async def _adjust_lights(self, lights, transition): + if not self._should_adjust(): + return + tasks = [ + await self._adjust_light(light, transition) + for light in lights + if is_on(self.hass, light) + ] + if tasks: + await asyncio.wait(tasks) + + async def _light_state_changed(self, entity_id, from_state, to_state): + assert to_state.state == "on" and from_state.state == "off" + _LOGGER.debug( + "_light_state_changed, from_state: '%s', to_state: '%s'", + from_state, + to_state, + ) + await self._update_lights( + lights=[entity_id], transition=self._initial_transition, force=True + ) + + async def _state_changed(self, entity_id, from_state, to_state): + _LOGGER.debug( + "_state_changed, from_state: '%s', to_state: '%s'", from_state, to_state + ) + await self._update_lights(transition=self._initial_transition, force=True) diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json new file mode 100644 index 00000000000000..1bea6ab512e127 --- /dev/null +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -0,0 +1,50 @@ +{ + "title": "Adaptive Lighting", + "config": { + "step": { + "user": { + "title": "Choose a name for the Adaptive Lighting", + "description": "Every instance can contain multiple lights!", + "data": { + "name": "Name" + } + } + }, + "abort": { + "already_configured": "This name is already configured." + } + }, + "options": { + "step": { + "init": { + "title": "Adaptive Lighting options", + "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", + "disable_brightness_adjust": "disable_brightness_adjust", + "disable_entity": "disable_entity", + "disable_state": "disable_state", + "initial_transition": "initial_transition, the transition of the lights when turning them on or when 'disable_state' or 'sleep_state' change", + "interval": "interval", + "max_brightness": "max_brightness", + "max_color_temp": "max_color_temp", + "min_brightness": "min_brightness", + "min_color_temp": "min_color_temp", + "only_once": "only_once", + "sleep_brightness": "sleep_brightness", + "sleep_color_temp": "sleep_color_temp", + "sleep_entity": "sleep_entity", + "sleep_state": "sleep_state", + "sunrise_offset": "sunrise_offset", + "sunrise_time": "sunrise_time", + "sunset_offset": "sunset_offset", + "sunset_time": "sunset_time", + "transition": "transition" + } + } + }, + "error": { + "option_error": "Invalid option" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 833f11190b6cba..4a3fcb3571c3ec 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -9,6 +9,7 @@ "abode", "accuweather", "acmeda", + "adaptive_lighting", "adguard", "advantage_air", "agent_dvr", From 1fe3c815ddece30a4a1b8414cd170d24815d1d63 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 26 Sep 2020 23:42:48 +0200 Subject: [PATCH 002/165] add a basic implemention to prevent lights from turning on when actually turning off the lights --- .../components/adaptive_lighting/const.py | 2 + .../components/adaptive_lighting/switch.py | 72 ++++++++++++++----- 2 files changed, 56 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index ed6c717e9373c8..5f670b58e7081e 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -42,6 +42,8 @@ CONF_COLORS_ONLY = "colors_only" CONF_ON_LIGHTS_ONLY = "on_lights_only" +TURNING_OFF_DELAY = 5 + def int_between(a, b): """Return an integer between 'a' and 'b'.""" diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 48743fd34ac0e1..0fc5b983496de3 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -34,6 +34,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import ( async_track_state_change, + async_track_state_change_event, async_track_time_interval, ) from homeassistant.helpers.restore_state import RestoreEntity @@ -76,6 +77,7 @@ SERVICE_APPLY, SUN_EVENT_MIDNIGHT, SUN_EVENT_NOON, + TURNING_OFF_DELAY, VALIDATION_TUPLES, replace_none_str, ) @@ -102,7 +104,9 @@ async def handle_apply(switch, service_call): data = service_call.data tasks = [ await switch._adjust_light( - light, data[CONF_TRANSITION], data[CONF_COLORS_ONLY], + light, + data[CONF_TRANSITION], + data[CONF_COLORS_ONLY], ) for light in data[CONF_LIGHTS] if not data[CONF_ON_LIGHTS_ONLY] or is_on(switch.hass, light) @@ -183,6 +187,7 @@ def __init__(self, hass, config_entry): # Set other attributes self._icon = ICON self._entity_id = f"switch.{DOMAIN}_{slugify(self._name)}" + self._turned_off = {} # Initialize attributes that will be set in self._update_attrs self._percent = None @@ -246,12 +251,9 @@ def _unpack_light_groups(self, lights): async def async_added_to_hass(self): """Call when entity about to be added to hass.""" if self._lights: - async_track_state_change( - self.hass, - self._unpack_light_groups(self._lights), - self._light_state_changed, - to_state="on", - from_state="off", + unpacked_lights = self._unpack_light_groups(self._lights) + async_track_state_change_event( + self.hass, unpacked_lights, self._light_event ) track_kwargs = dict(hass=self.hass, action=self._state_changed) if self._sleep_entity is not None: @@ -469,19 +471,53 @@ async def _adjust_lights(self, lights, transition): if tasks: await asyncio.wait(tasks) - async def _light_state_changed(self, entity_id, from_state, to_state): - assert to_state.state == "on" and from_state.state == "off" - _LOGGER.debug( - "_light_state_changed, from_state: '%s', to_state: '%s'", - from_state, - to_state, - ) - await self._update_lights( - lights=[entity_id], transition=self._initial_transition, force=True - ) - async def _state_changed(self, entity_id, from_state, to_state): _LOGGER.debug( "_state_changed, from_state: '%s', to_state: '%s'", from_state, to_state ) await self._update_lights(transition=self._initial_transition, force=True) + + async def _light_event(self, event): + old_state = event.data.get("old_state") + new_state = event.data.get("new_state") + + _LOGGER.debug( + "lights event, old_state: '%s', new_state: '%s'", + old_state, + new_state, + ) + entity_id = event.data.get("entity_id") + now = dt_util.now().timestamp() + if ( + old_state is not None + and old_state.state == "off" + and new_state is not None + and new_state.state == "on" + ): + last_turned_off = self._turned_off.get(entity_id, 0) + dt = now - last_turned_off + # TODO: make TURNING_OFF_DELAY depend on the 'transition' time + # passed to 'turn_off' IF transition was passed. + if dt < TURNING_OFF_DELAY: + # Possibly the lights just got a turn_off call, however, the light + # is actually still turning off and HA polls the light before the + # light is 100% off. This might trigger a rapid switch + # 'off' -> 'on' -> 'off'. To prevent this component from interfering + # on the 'on' state, we make sure to wait at least TURNING_OFF_DELAY + # between a 'off' -> 'on' event and then check whether the light is + # still 'on'. Only if it is still 'on' we adjust the lights. + await asyncio.sleep(TURNING_OFF_DELAY - dt) + if not is_on(self.hass, entity_id): + return + await self._update_lights( + lights=[entity_id], + transition=self._initial_transition, + force=True, + ) + if ( + old_state is not None + and old_state.state == "on" + and new_state is not None + and new_state.state == "off" + ): + self._turned_off[entity_id] = now From ae9ea3181cad335b14c298310ad58f32cd985d66 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 27 Sep 2020 01:08:58 +0200 Subject: [PATCH 003/165] add 'disable_color_adjust' option after request on Reddit --- homeassistant/components/adaptive_lighting/const.py | 2 ++ homeassistant/components/adaptive_lighting/strings.json | 1 + homeassistant/components/adaptive_lighting/switch.py | 4 +++- .../components/adaptive_lighting/translations/en.json | 1 + 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index 5f670b58e7081e..faef738b2e86df 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -16,6 +16,7 @@ "disable_brightness_adjust", False, ) +CONF_DISABLE_COLOR_ADJUST, DEFAULT_DISABLE_COLOR_ADJUST = "disable_color_adjust", False CONF_DISABLE_ENTITY = "disable_entity" CONF_DISABLE_STATE = "disable_state" CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION = "initial_transition", 1 @@ -53,6 +54,7 @@ def int_between(a, b): VALIDATION_TUPLES = [ (CONF_LIGHTS, DEFAULT_LIGHTS, cv.entity_ids), (CONF_DISABLE_BRIGHTNESS_ADJUST, DEFAULT_DISABLE_BRIGHTNESS_ADJUST, bool), + (CONF_DISABLE_COLOR_ADJUST, DEFAULT_DISABLE_COLOR_ADJUST, bool), (CONF_DISABLE_ENTITY, NONE_STR, cv.entity_id), (CONF_DISABLE_STATE, NONE_STR, str), (CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION), diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index 1bea6ab512e127..b871aac998e1c3 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -22,6 +22,7 @@ "data": { "lights": "lights", "disable_brightness_adjust": "disable_brightness_adjust", + "disable_color_adjust": "disable_color_adjust", "disable_entity": "disable_entity", "disable_state": "disable_state", "initial_transition": "initial_transition, the transition of the lights when turning them on or when 'disable_state' or 'sleep_state' change", diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 0fc5b983496de3..6a9a05ddb55051 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -51,6 +51,7 @@ from .const import ( CONF_COLORS_ONLY, CONF_DISABLE_BRIGHTNESS_ADJUST, + CONF_DISABLE_COLOR_ADJUST, CONF_DISABLE_ENTITY, CONF_DISABLE_STATE, CONF_INITIAL_TRANSITION, @@ -165,6 +166,7 @@ def __init__(self, hass, config_entry): self._name = data[CONF_NAME] self._lights = data[CONF_LIGHTS] self._disable_brightness_adjust = data[CONF_DISABLE_BRIGHTNESS_ADJUST] + self._disable_color_adjust = data[CONF_DISABLE_COLOR_ADJUST] self._disable_entity = data[CONF_DISABLE_ENTITY] self._disable_state = data[CONF_DISABLE_STATE] self._initial_transition = data[CONF_INITIAL_TRANSITION] @@ -442,7 +444,7 @@ async def _adjust_light(self, light, transition, colors_only=False): ): service_data[ATTR_BRIGHTNESS_PCT] = self._brightness - if "color" in features: + if "color" in features and not self._disable_color_adjust: service_data[ATTR_RGB_COLOR] = self._rgb_color elif "color_temp" in features: service_data[ATTR_COLOR_TEMP] = self._color_temp_mired diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index 1bea6ab512e127..b871aac998e1c3 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -22,6 +22,7 @@ "data": { "lights": "lights", "disable_brightness_adjust": "disable_brightness_adjust", + "disable_color_adjust": "disable_color_adjust", "disable_entity": "disable_entity", "disable_state": "disable_state", "initial_transition": "initial_transition, the transition of the lights when turning them on or when 'disable_state' or 'sleep_state' change", From 37c33fd64e514f5642246641ba54be871834c0bc Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 27 Sep 2020 13:02:47 +0200 Subject: [PATCH 004/165] cancel adjusting lights for at least the turn_off transition time --- .../components/adaptive_lighting/switch.py | 112 ++++++++++++++---- 1 file changed, 87 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 6a9a05ddb55051..98cd5979698623 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -5,9 +5,14 @@ from copy import deepcopy from datetime import timedelta import logging +from typing import Dict, Tuple import voluptuous as vol +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP, @@ -23,8 +28,13 @@ ) from homeassistant.components.switch import SwitchEntity from homeassistant.const import ( + ATTR_DOMAIN, ATTR_ENTITY_ID, + ATTR_SERVICE, + ATTR_SERVICE_DATA, CONF_NAME, + EVENT_CALL_SERVICE, + SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, SUN_EVENT_SUNRISE, @@ -189,7 +199,11 @@ def __init__(self, hass, config_entry): # Set other attributes self._icon = ICON self._entity_id = f"switch.{DOMAIN}_{slugify(self._name)}" - self._turned_off = {} + + # Tracks 'off' → 'on' state changes + self._on_to_off_event: Dict[str, Tuple[float, str]] = {} + # Tracks 'light.turn_off(..., transition=...)' service calls + self._turn_off_service_event: Dict[str, Tuple[str, float]] = {} # Initialize attributes that will be set in self._update_attrs self._percent = None @@ -257,6 +271,10 @@ async def async_added_to_hass(self): async_track_state_change_event( self.hass, unpacked_lights, self._light_event ) + # Tracks 'light.turn_off(..., transition=...)' service calls + self.hass.bus.async_listen( + EVENT_CALL_SERVICE, self._turn_off_event_listener + ) track_kwargs = dict(hass=self.hass, action=self._state_changed) if self._sleep_entity is not None: sleep_kwargs = dict(track_kwargs, entity_ids=self._sleep_entity) @@ -482,44 +500,88 @@ async def _state_changed(self, entity_id, from_state, to_state): async def _light_event(self, event): old_state = event.data.get("old_state") new_state = event.data.get("new_state") - - _LOGGER.debug( - "lights event, old_state: '%s', new_state: '%s'", - old_state, - new_state, - ) entity_id = event.data.get("entity_id") - now = dt_util.now().timestamp() + now_ts = dt_util.now().timestamp() if ( old_state is not None and old_state.state == "off" and new_state is not None and new_state.state == "on" ): - last_turned_off = self._turned_off.get(entity_id, 0) - dt = now - last_turned_off - # TODO: make TURNING_OFF_DELAY depend on the 'transition' time - # passed to 'turn_off' IF transition was passed. - if dt < TURNING_OFF_DELAY: - # Possibly the lights just got a turn_off call, however, the light - # is actually still turning off and HA polls the light before the - # light is 100% off. This might trigger a rapid switch - # 'off' -> 'on' -> 'off'. To prevent this component from interfering - # on the 'on' state, we make sure to wait at least TURNING_OFF_DELAY - # between a 'off' -> 'on' event and then check whether the light is - # still 'on'. Only if it is still 'on' we adjust the lights. - await asyncio.sleep(TURNING_OFF_DELAY - dt) - if not is_on(self.hass, entity_id): - return + if await self._maybe_cancel(entity_id, now_ts): + # Stop if a rapid 'off' → 'on' → 'off' happens. + _LOGGER.debug("Cancelling adjusting lights for %s", entity_id) + return await self._update_lights( lights=[entity_id], transition=self._initial_transition, force=True, ) - if ( + elif ( old_state is not None and old_state.state == "on" and new_state is not None and new_state.state == "off" ): - self._turned_off[entity_id] = now + # Tracks 'off' → 'on' state changes + self._on_to_off_event[entity_id] = (now_ts, event.context.id) + + async def _maybe_cancel(self, entity_id, now_ts) -> bool: + """Cancel the adjusting of a light if it has just been turned off. + + Possibly the lights just got a 'turn_off' call, however, the light + is actually still turning off (e.g., because of a 'transition') and + HA polls the light before the light is 100% off. This might trigger + a rapid switch 'off' → 'on' → 'off'. To prevent this component + from interfering on the 'on' state, we make sure to wait at least + TURNING_OFF_DELAY between a 'off' → 'on' event and then check + whether the light is still 'on'. Only if it is still 'on' we adjust + the lights. + """ + ts_on_to_off, id_on_to_off = self._on_to_off_event.get(entity_id, (0, None)) + id_turn_off, transition = self._turn_off_service_event.get( + entity_id, (None, None) + ) + if ( + id_on_to_off is not None + and id_turn_off is not None + and id_on_to_off == id_turn_off + ): + # State change 'off' → 'on' and 'light.turn_off(..., transition=...)' are + # from the same event, so wait at least the 'turn_off' transition time. + delay = transition + TURNING_OFF_DELAY + elif ts_on_to_off == 0: + # No state change has been registered before. + return False + else: + # State change 'off' → 'on' happened but **not** because a + # 'light.turn_off' event that is called with 'transition'. + delay = TURNING_OFF_DELAY + + dt = now_ts - ts_on_to_off + if dt < delay: + _LOGGER.debug("Waiting with adjusting '%s' for %s.", entity_id, delay - dt) + await asyncio.sleep(delay - dt) + await self.hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: entity_id}, + ) + if not is_on(self.hass, entity_id): + return True + return False + + async def _turn_off_event_listener(self, event): + """Track 'light.turn_off(..., transition=...)' service calls.""" + if event.data.get(ATTR_DOMAIN) != LIGHT_DOMAIN: + return + if event.data.get(ATTR_SERVICE) != SERVICE_TURN_OFF: + return + service_data = event.data.get(ATTR_SERVICE_DATA, {}) + transition = service_data.get(ATTR_TRANSITION) + if transition is not None and transition > 0: + entity_id = service_data[ATTR_ENTITY_ID] + if isinstance(entity_id, str): + entity_id = [entity_id] + for eid in entity_id: + self._turn_off_service_event[eid] = (event.context.id, transition) From f54db5ae5d8ada937c1bf35e792cf25add3abe9a Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 27 Sep 2020 13:55:12 +0200 Subject: [PATCH 005/165] fix pylint issues --- .../adaptive_lighting/config_flow.py | 2 +- .../components/adaptive_lighting/const.py | 6 ++--- .../components/adaptive_lighting/switch.py | 27 ++++++++++--------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/config_flow.py b/homeassistant/components/adaptive_lighting/config_flow.py index a20725319a6bf5..d022e040dfa967 100644 --- a/homeassistant/components/adaptive_lighting/config_flow.py +++ b/homeassistant/components/adaptive_lighting/config_flow.py @@ -7,7 +7,7 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from .const import ( +from .const import ( # pylint: disable=unused-import CONF_DISABLE_ENTITY, CONF_LIGHTS, CONF_SLEEP_ENTITY, diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index faef738b2e86df..c939b281a3d527 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -46,9 +46,9 @@ TURNING_OFF_DELAY = 5 -def int_between(a, b): - """Return an integer between 'a' and 'b'.""" - return vol.All(vol.Coerce(int), vol.Range(min=a, max=b)) +def int_between(min_int, max_int): + """Return an integer between 'min_int' and 'max_int'.""" + return vol.All(vol.Coerce(int), vol.Range(min=min_int, max=max_int)) VALIDATION_TUPLES = [ diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 98cd5979698623..5592529b943b00 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -114,7 +114,7 @@ async def handle_apply(switch, service_call): raise ValueError("Apply can only be called for a AdaptiveSwitch.") data = service_call.data tasks = [ - await switch._adjust_light( + await switch._adjust_light( # pylint: disable=protected-access light, data[CONF_TRANSITION], data[CONF_COLORS_ONLY], @@ -141,7 +141,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): { vol.Required(CONF_LIGHTS): cv.entity_ids, vol.Optional( - CONF_TRANSITION, default=switch._initial_transition + CONF_TRANSITION, + default=switch._initial_transition, # pylint: disable=protected-access ): VALID_TRANSITION, vol.Optional(CONF_COLORS_ONLY, default=False): cv.boolean, vol.Optional(CONF_ON_LIGHTS_ONLY, default=False): cv.boolean, @@ -158,10 +159,10 @@ def validate(config_entry): data.update(config_entry.options) # come from options flow data.update(config_entry.data) # all yaml settings come from data data = {key: replace_none_str(value) for key, value in data.items()} - for key, (validate, _) in EXTRA_VALIDATION.items(): + for key, (validate_value, _) in EXTRA_VALIDATION.items(): value = data.get(key) if value is not None: - data[key] = validate(value) # Fix the types of the inputs + data[key] = validate_value(value) # Fix the types of the inputs return data @@ -258,7 +259,7 @@ def _unpack_light_groups(self, lights): all_lights.append(light) elif "entity_id" in state.attributes: # it's a light group group = state.attributes["entity_id"] - self.debug("Unpacked %s to %s", group) + _LOGGER.debug("Unpacked %s to %s", lights, group) all_lights.extend(group) else: all_lights.append(light) @@ -310,7 +311,7 @@ def device_state_attributes(self): "hs_color": self._hs_color, } if not self.is_on: - return {key: None for key in attrs.keys()} + return {key: None for key in attrs} return attrs async def async_turn_on(self, **kwargs): @@ -405,8 +406,8 @@ def _calc_percent(self): now = dt_util.utcnow() now_ts = now.timestamp() today = self._relevant_events(now) - (prev_event, prev_ts), (next_event, next_ts) = today - h, x = ( + (_, prev_ts), (next_event, next_ts) = today + h, x = ( # pylint: disable=invalid-name (prev_ts, next_ts) if next_event in (SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE) else (next_ts, prev_ts) @@ -558,10 +559,12 @@ async def _maybe_cancel(self, entity_id, now_ts) -> bool: # 'light.turn_off' event that is called with 'transition'. delay = TURNING_OFF_DELAY - dt = now_ts - ts_on_to_off - if dt < delay: - _LOGGER.debug("Waiting with adjusting '%s' for %s.", entity_id, delay - dt) - await asyncio.sleep(delay - dt) + delta_time = now_ts - ts_on_to_off + if delta_time < delay: + _LOGGER.debug( + "Waiting with adjusting '%s' for %s.", entity_id, delay - delta_time + ) + await asyncio.sleep(delay - delta_time) await self.hass.services.async_call( HA_DOMAIN, SERVICE_UPDATE_ENTITY, From 6805c0484aadb316216d65ccde99c9fc29fcc4e9 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 27 Sep 2020 14:17:38 +0200 Subject: [PATCH 006/165] improve logging --- .../components/adaptive_lighting/switch.py | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 5592529b943b00..de6bea3d209fc6 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -218,9 +218,10 @@ def __init__(self, hass, config_entry): # Set and unset tracker in async_turn_on and async_turn_off self.unsub_tracker = None _LOGGER.debug( - "Setting up with '%s'," + "%s: Setting up with '%s'," " config_entry.data: '%s'," " config_entry.options: '%s', converted to '%s'.", + self._name, self._lights, config_entry.data, config_entry.options, @@ -254,12 +255,12 @@ def _unpack_light_groups(self, lights): for light in lights: state = self.hass.states.get(light) if state is None: - _LOGGER.debug("State of %s is None", light) + _LOGGER.debug("%s: State of %s is None", self._name, light) # TODO: make sure that the lights are loaded when doing this all_lights.append(light) elif "entity_id" in state.attributes: # it's a light group group = state.attributes["entity_id"] - _LOGGER.debug("Unpacked %s to %s", lights, group) + _LOGGER.debug("%s: Unpacked %s to %s", self._name, lights, group) all_lights.extend(group) else: all_lights.append(light) @@ -340,7 +341,7 @@ async def _update_attrs(self): self._xy_color = color_RGB_to_xy(*self._rgb_color) self._hs_color = color_xy_to_hs(*self._xy_color) self.async_write_ha_state() - _LOGGER.debug("'_update_attrs' called for %s", self._name) + _LOGGER.debug("%s: '_update_attrs' called", self._name) async def _async_update_at_interval(self, now=None): await self._update_lights(force=False) @@ -469,7 +470,8 @@ async def _adjust_light(self, light, transition, colors_only=False): service_data[ATTR_COLOR_TEMP] = self._color_temp_mired _LOGGER.debug( - "Scheduling 'light.turn_on' with the following 'service_data': %s", + "%s: Scheduling 'light.turn_on' with the following 'service_data': %s", + self._name, service_data, ) return self.hass.services.async_call( @@ -494,7 +496,10 @@ async def _adjust_lights(self, lights, transition): async def _state_changed(self, entity_id, from_state, to_state): _LOGGER.debug( - "_state_changed, from_state: '%s', to_state: '%s'", from_state, to_state + "%s: _state_changed, from_state: '%s', to_state: '%s'", + self._name, + from_state, + to_state, ) await self._update_lights(transition=self._initial_transition, force=True) @@ -509,9 +514,14 @@ async def _light_event(self, event): and new_state is not None and new_state.state == "on" ): + _LOGGER.debug( + "%s: Detected an 'off' → 'on' event for '%s'", self._name, entity_id + ) if await self._maybe_cancel(entity_id, now_ts): # Stop if a rapid 'off' → 'on' → 'off' happens. - _LOGGER.debug("Cancelling adjusting lights for %s", entity_id) + _LOGGER.debug( + "%s: Cancelling adjusting lights for %s", self._name, entity_id + ) return await self._update_lights( lights=[entity_id], @@ -562,9 +572,9 @@ async def _maybe_cancel(self, entity_id, now_ts) -> bool: delta_time = now_ts - ts_on_to_off if delta_time < delay: _LOGGER.debug( - "Waiting with adjusting '%s' for %s.", entity_id, delay - delta_time + "%s: Waiting with adjusting '%s' for %s.", self._name, entity_id, delay ) - await asyncio.sleep(delay - delta_time) + await asyncio.sleep(delay) await self.hass.services.async_call( HA_DOMAIN, SERVICE_UPDATE_ENTITY, From 9b5a6d1369c0595597a79dfb20cf39e7a40d4039 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 27 Sep 2020 15:21:55 +0200 Subject: [PATCH 007/165] light might take longer than specified turn_off 'transition', so wait a little longer --- .../components/adaptive_lighting/switch.py | 49 ++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index de6bea3d209fc6..4dc74ea74b58be 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -14,6 +14,7 @@ SERVICE_UPDATE_ENTITY, ) from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, @@ -560,7 +561,7 @@ async def _maybe_cancel(self, entity_id, now_ts) -> bool: ): # State change 'off' → 'on' and 'light.turn_off(..., transition=...)' are # from the same event, so wait at least the 'turn_off' transition time. - delay = transition + TURNING_OFF_DELAY + delay = transition elif ts_on_to_off == 0: # No state change has been registered before. return False @@ -571,15 +572,45 @@ async def _maybe_cancel(self, entity_id, now_ts) -> bool: delta_time = now_ts - ts_on_to_off if delta_time < delay: + delay -= delta_time # already been delta_time since the event + brightness_going_down = True # this might not be the case _LOGGER.debug( "%s: Waiting with adjusting '%s' for %s.", self._name, entity_id, delay ) - await asyncio.sleep(delay) - await self.hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: entity_id}, + current_state = self.hass.states.get(entity_id) + _LOGGER.debug( + "%s: '%s' state before sleep is '%s'", + self._name, + entity_id, + current_state, ) + for _ in range(3): + # It can happen that the actual transition time is longer than + # the specified time in the 'turn_off' service, so we check + # whether the brightness is still going down, if so, we wait a + # little longer. + await asyncio.sleep(delay) + await self.hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + old_state = current_state + current_state = self.hass.states.get(entity_id) + old_brightness = old_state.attributes.get(ATTR_BRIGHTNESS, 0) + current_brightness = current_state.attributes.get(ATTR_BRIGHTNESS, 0) + brightness_going_down = old_brightness > current_brightness + _LOGGER.debug( + "%s: '%s' state after sleep is '%s'", + self._name, + entity_id, + current_state, + ) + if not brightness_going_down: + break + delay = TURNING_OFF_DELAY + if not is_on(self.hass, entity_id): return True return False @@ -594,6 +625,12 @@ async def _turn_off_event_listener(self, event): transition = service_data.get(ATTR_TRANSITION) if transition is not None and transition > 0: entity_id = service_data[ATTR_ENTITY_ID] + _LOGGER.debug( + "%s: Detected an 'light.turn_off('%s', transition=%s)' event", + self._name, + entity_id, + transition, + ) if isinstance(entity_id, str): entity_id = [entity_id] for eid in entity_id: From d8ef2649d32156eb4539f9e805cfb1506a9727fb Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 27 Sep 2020 16:01:53 +0200 Subject: [PATCH 008/165] always ignore any event during a turn_off transition --- .../components/adaptive_lighting/switch.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 4dc74ea74b58be..879881aa009acc 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -546,9 +546,10 @@ async def _maybe_cancel(self, entity_id, now_ts) -> bool: HA polls the light before the light is 100% off. This might trigger a rapid switch 'off' → 'on' → 'off'. To prevent this component from interfering on the 'on' state, we make sure to wait at least - TURNING_OFF_DELAY between a 'off' → 'on' event and then check - whether the light is still 'on'. Only if it is still 'on' we adjust - the lights. + TURNING_OFF_DELAY (or the 'turn_off' transition time) between a + 'off' → 'on' event and then check whether the light is still 'on' or + if the brightness is still decreasing. Only if it is the case we + adjust the lights. """ ts_on_to_off, id_on_to_off = self._on_to_off_event.get(entity_id, (0, None)) id_turn_off, transition = self._turn_off_service_event.get( @@ -611,6 +612,14 @@ async def _maybe_cancel(self, entity_id, now_ts) -> bool: break delay = TURNING_OFF_DELAY + if transition is not None: + # Always ignore when there's a transition + # TODO: I am doing this because it seems like HA cannot detect + # whether a light is transitioning into 'off'. Because in my + # tests `brightness_going_down == False` even when it is actually + # still going down... Needs some discussion. + return True + if not is_on(self.hass, entity_id): return True return False From ba21692c0f427f57dcb7bd13ec204bfb9976de35 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 27 Sep 2020 16:09:38 +0200 Subject: [PATCH 009/165] ignore all files for coverage --- .coveragerc | 1 + 1 file changed, 1 insertion(+) diff --git a/.coveragerc b/.coveragerc index a8459a2cd74762..3abbc9e9db8519 100644 --- a/.coveragerc +++ b/.coveragerc @@ -18,6 +18,7 @@ omit = homeassistant/components/acmeda/helpers.py homeassistant/components/acmeda/hub.py homeassistant/components/acmeda/sensor.py + homeassistant/components/adaptive_lighting/* homeassistant/components/adguard/__init__.py homeassistant/components/adguard/const.py homeassistant/components/adguard/sensor.py From e0b37d0851360480f455c9a521570cc016463fc2 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 27 Sep 2020 21:06:27 +0200 Subject: [PATCH 010/165] reduce indentation level in _maybe_cancel --- .../components/adaptive_lighting/switch.py | 87 +++++++++---------- 1 file changed, 43 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 879881aa009acc..52edb6b9577f2d 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -572,57 +572,56 @@ async def _maybe_cancel(self, entity_id, now_ts) -> bool: delay = TURNING_OFF_DELAY delta_time = now_ts - ts_on_to_off - if delta_time < delay: - delay -= delta_time # already been delta_time since the event - brightness_going_down = True # this might not be the case - _LOGGER.debug( - "%s: Waiting with adjusting '%s' for %s.", self._name, entity_id, delay + if delta_time > delay: + return False + delay -= delta_time # delta_time has passed since the 'off' → 'on' event + _LOGGER.debug( + "%s: Waiting with adjusting '%s' for %s.", self._name, entity_id, delay + ) + current_state = self.hass.states.get(entity_id) + _LOGGER.debug( + "%s: '%s' state before sleep is '%s'", + self._name, + entity_id, + current_state, + ) + for _ in range(3): + # It can happen that the actual transition time is longer than the + # specified time in the 'turn_off' service, so we check whether the + # brightness is still going down, if so, we wait a little longer. + await asyncio.sleep(delay) + await self.hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, ) + old_state = current_state current_state = self.hass.states.get(entity_id) + if current_state.state == "off": + return True + old_brightness = old_state.attributes.get(ATTR_BRIGHTNESS, 0) + current_brightness = current_state.attributes.get(ATTR_BRIGHTNESS, 0) + brightness_going_down = old_brightness > current_brightness _LOGGER.debug( - "%s: '%s' state before sleep is '%s'", + "%s: '%s' state after sleep is '%s'", self._name, entity_id, current_state, ) - for _ in range(3): - # It can happen that the actual transition time is longer than - # the specified time in the 'turn_off' service, so we check - # whether the brightness is still going down, if so, we wait a - # little longer. - await asyncio.sleep(delay) - await self.hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - old_state = current_state - current_state = self.hass.states.get(entity_id) - old_brightness = old_state.attributes.get(ATTR_BRIGHTNESS, 0) - current_brightness = current_state.attributes.get(ATTR_BRIGHTNESS, 0) - brightness_going_down = old_brightness > current_brightness - _LOGGER.debug( - "%s: '%s' state after sleep is '%s'", - self._name, - entity_id, - current_state, - ) - if not brightness_going_down: - break - delay = TURNING_OFF_DELAY - - if transition is not None: - # Always ignore when there's a transition - # TODO: I am doing this because it seems like HA cannot detect - # whether a light is transitioning into 'off'. Because in my - # tests `brightness_going_down == False` even when it is actually - # still going down... Needs some discussion. - return True - - if not is_on(self.hass, entity_id): - return True - return False + if not brightness_going_down: + break + delay = TURNING_OFF_DELAY # next time only wait this long + + if transition is not None: + # Always ignore when there's a transition and light is still on. + # TODO: I am doing this because it seems like HA cannot detect + # whether a light is transitioning into 'off'. Because in my + # tests `brightness_going_down == False` even when it is actually + # still going down... Needs some discussion. + return True + + return current_state.state == "off" async def _turn_off_event_listener(self, event): """Track 'light.turn_off(..., transition=...)' service calls.""" From b13aee9814f19f4b8a4f21c39998d14c130640a2 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 28 Sep 2020 00:24:07 +0200 Subject: [PATCH 011/165] listen to light.turn_on events --- .../components/adaptive_lighting/switch.py | 70 +++++++++++-------- 1 file changed, 41 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 52edb6b9577f2d..3dc303cc1beaee 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -205,7 +205,9 @@ def __init__(self, hass, config_entry): # Tracks 'off' → 'on' state changes self._on_to_off_event: Dict[str, Tuple[float, str]] = {} # Tracks 'light.turn_off(..., transition=...)' service calls - self._turn_off_service_event: Dict[str, Tuple[str, float]] = {} + self._turn_off_event: Dict[str, Tuple[str, float]] = {} + # Tracks 'light.turn_on' service calls + self._turn_on_event: Dict[str, Tuple[str]] = {} # Initialize attributes that will be set in self._update_attrs self._percent = None @@ -518,7 +520,7 @@ async def _light_event(self, event): _LOGGER.debug( "%s: Detected an 'off' → 'on' event for '%s'", self._name, entity_id ) - if await self._maybe_cancel(entity_id, now_ts): + if await self._maybe_cancel_adjusting(entity_id, now_ts, event): # Stop if a rapid 'off' → 'on' → 'off' happens. _LOGGER.debug( "%s: Cancelling adjusting lights for %s", self._name, entity_id @@ -538,7 +540,7 @@ async def _light_event(self, event): # Tracks 'off' → 'on' state changes self._on_to_off_event[entity_id] = (now_ts, event.context.id) - async def _maybe_cancel(self, entity_id, now_ts) -> bool: + async def _maybe_cancel_adjusting(self, entity_id, now_ts, off_to_on_event) -> bool: """Cancel the adjusting of a light if it has just been turned off. Possibly the lights just got a 'turn_off' call, however, the light @@ -552,23 +554,23 @@ async def _maybe_cancel(self, entity_id, now_ts) -> bool: adjust the lights. """ ts_on_to_off, id_on_to_off = self._on_to_off_event.get(entity_id, (0, None)) - id_turn_off, transition = self._turn_off_service_event.get( - entity_id, (None, None) - ) - if ( - id_on_to_off is not None - and id_turn_off is not None - and id_on_to_off == id_turn_off - ): - # State change 'off' → 'on' and 'light.turn_off(..., transition=...)' are - # from the same event, so wait at least the 'turn_off' transition time. - delay = transition + id_turn_off, transition = self._turn_off_event.get(entity_id, (None, None)) + id_turn_on = self._turn_on_event.get(entity_id) + id_off_to_on = off_to_on_event.context.id + + if id_off_to_on == id_turn_on and id_off_to_on is not None: + # State change 'off' → 'on' triggered by 'light.turn_on'. + return False elif ts_on_to_off == 0: # No state change has been registered before. return False + elif id_on_to_off == id_turn_off and id_on_to_off is not None: + # State change 'off' → 'on' and 'light.turn_off(..., transition=...)' come + # from the same event, so wait at least the 'turn_off' transition time. + delay = transition else: - # State change 'off' → 'on' happened but **not** because a - # 'light.turn_off' event that is called with 'transition'. + # State change 'off' → 'on' happened because the light state was set. + # Possibly because of polling. delay = TURNING_OFF_DELAY delta_time = now_ts - ts_on_to_off @@ -618,28 +620,38 @@ async def _maybe_cancel(self, entity_id, now_ts) -> bool: # TODO: I am doing this because it seems like HA cannot detect # whether a light is transitioning into 'off'. Because in my # tests `brightness_going_down == False` even when it is actually - # still going down... Needs some discussion. + # still going down... Maybe needs some discussion. return True return current_state.state == "off" async def _turn_off_event_listener(self, event): - """Track 'light.turn_off(..., transition=...)' service calls.""" - if event.data.get(ATTR_DOMAIN) != LIGHT_DOMAIN: - return - if event.data.get(ATTR_SERVICE) != SERVICE_TURN_OFF: + """Track 'light.turn_off(..., transition=...)' and 'light.turn_on' service calls.""" + domain = event.data.get(ATTR_DOMAIN) + if domain != LIGHT_DOMAIN: return + service = event.data.get(ATTR_SERVICE) service_data = event.data.get(ATTR_SERVICE_DATA, {}) - transition = service_data.get(ATTR_TRANSITION) - if transition is not None and transition > 0: - entity_id = service_data[ATTR_ENTITY_ID] + entity_id = service_data.get(ATTR_ENTITY_ID) + if isinstance(entity_id, str): + entity_id = [entity_id] + + if service == SERVICE_TURN_OFF: + transition = service_data.get(ATTR_TRANSITION) + if transition is not None and transition > 0: + _LOGGER.debug( + "%s: Detected an 'light.turn_off('%s', transition=%s)' event", + self._name, + entity_id, + transition, + ) + for eid in entity_id: + self._turn_off_event[eid] = (event.context.id, transition) + elif service == SERVICE_TURN_ON: _LOGGER.debug( - "%s: Detected an 'light.turn_off('%s', transition=%s)' event", + "%s: Detected an 'light.turn_on('%s')' event", self._name, entity_id, - transition, ) - if isinstance(entity_id, str): - entity_id = [entity_id] for eid in entity_id: - self._turn_off_service_event[eid] = (event.context.id, transition) + self._turn_on_event[eid] = event.context.id From 30d3066f8280d6f46bbfe1c219c724b8728f4195 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 28 Sep 2020 10:12:37 +0200 Subject: [PATCH 012/165] lock lights while waiting to turn off --- .../components/adaptive_lighting/switch.py | 51 ++++++++++++------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 3dc303cc1beaee..1843102b0481fa 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -208,6 +208,8 @@ def __init__(self, hass, config_entry): self._turn_off_event: Dict[str, Tuple[str, float]] = {} # Tracks 'light.turn_on' service calls self._turn_on_event: Dict[str, Tuple[str]] = {} + # Locks that prevent light adjusting when waiting for a light to 'turn_off' + self._locks: Dict[str, asyncio.Lock] = {} # Initialize attributes that will be set in self._update_attrs self._percent = None @@ -272,13 +274,11 @@ def _unpack_light_groups(self, lights): async def async_added_to_hass(self): """Call when entity about to be added to hass.""" if self._lights: - unpacked_lights = self._unpack_light_groups(self._lights) - async_track_state_change_event( - self.hass, unpacked_lights, self._light_event - ) + self._lights = self._unpack_light_groups(self._lights) + async_track_state_change_event(self.hass, self._lights, self._light_event) # Tracks 'light.turn_off(..., transition=...)' service calls self.hass.bus.async_listen( - EVENT_CALL_SERVICE, self._turn_off_event_listener + EVENT_CALL_SERVICE, self._turn_on_off_event_listener ) track_kwargs = dict(hass=self.hass, action=self._state_changed) if self._sleep_entity is not None: @@ -504,6 +504,9 @@ async def _state_changed(self, entity_id, from_state, to_state): from_state, to_state, ) + lock = self._locks.get(entity_id) + if lock is not None and lock.locked: + return await self._update_lights(transition=self._initial_transition, force=True) async def _light_event(self, event): @@ -520,17 +523,19 @@ async def _light_event(self, event): _LOGGER.debug( "%s: Detected an 'off' → 'on' event for '%s'", self._name, entity_id ) - if await self._maybe_cancel_adjusting(entity_id, now_ts, event): - # Stop if a rapid 'off' → 'on' → 'off' happens. - _LOGGER.debug( - "%s: Cancelling adjusting lights for %s", self._name, entity_id + lock = self._locks.setdefault(entity_id, asyncio.Lock()) + async with lock: + if await self._maybe_cancel_adjusting(entity_id, now_ts, event): + # Stop if a rapid 'off' → 'on' → 'off' happens. + _LOGGER.debug( + "%s: Cancelling adjusting lights for %s", self._name, entity_id + ) + return + await self._update_lights( + lights=[entity_id], + transition=self._initial_transition, + force=True, ) - return - await self._update_lights( - lights=[entity_id], - transition=self._initial_transition, - force=True, - ) elif ( old_state is not None and old_state.state == "on" @@ -561,10 +566,12 @@ async def _maybe_cancel_adjusting(self, entity_id, now_ts, off_to_on_event) -> b if id_off_to_on == id_turn_on and id_off_to_on is not None: # State change 'off' → 'on' triggered by 'light.turn_on'. return False - elif ts_on_to_off == 0: + + if ts_on_to_off == 0: # No state change has been registered before. return False - elif id_on_to_off == id_turn_off and id_on_to_off is not None: + + if id_on_to_off == id_turn_off and id_on_to_off is not None: # State change 'off' → 'on' and 'light.turn_off(..., transition=...)' come # from the same event, so wait at least the 'turn_off' transition time. delay = transition @@ -576,6 +583,12 @@ async def _maybe_cancel_adjusting(self, entity_id, now_ts, off_to_on_event) -> b delta_time = now_ts - ts_on_to_off if delta_time > delay: return False + + # Here we could just `return True` but because we want to prevent any updates + # from happening to this light (through async_track_time_interval or + # sleep_state or disable_state) for some time, we wait below until the light + # is 'off' or the time has passed. + delay -= delta_time # delta_time has passed since the 'off' → 'on' event _LOGGER.debug( "%s: Waiting with adjusting '%s' for %s.", self._name, entity_id, delay @@ -620,12 +633,12 @@ async def _maybe_cancel_adjusting(self, entity_id, now_ts, off_to_on_event) -> b # TODO: I am doing this because it seems like HA cannot detect # whether a light is transitioning into 'off'. Because in my # tests `brightness_going_down == False` even when it is actually - # still going down... Maybe needs some discussion. + # still going down... Maybe needs some discussion/input? return True return current_state.state == "off" - async def _turn_off_event_listener(self, event): + async def _turn_on_off_event_listener(self, event): """Track 'light.turn_off(..., transition=...)' and 'light.turn_on' service calls.""" domain = event.data.get(ATTR_DOMAIN) if domain != LIGHT_DOMAIN: From 513b24d51198443a732339674ddf99bd0075b699 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 28 Sep 2020 10:58:17 +0200 Subject: [PATCH 013/165] partially address review comments --- homeassistant/components/adaptive_lighting/__init__.py | 10 +++++----- .../components/adaptive_lighting/config_flow.py | 2 +- homeassistant/components/adaptive_lighting/const.py | 2 +- homeassistant/components/adaptive_lighting/switch.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/__init__.py b/homeassistant/components/adaptive_lighting/__init__.py index 9e6f5c9c070afe..0232cc2d9222e8 100644 --- a/homeassistant/components/adaptive_lighting/__init__.py +++ b/homeassistant/components/adaptive_lighting/__init__.py @@ -1,11 +1,11 @@ -"""Adaptive Lighting Component in Home-Assistant. +"""Adaptive Lighting integration in Home-Assistant. -This component calculates color temperature and brightness to synchronize +This integration calculates color temperature and brightness to synchronize your color-changing lights with the perceived color temperature of the sky throughout the day. This gives your environment a more natural feel, with cooler whites during the midday and warmer tints near twilight and dawn. -Additionally, the component sets your lights to a nice warm white at 1% in +Additionally, the integration sets your lights to a nice warm white at 1% in "Sleep mode", which is far brighter than starlight but won't reset your circadian rhythm or break down too much rhodopsin in your eyes. @@ -20,8 +20,8 @@ ## Notes * Only your location is taken into account to calculate the the sun's position. -* Weather and altitude are not considered. -* The component does not calculate a true "Blue Hour" -- it just sets the +* Weather is not considered. +* The integration does not calculate a true "Blue Hour" -- it just sets the lights to 2700K (warm white) until your hub goes into "Sleep mode". """ import asyncio diff --git a/homeassistant/components/adaptive_lighting/config_flow.py b/homeassistant/components/adaptive_lighting/config_flow.py index d022e040dfa967..7002c7d7b37cb7 100644 --- a/homeassistant/components/adaptive_lighting/config_flow.py +++ b/homeassistant/components/adaptive_lighting/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for Coronavirus integration.""" +"""Config flow for Adaptive Lighting integration.""" import logging import voluptuous as vol diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index c939b281a3d527..23e85b7bdc7fa7 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -1,4 +1,4 @@ -"""Constants for the Adaptive Lighting Component in Home-Assistant.""" +"""Constants for the Adaptive Lighting integration.""" import voluptuous as vol from homeassistant.components.light import VALID_TRANSITION diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 1843102b0481fa..6752f5d80e2f06 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -1,4 +1,4 @@ -"""Adaptive Lighting Component for Home-Assistant.""" +"""Switch for the Adaptive Lighting integration.""" import asyncio import bisect From 8dd78628ea55c8fcee9cc9b11fd80a78687794de Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 28 Sep 2020 11:09:46 +0200 Subject: [PATCH 014/165] add TurnOnOffListener that is used among switches and wait until EVENT_HOMEASSISTANT_START --- .../components/adaptive_lighting/__init__.py | 1 - .../components/adaptive_lighting/const.py | 3 +- .../components/adaptive_lighting/switch.py | 179 ++++++++++-------- 3 files changed, 101 insertions(+), 82 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/__init__.py b/homeassistant/components/adaptive_lighting/__init__.py index 0232cc2d9222e8..1e935df7415a6c 100644 --- a/homeassistant/components/adaptive_lighting/__init__.py +++ b/homeassistant/components/adaptive_lighting/__init__.py @@ -72,7 +72,6 @@ async def async_setup_entry(hass, config_entry: ConfigEntry): undo_listener = config_entry.add_update_listener(async_update_options) hass.data[DOMAIN][config_entry.entry_id] = {UNDO_UPDATE_LISTENER: undo_listener} - for platform in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(config_entry, platform) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index 23e85b7bdc7fa7..d194efc8ca25c3 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -36,8 +36,9 @@ CONF_SUNSET_TIME = "sunset_time" CONF_TRANSITION, DEFAULT_TRANSITION = "transition", 60 +ATTR_TURN_ON_OFF_LISTENER = "turn_on_off_listener" UNDO_UPDATE_LISTENER = "undo_update_listener" -NONE_STR = "None" # TODO: use `from homeassistant.const import ENTITY_MATCH_NONE`? +NONE_STR = "None" SERVICE_APPLY = "apply" CONF_COLORS_ONLY = "colors_only" diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 6752f5d80e2f06..c5eaadf99d46ae 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -35,12 +35,14 @@ ATTR_SERVICE_DATA, CONF_NAME, EVENT_CALL_SERVICE, + EVENT_HOMEASSISTANT_START, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, ) +from homeassistant.core import Event from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import ( @@ -60,6 +62,7 @@ import homeassistant.util.dt as dt_util from .const import ( + ATTR_TURN_ON_OFF_LISTENER, CONF_COLORS_ONLY, CONF_DISABLE_BRIGHTNESS_ADJUST, CONF_DISABLE_COLOR_ADJUST, @@ -129,9 +132,14 @@ async def handle_apply(switch, service_call): async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the AdaptiveLighting switch.""" - switch = AdaptiveSwitch(hass, config_entry) if DOMAIN not in hass.data: hass.data[DOMAIN] = {} + + if ATTR_TURN_ON_OFF_LISTENER not in hass.data[DOMAIN]: + hass.data[DOMAIN][ATTR_TURN_ON_OFF_LISTENER] = TurnOnOffListener(hass) + + turn_on_off_listener = hass.data[DOMAIN][ATTR_TURN_ON_OFF_LISTENER] + switch = AdaptiveSwitch(hass, config_entry, turn_on_off_listener) name = config_entry.data[CONF_NAME] hass.data[DOMAIN][name] = switch @@ -170,9 +178,10 @@ def validate(config_entry): class AdaptiveSwitch(SwitchEntity, RestoreEntity): """Representation of a Adaptive Lighting switch.""" - def __init__(self, hass, config_entry): + def __init__(self, hass, config_entry, turn_on_off_listener): """Initialize the Adaptive Lighting switch.""" self.hass = hass + self.turn_on_off_listener = turn_on_off_listener data = validate(config_entry) self._name = data[CONF_NAME] @@ -203,11 +212,7 @@ def __init__(self, hass, config_entry): self._entity_id = f"switch.{DOMAIN}_{slugify(self._name)}" # Tracks 'off' → 'on' state changes - self._on_to_off_event: Dict[str, Tuple[float, str]] = {} - # Tracks 'light.turn_off(..., transition=...)' service calls - self._turn_off_event: Dict[str, Tuple[str, float]] = {} - # Tracks 'light.turn_on' service calls - self._turn_on_event: Dict[str, Tuple[str]] = {} + self._on_to_off_event: Dict[str, Event] = {} # Locks that prevent light adjusting when waiting for a light to 'turn_off' self._locks: Dict[str, asyncio.Lock] = {} @@ -255,47 +260,49 @@ def _supported_features(self, light): key for key, value in _SUPPORT_OPTS.items() if supported_features & value } - def _unpack_light_groups(self, lights): + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + if self._lights: + if self.hass.is_running: + await self._setup_listeners() + else: + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, self._setup_listeners + ) + last_state = await self.async_get_last_state() + if last_state and last_state.state == STATE_ON: + await self.async_turn_on() + + def _unpack_light_groups(self) -> None: all_lights = [] - for light in lights: + for light in self._lights: state = self.hass.states.get(light) if state is None: _LOGGER.debug("%s: State of %s is None", self._name, light) - # TODO: make sure that the lights are loaded when doing this all_lights.append(light) elif "entity_id" in state.attributes: # it's a light group group = state.attributes["entity_id"] - _LOGGER.debug("%s: Unpacked %s to %s", self._name, lights, group) all_lights.extend(group) + _LOGGER.debug("%s: Unpacked %s to %s", self._name, light, group) else: all_lights.append(light) - return all_lights - - async def async_added_to_hass(self): - """Call when entity about to be added to hass.""" - if self._lights: - self._lights = self._unpack_light_groups(self._lights) - async_track_state_change_event(self.hass, self._lights, self._light_event) - # Tracks 'light.turn_off(..., transition=...)' service calls - self.hass.bus.async_listen( - EVENT_CALL_SERVICE, self._turn_on_off_event_listener - ) - track_kwargs = dict(hass=self.hass, action=self._state_changed) - if self._sleep_entity is not None: - sleep_kwargs = dict(track_kwargs, entity_ids=self._sleep_entity) - async_track_state_change(**sleep_kwargs, to_state=self._sleep_state) - async_track_state_change(**sleep_kwargs, from_state=self._sleep_state) - - if self._disable_entity is not None: - disable_kwargs = dict(track_kwargs, entity_ids=self._disable_entity) - async_track_state_change( - **disable_kwargs, from_state=self._disable_state - ) - async_track_state_change(**disable_kwargs, to_state=self._disable_state) - - last_state = await self.async_get_last_state() - if last_state and last_state.state == STATE_ON: - await self.async_turn_on() + self._lights = all_lights + + async def _setup_listeners(self, _=None): + self._unpack_light_groups() + for light in self._lights: + self.turn_on_off_listener.lights.add(light) + async_track_state_change_event(self.hass, self._lights, self._light_event) + track_kwargs = dict(hass=self.hass, action=self._state_changed) + if self._sleep_entity is not None: + sleep_kwargs = dict(track_kwargs, entity_ids=self._sleep_entity) + async_track_state_change(**sleep_kwargs, to_state=self._sleep_state) + async_track_state_change(**sleep_kwargs, from_state=self._sleep_state) + + if self._disable_entity is not None: + disable_kwargs = dict(track_kwargs, entity_ids=self._disable_entity) + async_track_state_change(**disable_kwargs, from_state=self._disable_state) + async_track_state_change(**disable_kwargs, to_state=self._disable_state) @property def icon(self): @@ -510,10 +517,10 @@ async def _state_changed(self, entity_id, from_state, to_state): await self._update_lights(transition=self._initial_transition, force=True) async def _light_event(self, event): + old_state = event.data.get("old_state") new_state = event.data.get("new_state") entity_id = event.data.get("entity_id") - now_ts = dt_util.now().timestamp() if ( old_state is not None and old_state.state == "off" @@ -523,19 +530,24 @@ async def _light_event(self, event): _LOGGER.debug( "%s: Detected an 'off' → 'on' event for '%s'", self._name, entity_id ) + on_to_off_event = self._on_to_off_event.get(entity_id) lock = self._locks.setdefault(entity_id, asyncio.Lock()) async with lock: - if await self._maybe_cancel_adjusting(entity_id, now_ts, event): + if await self.turn_on_off_listener.maybe_cancel_adjusting( + entity_id, + off_to_on_event=event, + on_to_off_event=on_to_off_event, + ): # Stop if a rapid 'off' → 'on' → 'off' happens. _LOGGER.debug( "%s: Cancelling adjusting lights for %s", self._name, entity_id ) return - await self._update_lights( - lights=[entity_id], - transition=self._initial_transition, - force=True, - ) + await self._update_lights( + lights=[entity_id], + transition=self._initial_transition, + force=True, + ) elif ( old_state is not None and old_state.state == "on" @@ -543,9 +555,27 @@ async def _light_event(self, event): and new_state.state == "off" ): # Tracks 'off' → 'on' state changes - self._on_to_off_event[entity_id] = (now_ts, event.context.id) + self._on_to_off_event[entity_id] = event + - async def _maybe_cancel_adjusting(self, entity_id, now_ts, off_to_on_event) -> bool: +class TurnOnOffListener: + """Track 'light.turn_off(..., transition=...)' and 'light.turn_on' service calls.""" + + def __init__(self, hass): + """Initialize the TurnOnOffListener that is shared among all switches.""" + self.hass = hass + self.lights = set() + + # Tracks 'light.turn_off(..., transition=...)' service calls + self.turn_off_event: Dict[str, Tuple[str, float]] = {} + # Tracks 'light.turn_on' service calls + self.turn_on_event: Dict[str, Tuple[str]] = {} + + self.hass.bus.async_listen(EVENT_CALL_SERVICE, self.turn_on_off_event_listener) + + async def maybe_cancel_adjusting( + self, entity_id, off_to_on_event, on_to_off_event + ) -> bool: """Cancel the adjusting of a light if it has just been turned off. Possibly the lights just got a 'turn_off' call, however, the light @@ -558,19 +588,19 @@ async def _maybe_cancel_adjusting(self, entity_id, now_ts, off_to_on_event) -> b if the brightness is still decreasing. Only if it is the case we adjust the lights. """ - ts_on_to_off, id_on_to_off = self._on_to_off_event.get(entity_id, (0, None)) - id_turn_off, transition = self._turn_off_event.get(entity_id, (None, None)) - id_turn_on = self._turn_on_event.get(entity_id) + if on_to_off_event is None: + # No state change has been registered before. + return False + + id_on_to_off = on_to_off_event.context.id + id_turn_off, transition = self.turn_off_event.get(entity_id, (None, None)) + id_turn_on = self.turn_on_event.get(entity_id) id_off_to_on = off_to_on_event.context.id if id_off_to_on == id_turn_on and id_off_to_on is not None: # State change 'off' → 'on' triggered by 'light.turn_on'. return False - if ts_on_to_off == 0: - # No state change has been registered before. - return False - if id_on_to_off == id_turn_off and id_on_to_off is not None: # State change 'off' → 'on' and 'light.turn_off(..., transition=...)' come # from the same event, so wait at least the 'turn_off' transition time. @@ -580,7 +610,7 @@ async def _maybe_cancel_adjusting(self, entity_id, now_ts, off_to_on_event) -> b # Possibly because of polling. delay = TURNING_OFF_DELAY - delta_time = now_ts - ts_on_to_off + delta_time = (dt_util.utcnow() - on_to_off_event.time_fired).total_seconds() if delta_time > delay: return False @@ -590,16 +620,9 @@ async def _maybe_cancel_adjusting(self, entity_id, now_ts, off_to_on_event) -> b # is 'off' or the time has passed. delay -= delta_time # delta_time has passed since the 'off' → 'on' event - _LOGGER.debug( - "%s: Waiting with adjusting '%s' for %s.", self._name, entity_id, delay - ) + _LOGGER.debug("Waiting with adjusting '%s' for %s.", entity_id, delay) current_state = self.hass.states.get(entity_id) - _LOGGER.debug( - "%s: '%s' state before sleep is '%s'", - self._name, - entity_id, - current_state, - ) + _LOGGER.debug("'%s' state before sleep is '%s'", entity_id, current_state) for _ in range(3): # It can happen that the actual transition time is longer than the # specified time in the 'turn_off' service, so we check whether the @@ -618,12 +641,7 @@ async def _maybe_cancel_adjusting(self, entity_id, now_ts, off_to_on_event) -> b old_brightness = old_state.attributes.get(ATTR_BRIGHTNESS, 0) current_brightness = current_state.attributes.get(ATTR_BRIGHTNESS, 0) brightness_going_down = old_brightness > current_brightness - _LOGGER.debug( - "%s: '%s' state after sleep is '%s'", - self._name, - entity_id, - current_state, - ) + _LOGGER.debug("'%s' state after sleep is '%s'", entity_id, current_state) if not brightness_going_down: break delay = TURNING_OFF_DELAY # next time only wait this long @@ -638,33 +656,34 @@ async def _maybe_cancel_adjusting(self, entity_id, now_ts, off_to_on_event) -> b return current_state.state == "off" - async def _turn_on_off_event_listener(self, event): + async def turn_on_off_event_listener(self, event): """Track 'light.turn_off(..., transition=...)' and 'light.turn_on' service calls.""" domain = event.data.get(ATTR_DOMAIN) if domain != LIGHT_DOMAIN: return + service = event.data.get(ATTR_SERVICE) service_data = event.data.get(ATTR_SERVICE_DATA, {}) + entity_id = service_data.get(ATTR_ENTITY_ID) if isinstance(entity_id, str): entity_id = [entity_id] + if not any(eid in self.lights for eid in entity_id): + return + if service == SERVICE_TURN_OFF: transition = service_data.get(ATTR_TRANSITION) if transition is not None and transition > 0: _LOGGER.debug( - "%s: Detected an 'light.turn_off('%s', transition=%s)' event", - self._name, + "Detected an 'light.turn_off('%s', transition=%s)' event", entity_id, transition, ) for eid in entity_id: - self._turn_off_event[eid] = (event.context.id, transition) + self.turn_off_event[eid] = (event.context.id, transition) + elif service == SERVICE_TURN_ON: - _LOGGER.debug( - "%s: Detected an 'light.turn_on('%s')' event", - self._name, - entity_id, - ) + _LOGGER.debug("Detected an 'light.turn_on('%s')' event", entity_id) for eid in entity_id: - self._turn_on_event[eid] = event.context.id + self.turn_on_event[eid] = event.context.id From 010580a678edca8a47094bb48a928d7795488afd Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 28 Sep 2020 14:19:45 +0200 Subject: [PATCH 015/165] simplify maybe_cancel_adjusting to not overflow a light with calls --- .../components/adaptive_lighting/switch.py | 39 ++++--------------- 1 file changed, 8 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index c5eaadf99d46ae..3d13e946800763 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -9,12 +9,7 @@ import voluptuous as vol -from homeassistant.components.homeassistant import ( - DOMAIN as HA_DOMAIN, - SERVICE_UPDATE_ENTITY, -) from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, @@ -602,7 +597,7 @@ async def maybe_cancel_adjusting( return False if id_on_to_off == id_turn_off and id_on_to_off is not None: - # State change 'off' → 'on' and 'light.turn_off(..., transition=...)' come + # State change 'on' → 'off' and 'light.turn_off(..., transition=...)' come # from the same event, so wait at least the 'turn_off' transition time. delay = transition else: @@ -621,40 +616,22 @@ async def maybe_cancel_adjusting( delay -= delta_time # delta_time has passed since the 'off' → 'on' event _LOGGER.debug("Waiting with adjusting '%s' for %s.", entity_id, delay) - current_state = self.hass.states.get(entity_id) - _LOGGER.debug("'%s' state before sleep is '%s'", entity_id, current_state) + for _ in range(3): # It can happen that the actual transition time is longer than the - # specified time in the 'turn_off' service, so we check whether the - # brightness is still going down, if so, we wait a little longer. + # specified time in the 'turn_off' service. await asyncio.sleep(delay) - await self.hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - old_state = current_state - current_state = self.hass.states.get(entity_id) - if current_state.state == "off": + if not is_on(self.hass, entity_id): return True - old_brightness = old_state.attributes.get(ATTR_BRIGHTNESS, 0) - current_brightness = current_state.attributes.get(ATTR_BRIGHTNESS, 0) - brightness_going_down = old_brightness > current_brightness - _LOGGER.debug("'%s' state after sleep is '%s'", entity_id, current_state) - if not brightness_going_down: - break delay = TURNING_OFF_DELAY # next time only wait this long if transition is not None: - # Always ignore when there's a transition and light is still on. - # TODO: I am doing this because it seems like HA cannot detect - # whether a light is transitioning into 'off'. Because in my - # tests `brightness_going_down == False` even when it is actually - # still going down... Maybe needs some discussion/input? + # Always ignore when there's a transition. + # Because it seems like HA cannot detect whether a light is + # transitioning into 'off'. Maybe needs some discussion/input? return True - return current_state.state == "off" + return False async def turn_on_off_event_listener(self, event): """Track 'light.turn_off(..., transition=...)' and 'light.turn_on' service calls.""" From e1cca6c67802564fe469e55fe440278ddf5bb9f7 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 28 Sep 2020 14:34:02 +0200 Subject: [PATCH 016/165] catch all turn_off events --- .../components/adaptive_lighting/switch.py | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 3d13e946800763..9f28be3c6f06b5 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -525,13 +525,12 @@ async def _light_event(self, event): _LOGGER.debug( "%s: Detected an 'off' → 'on' event for '%s'", self._name, entity_id ) - on_to_off_event = self._on_to_off_event.get(entity_id) lock = self._locks.setdefault(entity_id, asyncio.Lock()) async with lock: if await self.turn_on_off_listener.maybe_cancel_adjusting( entity_id, off_to_on_event=event, - on_to_off_event=on_to_off_event, + on_to_off_event=self._on_to_off_event.get(entity_id), ): # Stop if a rapid 'off' → 'on' → 'off' happens. _LOGGER.debug( @@ -554,14 +553,14 @@ async def _light_event(self, event): class TurnOnOffListener: - """Track 'light.turn_off(..., transition=...)' and 'light.turn_on' service calls.""" + """Track 'light.turn_off' and 'light.turn_on' service calls.""" def __init__(self, hass): """Initialize the TurnOnOffListener that is shared among all switches.""" self.hass = hass self.lights = set() - # Tracks 'light.turn_off(..., transition=...)' service calls + # Tracks 'light.turn_off' service calls self.turn_off_event: Dict[str, Tuple[str, float]] = {} # Tracks 'light.turn_on' service calls self.turn_on_event: Dict[str, Tuple[str]] = {} @@ -596,10 +595,14 @@ async def maybe_cancel_adjusting( # State change 'off' → 'on' triggered by 'light.turn_on'. return False - if id_on_to_off == id_turn_off and id_on_to_off is not None: + if ( + id_on_to_off == id_turn_off + and id_on_to_off is not None + and transition is not None # 'turn_off' is called with transition=... + ): # State change 'on' → 'off' and 'light.turn_off(..., transition=...)' come # from the same event, so wait at least the 'turn_off' transition time. - delay = transition + delay = max(transition, TURNING_OFF_DELAY) else: # State change 'off' → 'on' happened because the light state was set. # Possibly because of polling. @@ -615,7 +618,7 @@ async def maybe_cancel_adjusting( # is 'off' or the time has passed. delay -= delta_time # delta_time has passed since the 'off' → 'on' event - _LOGGER.debug("Waiting with adjusting '%s' for %s.", entity_id, delay) + _LOGGER.debug("Waiting with adjusting '%s' for %s", entity_id, delay) for _ in range(3): # It can happen that the actual transition time is longer than the @@ -626,7 +629,7 @@ async def maybe_cancel_adjusting( delay = TURNING_OFF_DELAY # next time only wait this long if transition is not None: - # Always ignore when there's a transition. + # Always ignore when there's a 'turn_off' transition. # Because it seems like HA cannot detect whether a light is # transitioning into 'off'. Maybe needs some discussion/input? return True @@ -634,7 +637,7 @@ async def maybe_cancel_adjusting( return False async def turn_on_off_event_listener(self, event): - """Track 'light.turn_off(..., transition=...)' and 'light.turn_on' service calls.""" + """Track 'light.turn_off' and 'light.turn_on' service calls.""" domain = event.data.get(ATTR_DOMAIN) if domain != LIGHT_DOMAIN: return @@ -651,14 +654,13 @@ async def turn_on_off_event_listener(self, event): if service == SERVICE_TURN_OFF: transition = service_data.get(ATTR_TRANSITION) - if transition is not None and transition > 0: - _LOGGER.debug( - "Detected an 'light.turn_off('%s', transition=%s)' event", - entity_id, - transition, - ) - for eid in entity_id: - self.turn_off_event[eid] = (event.context.id, transition) + _LOGGER.debug( + "Detected an 'light.turn_off('%s', transition=%s)' event", + entity_id, + transition, + ) + for eid in entity_id: + self.turn_off_event[eid] = (event.context.id, transition) elif service == SERVICE_TURN_ON: _LOGGER.debug("Detected an 'light.turn_on('%s')' event", entity_id) From 5a7bf709ac9c84fc291aff68aa10481104a9309b Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 28 Sep 2020 16:09:19 +0200 Subject: [PATCH 017/165] update with latest yaml settings --- homeassistant/components/adaptive_lighting/config_flow.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/config_flow.py b/homeassistant/components/adaptive_lighting/config_flow.py index 7002c7d7b37cb7..543363a24d55b1 100644 --- a/homeassistant/components/adaptive_lighting/config_flow.py +++ b/homeassistant/components/adaptive_lighting/config_flow.py @@ -43,7 +43,12 @@ async def async_step_user(self, user_input=None): async def async_step_import(self, user_input=None): """Handle configuration by yaml file.""" await self.async_set_unique_id(user_input["name"]) - self._abort_if_unique_id_configured() + for entry in self._async_current_entries(): + if entry.unique_id == self.unique_id: + self.hass.config_entries.async_update_entry( + entry, data=dict(entry.data, **user_input) + ) + return return self.async_create_entry(title=user_input["name"], data=user_input) @staticmethod From 90867f259941893a49b7e6e8c4d3a11840a3913f Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 28 Sep 2020 16:30:36 +0200 Subject: [PATCH 018/165] always use color_temp over rgb --- homeassistant/components/adaptive_lighting/const.py | 7 +++++-- .../components/adaptive_lighting/strings.json | 2 +- .../components/adaptive_lighting/switch.py | 13 ++++++++----- .../adaptive_lighting/translations/en.json | 2 +- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index d194efc8ca25c3..f49188abd28045 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -16,7 +16,10 @@ "disable_brightness_adjust", False, ) -CONF_DISABLE_COLOR_ADJUST, DEFAULT_DISABLE_COLOR_ADJUST = "disable_color_adjust", False +CONF_DISABLE_COLOR_TEMP_ADJUST, DEFAULT_DISABLE_COLOR_TEMP_ADJUST = ( + "disable_color_temp_adjust", + False, +) CONF_DISABLE_ENTITY = "disable_entity" CONF_DISABLE_STATE = "disable_state" CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION = "initial_transition", 1 @@ -55,7 +58,7 @@ def int_between(min_int, max_int): VALIDATION_TUPLES = [ (CONF_LIGHTS, DEFAULT_LIGHTS, cv.entity_ids), (CONF_DISABLE_BRIGHTNESS_ADJUST, DEFAULT_DISABLE_BRIGHTNESS_ADJUST, bool), - (CONF_DISABLE_COLOR_ADJUST, DEFAULT_DISABLE_COLOR_ADJUST, bool), + (CONF_DISABLE_COLOR_TEMP_ADJUST, DEFAULT_DISABLE_COLOR_TEMP_ADJUST, bool), (CONF_DISABLE_ENTITY, NONE_STR, cv.entity_id), (CONF_DISABLE_STATE, NONE_STR, str), (CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION), diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index b871aac998e1c3..d63dae61ce2942 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -22,7 +22,7 @@ "data": { "lights": "lights", "disable_brightness_adjust": "disable_brightness_adjust", - "disable_color_adjust": "disable_color_adjust", + "disable_color_temp_adjust": "disable_color_temp_adjust", "disable_entity": "disable_entity", "disable_state": "disable_state", "initial_transition": "initial_transition, the transition of the lights when turning them on or when 'disable_state' or 'sleep_state' change", diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 9f28be3c6f06b5..3022b6bd9e94f9 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -60,7 +60,7 @@ ATTR_TURN_ON_OFF_LISTENER, CONF_COLORS_ONLY, CONF_DISABLE_BRIGHTNESS_ADJUST, - CONF_DISABLE_COLOR_ADJUST, + CONF_DISABLE_COLOR_TEMP_ADJUST, CONF_DISABLE_ENTITY, CONF_DISABLE_STATE, CONF_INITIAL_TRANSITION, @@ -182,7 +182,7 @@ def __init__(self, hass, config_entry, turn_on_off_listener): self._name = data[CONF_NAME] self._lights = data[CONF_LIGHTS] self._disable_brightness_adjust = data[CONF_DISABLE_BRIGHTNESS_ADJUST] - self._disable_color_adjust = data[CONF_DISABLE_COLOR_ADJUST] + self._disable_color_temp_adjust = data[CONF_DISABLE_COLOR_TEMP_ADJUST] self._disable_entity = data[CONF_DISABLE_ENTITY] self._disable_state = data[CONF_DISABLE_STATE] self._initial_transition = data[CONF_INITIAL_TRANSITION] @@ -469,10 +469,13 @@ async def _adjust_light(self, light, transition, colors_only=False): ): service_data[ATTR_BRIGHTNESS_PCT] = self._brightness - if "color" in features and not self._disable_color_adjust: + if "color_temp" in features and not self._disable_color_temp_adjust: + attributes = self.hass.states.get(light).attributes + min_mireds, max_mireds = attributes["min_mireds"], attributes["max_mireds"] + color_temp_mired = max(min(self._color_temp_mired, max_mireds), min_mireds) + service_data[ATTR_COLOR_TEMP] = color_temp_mired + elif "color" in features: service_data[ATTR_RGB_COLOR] = self._rgb_color - elif "color_temp" in features: - service_data[ATTR_COLOR_TEMP] = self._color_temp_mired _LOGGER.debug( "%s: Scheduling 'light.turn_on' with the following 'service_data': %s", diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index b871aac998e1c3..d63dae61ce2942 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -22,7 +22,7 @@ "data": { "lights": "lights", "disable_brightness_adjust": "disable_brightness_adjust", - "disable_color_adjust": "disable_color_adjust", + "disable_color_temp_adjust": "disable_color_temp_adjust", "disable_entity": "disable_entity", "disable_state": "disable_state", "initial_transition": "initial_transition, the transition of the lights when turning them on or when 'disable_state' or 'sleep_state' change", From 9fa6c701b132ed76571199b3a47e8893c05ce715 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 28 Sep 2020 17:59:44 +0200 Subject: [PATCH 019/165] fix tz issue when manually setting sunset/sunrise --- .../components/adaptive_lighting/switch.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 3022b6bd9e94f9..0b2cef04ced0bf 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -3,6 +3,7 @@ import asyncio import bisect from copy import deepcopy +import datetime from datetime import timedelta import logging from typing import Dict, Tuple @@ -359,12 +360,15 @@ async def _update_lights(self, lights=None, transition=None, force=False): def _get_sun_events(self, date): def _replace_time(date, key): - other_date = getattr(self, f"_{key}_time") + time = getattr(self, f"_{key}_time") + dt = datetime.datetime.combine(datetime.date.today(), time) + tz = self.hass.config.time_zone + utc_time = tz.localize(dt).astimezone(dt_util.UTC) return date.replace( - hour=other_date.hour, - minute=other_date.minute, - second=other_date.second, - microsecond=other_date.microsecond, + hour=utc_time.hour, + minute=utc_time.minute, + second=utc_time.second, + microsecond=utc_time.microsecond, ) location = get_astral_location(self.hass) From 083681953ffb0094b48746348a85b8cb3b5e2f1c Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 28 Sep 2020 18:12:40 +0200 Subject: [PATCH 020/165] add prefer_rgb_color --- .../components/adaptive_lighting/const.py | 7 +++++++ .../components/adaptive_lighting/strings.json | 2 ++ .../components/adaptive_lighting/switch.py | 19 +++++++++++++------ .../adaptive_lighting/translations/en.json | 2 ++ 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index f49188abd28045..cdf20d1abd9439 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -20,6 +20,10 @@ "disable_color_temp_adjust", False, ) +CONF_DISABLE_RGB_COLOR_ADJUST, DEFAULT_DISABLE_RGB_COLOR_ADJUST = ( + "disable_rgb_color_adjust", + False, +) CONF_DISABLE_ENTITY = "disable_entity" CONF_DISABLE_STATE = "disable_state" CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION = "initial_transition", 1 @@ -29,6 +33,7 @@ CONF_MIN_BRIGHTNESS, DEFAULT_MIN_BRIGHTNESS = "min_brightness", 1 CONF_MIN_COLOR_TEMP, DEFAULT_MIN_COLOR_TEMP = "min_color_temp", 2500 CONF_ONLY_ONCE, DEFAULT_ONLY_ONCE = "only_once", False +CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR = "prefer_rgb_color", False CONF_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_BRIGHTNESS = "sleep_brightness", 1 CONF_SLEEP_COLOR_TEMP, DEFAULT_SLEEP_COLOR_TEMP = "sleep_color_temp", 1000 CONF_SLEEP_ENTITY = "sleep_entity" @@ -60,6 +65,7 @@ def int_between(min_int, max_int): (CONF_DISABLE_BRIGHTNESS_ADJUST, DEFAULT_DISABLE_BRIGHTNESS_ADJUST, bool), (CONF_DISABLE_COLOR_TEMP_ADJUST, DEFAULT_DISABLE_COLOR_TEMP_ADJUST, bool), (CONF_DISABLE_ENTITY, NONE_STR, cv.entity_id), + (CONF_DISABLE_RGB_COLOR_ADJUST, DEFAULT_DISABLE_RGB_COLOR_ADJUST, bool), (CONF_DISABLE_STATE, NONE_STR, str), (CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION), (CONF_INTERVAL, DEFAULT_INTERVAL, cv.positive_int), @@ -68,6 +74,7 @@ def int_between(min_int, max_int): (CONF_MIN_BRIGHTNESS, DEFAULT_MIN_BRIGHTNESS, int_between(1, 100)), (CONF_MIN_COLOR_TEMP, DEFAULT_MIN_COLOR_TEMP, int_between(1000, 10000)), (CONF_ONLY_ONCE, DEFAULT_ONLY_ONCE, bool), + (CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR, bool), (CONF_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_BRIGHTNESS, int_between(1, 100)), (CONF_SLEEP_COLOR_TEMP, DEFAULT_SLEEP_COLOR_TEMP, int_between(1000, 10000)), (CONF_SLEEP_ENTITY, NONE_STR, cv.entity_id), diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index d63dae61ce2942..45a0396a873560 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -24,6 +24,7 @@ "disable_brightness_adjust": "disable_brightness_adjust", "disable_color_temp_adjust": "disable_color_temp_adjust", "disable_entity": "disable_entity", + "disable_rgb_color_adjust": "disable_rgb_color_adjust", "disable_state": "disable_state", "initial_transition": "initial_transition, the transition of the lights when turning them on or when 'disable_state' or 'sleep_state' change", "interval": "interval", @@ -32,6 +33,7 @@ "min_brightness": "min_brightness", "min_color_temp": "min_color_temp", "only_once": "only_once", + "prefer_rgb_color": "prefer_rgb_color", "sleep_brightness": "sleep_brightness", "sleep_color_temp": "sleep_color_temp", "sleep_entity": "sleep_entity", diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 0b2cef04ced0bf..50aa805b3e2211 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -63,6 +63,7 @@ CONF_DISABLE_BRIGHTNESS_ADJUST, CONF_DISABLE_COLOR_TEMP_ADJUST, CONF_DISABLE_ENTITY, + CONF_DISABLE_RGB_COLOR_ADJUST, CONF_DISABLE_STATE, CONF_INITIAL_TRANSITION, CONF_INTERVAL, @@ -73,6 +74,7 @@ CONF_MIN_COLOR_TEMP, CONF_ON_LIGHTS_ONLY, CONF_ONLY_ONCE, + CONF_PREFER_RGB_COLOR, CONF_SLEEP_BRIGHTNESS, CONF_SLEEP_COLOR_TEMP, CONF_SLEEP_ENTITY, @@ -183,6 +185,7 @@ def __init__(self, hass, config_entry, turn_on_off_listener): self._name = data[CONF_NAME] self._lights = data[CONF_LIGHTS] self._disable_brightness_adjust = data[CONF_DISABLE_BRIGHTNESS_ADJUST] + self._disable_rgb_color_adjust = data[CONF_DISABLE_RGB_COLOR_ADJUST] self._disable_color_temp_adjust = data[CONF_DISABLE_COLOR_TEMP_ADJUST] self._disable_entity = data[CONF_DISABLE_ENTITY] self._disable_state = data[CONF_DISABLE_STATE] @@ -193,6 +196,7 @@ def __init__(self, hass, config_entry, turn_on_off_listener): self._min_brightness = data[CONF_MIN_BRIGHTNESS] self._min_color_temp = data[CONF_MIN_COLOR_TEMP] self._only_once = data[CONF_ONLY_ONCE] + self._prefer_rgb_color = data[CONF_PREFER_RGB_COLOR] self._sleep_brightness = data[CONF_SLEEP_BRIGHTNESS] self._sleep_color_temp = data[CONF_SLEEP_COLOR_TEMP] self._sleep_entity = data[CONF_SLEEP_ENTITY] @@ -441,8 +445,6 @@ def _calc_color_temp_kelvin(self): return self._min_color_temp def _calc_brightness(self) -> float: - if self._disable_brightness_adjust: - return if self._is_sleep(): return self._sleep_brightness if self._percent > 0: @@ -467,18 +469,23 @@ async def _adjust_light(self, light, transition, colors_only=False): service_data[ATTR_TRANSITION] = transition if ( - self._brightness is not None - and "brightness" in features + "brightness" in features + and not self._disable_brightness_adjust and not colors_only ): service_data[ATTR_BRIGHTNESS_PCT] = self._brightness - if "color_temp" in features and not self._disable_color_temp_adjust: + prefer_rgb_color = self._prefer_rgb_color + if ( + "color_temp" in features + and not self._disable_color_temp_adjust + and not (prefer_rgb_color and "color" in features) + ): attributes = self.hass.states.get(light).attributes min_mireds, max_mireds = attributes["min_mireds"], attributes["max_mireds"] color_temp_mired = max(min(self._color_temp_mired, max_mireds), min_mireds) service_data[ATTR_COLOR_TEMP] = color_temp_mired - elif "color" in features: + elif "color" in features and not self._disable_rgb_color_adjust: service_data[ATTR_RGB_COLOR] = self._rgb_color _LOGGER.debug( diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index d63dae61ce2942..45a0396a873560 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -24,6 +24,7 @@ "disable_brightness_adjust": "disable_brightness_adjust", "disable_color_temp_adjust": "disable_color_temp_adjust", "disable_entity": "disable_entity", + "disable_rgb_color_adjust": "disable_rgb_color_adjust", "disable_state": "disable_state", "initial_transition": "initial_transition, the transition of the lights when turning them on or when 'disable_state' or 'sleep_state' change", "interval": "interval", @@ -32,6 +33,7 @@ "min_brightness": "min_brightness", "min_color_temp": "min_color_temp", "only_once": "only_once", + "prefer_rgb_color": "prefer_rgb_color", "sleep_brightness": "sleep_brightness", "sleep_color_temp": "sleep_color_temp", "sleep_entity": "sleep_entity", From 77eef656ca69837c163494b0d1ea8afa79bd7421 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 28 Sep 2020 20:45:00 +0200 Subject: [PATCH 021/165] cancel sleep task when turn_on is called --- .../components/adaptive_lighting/switch.py | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 50aa805b3e2211..e18542759fa309 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -505,6 +505,9 @@ def _should_adjust(self): async def _adjust_lights(self, lights, transition): if not self._should_adjust(): return + _LOGGER.debug( + "%s: '_adjust_lights(%s, %s)' called", self.name, lights, transition + ) tasks = [ await self._adjust_light(light, transition) for light in lights @@ -526,7 +529,6 @@ async def _state_changed(self, entity_id, from_state, to_state): await self._update_lights(transition=self._initial_transition, force=True) async def _light_event(self, event): - old_state = event.data.get("old_state") new_state = event.data.get("new_state") entity_id = event.data.get("entity_id") @@ -579,6 +581,8 @@ def __init__(self, hass): # Tracks 'light.turn_on' service calls self.turn_on_event: Dict[str, Tuple[str]] = {} + self.sleep_tasks: Dict[str, asyncio.Task] = {} + self.hass.bus.async_listen(EVENT_CALL_SERVICE, self.turn_on_off_event_listener) async def maybe_cancel_adjusting( @@ -637,7 +641,17 @@ async def maybe_cancel_adjusting( for _ in range(3): # It can happen that the actual transition time is longer than the # specified time in the 'turn_off' service. - await asyncio.sleep(delay) + coro = asyncio.sleep(delay) + task = self.sleep_tasks[entity_id] = asyncio.ensure_future(coro) + try: + await task + except asyncio.CancelledError: # 'light.turn_on' has been called + _LOGGER.debug( + "Sleep task is cancelled due to 'light.turn_on('%s')' call", + entity_id, + ) + return False + if not is_on(self.hass, entity_id): return True delay = TURNING_OFF_DELAY # next time only wait this long @@ -659,24 +673,27 @@ async def turn_on_off_event_listener(self, event): service = event.data.get(ATTR_SERVICE) service_data = event.data.get(ATTR_SERVICE_DATA, {}) - entity_id = service_data.get(ATTR_ENTITY_ID) - if isinstance(entity_id, str): - entity_id = [entity_id] + entity_ids = service_data.get(ATTR_ENTITY_ID) + if isinstance(entity_ids, str): + entity_ids = [entity_ids] - if not any(eid in self.lights for eid in entity_id): + if not any(eid in self.lights for eid in entity_ids): return if service == SERVICE_TURN_OFF: transition = service_data.get(ATTR_TRANSITION) _LOGGER.debug( "Detected an 'light.turn_off('%s', transition=%s)' event", - entity_id, + entity_ids, transition, ) - for eid in entity_id: + for eid in entity_ids: self.turn_off_event[eid] = (event.context.id, transition) elif service == SERVICE_TURN_ON: - _LOGGER.debug("Detected an 'light.turn_on('%s')' event", entity_id) - for eid in entity_id: + _LOGGER.debug("Detected an 'light.turn_on('%s')' event", entity_ids) + for eid in entity_ids: + task = self.sleep_tasks.get(eid) + if task is not None: + task.cancel() self.turn_on_event[eid] = event.context.id From 5c62069025ee52a7438874775470e67ecb04a317 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 28 Sep 2020 21:06:23 +0200 Subject: [PATCH 022/165] fix issue where lights weren't updated when toggling the switch --- homeassistant/components/adaptive_lighting/switch.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index e18542759fa309..143911ce5fa752 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -271,7 +271,7 @@ async def async_added_to_hass(self): ) last_state = await self.async_get_last_state() if last_state and last_state.state == STATE_ON: - await self.async_turn_on() + await self.async_turn_on(adjust_lights=False) def _unpack_light_groups(self) -> None: all_lights = [] @@ -325,12 +325,13 @@ def device_state_attributes(self): return {key: None for key in attrs} return attrs - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, adjust_lights=True): """Turn on adaptive lighting.""" - await self._update_lights(transition=self._initial_transition, force=True) self.unsub_tracker = async_track_time_interval( self.hass, self._async_update_at_interval, self._interval ) + if adjust_lights: + await self._update_lights(transition=self._initial_transition, force=True) async def async_turn_off(self, **kwargs): """Turn off adaptive lighting.""" From 378a774e4e6f1d67b68838e1be7fed142bc1d0a3 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 28 Sep 2020 21:12:55 +0200 Subject: [PATCH 023/165] do not return but call self._abort_if_unique_id_configured --- homeassistant/components/adaptive_lighting/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/config_flow.py b/homeassistant/components/adaptive_lighting/config_flow.py index 543363a24d55b1..2b2de991588a39 100644 --- a/homeassistant/components/adaptive_lighting/config_flow.py +++ b/homeassistant/components/adaptive_lighting/config_flow.py @@ -48,7 +48,7 @@ async def async_step_import(self, user_input=None): self.hass.config_entries.async_update_entry( entry, data=dict(entry.data, **user_input) ) - return + self._abort_if_unique_id_configured() return self.async_create_entry(title=user_input["name"], data=user_input) @staticmethod From d8453ace05d8243767d4d2d493aad8a9ce050319 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 28 Sep 2020 21:15:36 +0200 Subject: [PATCH 024/165] rename variables (sorry pylint) --- homeassistant/components/adaptive_lighting/switch.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 143911ce5fa752..efd19bf38f3d49 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -366,9 +366,9 @@ async def _update_lights(self, lights=None, transition=None, force=False): def _get_sun_events(self, date): def _replace_time(date, key): time = getattr(self, f"_{key}_time") - dt = datetime.datetime.combine(datetime.date.today(), time) - tz = self.hass.config.time_zone - utc_time = tz.localize(dt).astimezone(dt_util.UTC) + date_time = datetime.datetime.combine(datetime.date.today(), time) + time_zone = self.hass.config.time_zone + utc_time = time_zone.localize(date_time).astimezone(dt_util.UTC) return date.replace( hour=utc_time.hour, minute=utc_time.minute, From 766a1b4f3ecd3e554742670172559af8ffe271e8 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 28 Sep 2020 23:07:01 +0200 Subject: [PATCH 025/165] styling --- homeassistant/components/adaptive_lighting/switch.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index efd19bf38f3d49..03fc6b585cecb4 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -185,9 +185,9 @@ def __init__(self, hass, config_entry, turn_on_off_listener): self._name = data[CONF_NAME] self._lights = data[CONF_LIGHTS] self._disable_brightness_adjust = data[CONF_DISABLE_BRIGHTNESS_ADJUST] - self._disable_rgb_color_adjust = data[CONF_DISABLE_RGB_COLOR_ADJUST] self._disable_color_temp_adjust = data[CONF_DISABLE_COLOR_TEMP_ADJUST] self._disable_entity = data[CONF_DISABLE_ENTITY] + self._disable_rgb_color_adjust = data[CONF_DISABLE_RGB_COLOR_ADJUST] self._disable_state = data[CONF_DISABLE_STATE] self._initial_transition = data[CONF_INITIAL_TRANSITION] self._interval = data[CONF_INTERVAL] @@ -476,11 +476,10 @@ async def _adjust_light(self, light, transition, colors_only=False): ): service_data[ATTR_BRIGHTNESS_PCT] = self._brightness - prefer_rgb_color = self._prefer_rgb_color if ( "color_temp" in features and not self._disable_color_temp_adjust - and not (prefer_rgb_color and "color" in features) + and not (self._prefer_rgb_color and "color" in features) ): attributes = self.hass.states.get(light).attributes min_mireds, max_mireds = attributes["min_mireds"], attributes["max_mireds"] From c02faf9274fc06aa30d26e1123b61cf19824b993 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 28 Sep 2020 23:38:19 +0200 Subject: [PATCH 026/165] sub/unsub trackers when loading/unloading and turning on/off --- .../components/adaptive_lighting/__init__.py | 14 +++-- .../components/adaptive_lighting/switch.py | 62 ++++++++++++------- 2 files changed, 50 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/__init__.py b/homeassistant/components/adaptive_lighting/__init__.py index 1e935df7415a6c..5c0aa0807b1039 100644 --- a/homeassistant/components/adaptive_lighting/__init__.py +++ b/homeassistant/components/adaptive_lighting/__init__.py @@ -29,6 +29,7 @@ import voluptuous as vol +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry import homeassistant.helpers.config_validation as cv @@ -68,10 +69,10 @@ async def async_setup(hass, config): async def async_setup_entry(hass, config_entry: ConfigEntry): """Set up the component.""" - hass.data.setdefault(DOMAIN, {}) + data = hass.data.setdefault(DOMAIN, {}) undo_listener = config_entry.add_update_listener(async_update_options) - hass.data[DOMAIN][config_entry.entry_id] = {UNDO_UPDATE_LISTENER: undo_listener} + data[config_entry.entry_id] = {UNDO_UPDATE_LISTENER: undo_listener} for platform in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(config_entry, platform) @@ -95,9 +96,14 @@ async def async_unload_entry(hass, config_entry: ConfigEntry) -> bool: ] ) ) - hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() + data = hass.data[DOMAIN] + data[config_entry.entry_id][UNDO_UPDATE_LISTENER]() + switch = data[config_entry.entry_id][SWITCH_DOMAIN] + while switch.unsub_trackers: + unsub = switch.unsub_trackers.pop() + unsub() if unload_ok: - hass.data[DOMAIN].pop(config_entry.entry_id) + data.pop(config_entry.entry_id) return unload_ok diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 03fc6b585cecb4..482780be34f5e9 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -23,7 +23,7 @@ VALID_TRANSITION, is_on, ) -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity from homeassistant.const import ( ATTR_DOMAIN, ATTR_ENTITY_ID, @@ -130,16 +130,14 @@ async def handle_apply(switch, service_call): async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the AdaptiveLighting switch.""" - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} + data = hass.data[DOMAIN] - if ATTR_TURN_ON_OFF_LISTENER not in hass.data[DOMAIN]: - hass.data[DOMAIN][ATTR_TURN_ON_OFF_LISTENER] = TurnOnOffListener(hass) + if ATTR_TURN_ON_OFF_LISTENER not in data: + data[ATTR_TURN_ON_OFF_LISTENER] = TurnOnOffListener(hass) + turn_on_off_listener = data[ATTR_TURN_ON_OFF_LISTENER] - turn_on_off_listener = hass.data[DOMAIN][ATTR_TURN_ON_OFF_LISTENER] switch = AdaptiveSwitch(hass, config_entry, turn_on_off_listener) - name = config_entry.data[CONF_NAME] - hass.data[DOMAIN][name] = switch + data[config_entry.entry_id][SWITCH_DOMAIN] = switch # Register `apply` service platform = entity_platform.current_platform.get() @@ -226,7 +224,7 @@ def __init__(self, hass, config_entry, turn_on_off_listener): self._hs_color = None # Set and unset tracker in async_turn_on and async_turn_off - self.unsub_tracker = None + self.unsub_trackers = [] _LOGGER.debug( "%s: Setting up with '%s'," " config_entry.data: '%s'," @@ -251,7 +249,7 @@ def name(self): @property def is_on(self): """Return true if adaptive lighting is on.""" - return self.unsub_tracker is not None + return bool(self.unsub_trackers) def _supported_features(self, light): state = self.hass.states.get(light) @@ -271,7 +269,7 @@ async def async_added_to_hass(self): ) last_state = await self.async_get_last_state() if last_state and last_state.state == STATE_ON: - await self.async_turn_on(adjust_lights=False) + await self.async_turn_on(adjust_lights=False, setup_listeners=False) def _unpack_light_groups(self) -> None: all_lights = [] @@ -292,17 +290,29 @@ async def _setup_listeners(self, _=None): self._unpack_light_groups() for light in self._lights: self.turn_on_off_listener.lights.add(light) - async_track_state_change_event(self.hass, self._lights, self._light_event) + self.unsub_trackers.append( + async_track_state_change_event(self.hass, self._lights, self._light_event) + ) track_kwargs = dict(hass=self.hass, action=self._state_changed) if self._sleep_entity is not None: sleep_kwargs = dict(track_kwargs, entity_ids=self._sleep_entity) - async_track_state_change(**sleep_kwargs, to_state=self._sleep_state) - async_track_state_change(**sleep_kwargs, from_state=self._sleep_state) + self.unsub_trackers.append( + async_track_state_change(**sleep_kwargs, to_state=self._sleep_state) + ) + self.unsub_trackers.append( + async_track_state_change(**sleep_kwargs, from_state=self._sleep_state) + ) if self._disable_entity is not None: disable_kwargs = dict(track_kwargs, entity_ids=self._disable_entity) - async_track_state_change(**disable_kwargs, from_state=self._disable_state) - async_track_state_change(**disable_kwargs, to_state=self._disable_state) + self.unsub_trackers.append( + async_track_state_change( + **disable_kwargs, from_state=self._disable_state + ) + ) + self.unsub_trackers.append( + async_track_state_change(**disable_kwargs, to_state=self._disable_state) + ) @property def icon(self): @@ -325,19 +335,27 @@ def device_state_attributes(self): return {key: None for key in attrs} return attrs - async def async_turn_on(self, adjust_lights=True): + async def async_turn_on(self, adjust_lights=True, setup_listeners=True): """Turn on adaptive lighting.""" - self.unsub_tracker = async_track_time_interval( - self.hass, self._async_update_at_interval, self._interval + if self.is_on: + return + self.unsub_trackers.append( + async_track_time_interval( + self.hass, self._async_update_at_interval, self._interval + ) ) + if setup_listeners: + self._setup_listeners() if adjust_lights: await self._update_lights(transition=self._initial_transition, force=True) async def async_turn_off(self, **kwargs): """Turn off adaptive lighting.""" - if self.is_on: - self.unsub_tracker() - self.unsub_tracker = None + if not self.is_on: + return + while self.unsub_trackers: + unsub = self.unsub_trackers.pop() + unsub() async def _update_attrs(self): """Update Adaptive Values.""" From ad61f35908d652bf5c310d1875fa9468f9b2866f Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 29 Sep 2020 10:26:56 +0200 Subject: [PATCH 027/165] fix turning on and off, delay setup listeners until HA start --- .../components/adaptive_lighting/switch.py | 86 +++++++++++-------- 1 file changed, 49 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 482780be34f5e9..8ea4586e6de584 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -38,7 +38,7 @@ SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, ) -from homeassistant.core import Event +from homeassistant.core import Context, Event from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import ( @@ -208,6 +208,7 @@ def __init__(self, hass, config_entry, turn_on_off_listener): # Set other attributes self._icon = ICON self._entity_id = f"switch.{DOMAIN}_{slugify(self._name)}" + self._state = None # Tracks 'off' → 'on' state changes self._on_to_off_event: Dict[str, Event] = {} @@ -249,7 +250,7 @@ def name(self): @property def is_on(self): """Return true if adaptive lighting is on.""" - return bool(self.unsub_trackers) + return self._state def _supported_features(self, light): state = self.hass.states.get(light) @@ -269,50 +270,56 @@ async def async_added_to_hass(self): ) last_state = await self.async_get_last_state() if last_state and last_state.state == STATE_ON: - await self.async_turn_on(adjust_lights=False, setup_listeners=False) + self._state = True + await self.async_turn_on( + adjust_lights=not self._only_once, + setup_listeners=False, + ) + else: + self._state = False def _unpack_light_groups(self) -> None: - all_lights = [] + all_lights = set() for light in self._lights: state = self.hass.states.get(light) if state is None: _LOGGER.debug("%s: State of %s is None", self._name, light) - all_lights.append(light) + all_lights.add(light) elif "entity_id" in state.attributes: # it's a light group group = state.attributes["entity_id"] - all_lights.extend(group) + all_lights.update(group) _LOGGER.debug("%s: Unpacked %s to %s", self._name, light, group) else: - all_lights.append(light) - self._lights = all_lights + all_lights.add(light) + self.turn_on_off_listener.lights.update(all_lights) + self._lights = list(all_lights) async def _setup_listeners(self, _=None): + if self.unsub_trackers: + _LOGGER.error( + "%s: Calling '_setup_listeners' when they are already set up", self.name + ) + return + self._unpack_light_groups() - for light in self._lights: - self.turn_on_off_listener.lights.add(light) - self.unsub_trackers.append( - async_track_state_change_event(self.hass, self._lights, self._light_event) + rm_interval = async_track_time_interval( + self.hass, self._async_update_at_interval, self._interval + ) + rm_state = async_track_state_change_event( + self.hass, self._lights, self._light_event ) + self.unsub_trackers.extend([rm_interval, rm_state]) track_kwargs = dict(hass=self.hass, action=self._state_changed) if self._sleep_entity is not None: - sleep_kwargs = dict(track_kwargs, entity_ids=self._sleep_entity) - self.unsub_trackers.append( - async_track_state_change(**sleep_kwargs, to_state=self._sleep_state) - ) - self.unsub_trackers.append( - async_track_state_change(**sleep_kwargs, from_state=self._sleep_state) - ) - + kwgs = dict(track_kwargs, entity_ids=self._sleep_entity) + rm_from = async_track_state_change(**kwgs, from_state=self._sleep_state) + rm_to = async_track_state_change(**kwgs, to_state=self._sleep_state) + self.unsub_trackers.extend([rm_from, rm_to]) if self._disable_entity is not None: - disable_kwargs = dict(track_kwargs, entity_ids=self._disable_entity) - self.unsub_trackers.append( - async_track_state_change( - **disable_kwargs, from_state=self._disable_state - ) - ) - self.unsub_trackers.append( - async_track_state_change(**disable_kwargs, to_state=self._disable_state) - ) + kwgs = dict(track_kwargs, entity_ids=self._disable_entity) + rm_from = async_track_state_change(**kwgs, from_state=self._disable_state) + rm_to = async_track_state_change(**kwgs, to_state=self._disable_state) + self.unsub_trackers.extend([rm_from, rm_to]) @property def icon(self): @@ -335,15 +342,13 @@ def device_state_attributes(self): return {key: None for key in attrs} return attrs - async def async_turn_on(self, adjust_lights=True, setup_listeners=True): + async def async_turn_on( + self, adjust_lights=True, setup_listeners=True + ): # pylint: disable=arguments-differ """Turn on adaptive lighting.""" if self.is_on: return - self.unsub_trackers.append( - async_track_time_interval( - self.hass, self._async_update_at_interval, self._interval - ) - ) + self._state = True if setup_listeners: self._setup_listeners() if adjust_lights: @@ -353,6 +358,7 @@ async def async_turn_off(self, **kwargs): """Turn off adaptive lighting.""" if not self.is_on: return + self._state = False while self.unsub_trackers: unsub = self.unsub_trackers.pop() unsub() @@ -511,8 +517,12 @@ async def _adjust_light(self, light, transition, colors_only=False): self._name, service_data, ) + return self.hass.services.async_call( - LIGHT_DOMAIN, SERVICE_TURN_ON, service_data + LIGHT_DOMAIN, + SERVICE_TURN_ON, + service_data, + context=Context(), ) def _should_adjust(self): @@ -559,7 +569,9 @@ async def _light_event(self, event): _LOGGER.debug( "%s: Detected an 'off' → 'on' event for '%s'", self._name, entity_id ) - lock = self._locks.setdefault(entity_id, asyncio.Lock()) + lock = self._locks.get(entity_id) + if lock is None: + lock = asyncio.Lock() async with lock: if await self.turn_on_off_listener.maybe_cancel_adjusting( entity_id, From e02fb0ed08d2f4b9d1f95d3c89bc8e0f72d12ad0 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 29 Sep 2020 10:48:07 +0200 Subject: [PATCH 028/165] define and use switch._unsub_trackers --- .../components/adaptive_lighting/__init__.py | 4 +-- .../components/adaptive_lighting/switch.py | 25 +++++++++---------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/__init__.py b/homeassistant/components/adaptive_lighting/__init__.py index 5c0aa0807b1039..e8257a422be096 100644 --- a/homeassistant/components/adaptive_lighting/__init__.py +++ b/homeassistant/components/adaptive_lighting/__init__.py @@ -99,9 +99,7 @@ async def async_unload_entry(hass, config_entry: ConfigEntry) -> bool: data = hass.data[DOMAIN] data[config_entry.entry_id][UNDO_UPDATE_LISTENER]() switch = data[config_entry.entry_id][SWITCH_DOMAIN] - while switch.unsub_trackers: - unsub = switch.unsub_trackers.pop() - unsub() + switch._unsub_trackers() # pylint: disable=protected-access if unload_ok: data.pop(config_entry.entry_id) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 8ea4586e6de584..56614c2de5d4ed 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -263,10 +263,10 @@ async def async_added_to_hass(self): """Call when entity about to be added to hass.""" if self._lights: if self.hass.is_running: - await self._setup_listeners() + await self._setup_trackers() else: self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, self._setup_listeners + EVENT_HOMEASSISTANT_START, self._setup_trackers ) last_state = await self.async_get_last_state() if last_state and last_state.state == STATE_ON: @@ -294,13 +294,8 @@ def _unpack_light_groups(self) -> None: self.turn_on_off_listener.lights.update(all_lights) self._lights = list(all_lights) - async def _setup_listeners(self, _=None): - if self.unsub_trackers: - _LOGGER.error( - "%s: Calling '_setup_listeners' when they are already set up", self.name - ) - return - + async def _setup_trackers(self, _=None): + assert not self.unsub_trackers self._unpack_light_groups() rm_interval = async_track_time_interval( self.hass, self._async_update_at_interval, self._interval @@ -321,6 +316,12 @@ async def _setup_listeners(self, _=None): rm_to = async_track_state_change(**kwgs, to_state=self._disable_state) self.unsub_trackers.extend([rm_from, rm_to]) + def _unsub_trackers(self): + assert self.unsub_trackers + while self.unsub_trackers: + unsub = self.unsub_trackers.pop() + unsub() + @property def icon(self): """Icon to use in the frontend, if any.""" @@ -350,7 +351,7 @@ async def async_turn_on( return self._state = True if setup_listeners: - self._setup_listeners() + await self._setup_trackers() if adjust_lights: await self._update_lights(transition=self._initial_transition, force=True) @@ -359,9 +360,7 @@ async def async_turn_off(self, **kwargs): if not self.is_on: return self._state = False - while self.unsub_trackers: - unsub = self.unsub_trackers.pop() - unsub() + self._unsub_trackers() async def _update_attrs(self): """Update Adaptive Values.""" From 4cdf404b3719aa11e2348f65f1fd047c556d8edc Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 29 Sep 2020 13:09:32 +0200 Subject: [PATCH 029/165] do not use old settings when reloading config --- homeassistant/components/adaptive_lighting/config_flow.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/config_flow.py b/homeassistant/components/adaptive_lighting/config_flow.py index 2b2de991588a39..a0c746a06c952c 100644 --- a/homeassistant/components/adaptive_lighting/config_flow.py +++ b/homeassistant/components/adaptive_lighting/config_flow.py @@ -45,9 +45,7 @@ async def async_step_import(self, user_input=None): await self.async_set_unique_id(user_input["name"]) for entry in self._async_current_entries(): if entry.unique_id == self.unique_id: - self.hass.config_entries.async_update_entry( - entry, data=dict(entry.data, **user_input) - ) + self.hass.config_entries.async_update_entry(entry, data=user_input) self._abort_if_unique_id_configured() return self.async_create_entry(title=user_input["name"], data=user_input) From 5feb96e4837ebc3ea3ce76731cee5f111c980abf Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 29 Sep 2020 13:10:44 +0200 Subject: [PATCH 030/165] simplify call to hass.config_entries.async_forward_entry_unload --- homeassistant/components/adaptive_lighting/__init__.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/__init__.py b/homeassistant/components/adaptive_lighting/__init__.py index e8257a422be096..6b5f6c7dc914d4 100644 --- a/homeassistant/components/adaptive_lighting/__init__.py +++ b/homeassistant/components/adaptive_lighting/__init__.py @@ -24,7 +24,6 @@ * The integration does not calculate a true "Blue Hour" -- it just sets the lights to 2700K (warm white) until your hub goes into "Sleep mode". """ -import asyncio import logging import voluptuous as vol @@ -88,13 +87,8 @@ async def async_update_options(hass, config_entry: ConfigEntry): async def async_unload_entry(hass, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = all( - await asyncio.gather( - *[ - hass.config_entries.async_forward_entry_unload(config_entry, platform) - for platform in PLATFORMS - ] - ) + unload_ok = await hass.config_entries.async_forward_entry_unload( + config_entry, "switch" ) data = hass.data[DOMAIN] data[config_entry.entry_id][UNDO_UPDATE_LISTENER]() From 1403b66ea24ba7080ab680818053b7348a563f39 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 29 Sep 2020 13:12:34 +0200 Subject: [PATCH 031/165] remove assert because it's possible that trackers haven't been set up yet --- homeassistant/components/adaptive_lighting/switch.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 56614c2de5d4ed..47d892ff75a0bd 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -317,7 +317,6 @@ async def _setup_trackers(self, _=None): self.unsub_trackers.extend([rm_from, rm_to]) def _unsub_trackers(self): - assert self.unsub_trackers while self.unsub_trackers: unsub = self.unsub_trackers.pop() unsub() From 80236f98c77700dc071e9100b8c8501d8de15aca Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 29 Sep 2020 14:20:39 +0200 Subject: [PATCH 032/165] actually use the lock --- homeassistant/components/adaptive_lighting/switch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 47d892ff75a0bd..ac592efb114092 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -569,7 +569,7 @@ async def _light_event(self, event): ) lock = self._locks.get(entity_id) if lock is None: - lock = asyncio.Lock() + lock = self._locks[entity_id] = asyncio.Lock() async with lock: if await self.turn_on_off_listener.maybe_cancel_adjusting( entity_id, From ea615e06b3b28d9948129ce888581e3cee8fdd78 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 29 Sep 2020 14:27:17 +0200 Subject: [PATCH 033/165] fix documentation url --- CODEOWNERS | 2 +- homeassistant/components/adaptive_lighting/manifest.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 87675302e1d8e9..be074e5ccbc9ca 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -22,7 +22,7 @@ homeassistant/scripts/check_config.py @kellerza homeassistant/components/abode/* @shred86 homeassistant/components/accuweather/* @bieniu homeassistant/components/acmeda/* @atmurray -homeassistant/components/adaptive_lighting/* @claytonjn @basnijholt +homeassistant/components/adaptive_lighting/* @basnijholt @claytonjn homeassistant/components/adguard/* @frenck homeassistant/components/advantage_air/* @Bre77 homeassistant/components/agent_dvr/* @ispysoftware diff --git a/homeassistant/components/adaptive_lighting/manifest.json b/homeassistant/components/adaptive_lighting/manifest.json index ceae225ddd99b0..dc7304499f1b23 100644 --- a/homeassistant/components/adaptive_lighting/manifest.json +++ b/homeassistant/components/adaptive_lighting/manifest.json @@ -1,9 +1,9 @@ { "domain": "adaptive_lighting", "name": "Adaptive Lighting", - "documentation": "https://github.com/basnijholt/adaptive_lighting", + "documentation": "https://www.home-assistant.io/integrations/adaptive_lighting", "config_flow": true, "dependencies": [], - "codeowners": ["@claytonjn", "@basnijholt"], + "codeowners": ["@basnijholt", "@claytonjn"], "requirements": [] } From 9a7e1029c9255fa33662f5882497af13e9bd14d5 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 13 Oct 2020 23:33:25 +0200 Subject: [PATCH 034/165] udpate manifest.json --- CODEOWNERS | 2 +- homeassistant/components/adaptive_lighting/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index be074e5ccbc9ca..4b4fc9a3e34a46 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -22,7 +22,7 @@ homeassistant/scripts/check_config.py @kellerza homeassistant/components/abode/* @shred86 homeassistant/components/accuweather/* @bieniu homeassistant/components/acmeda/* @atmurray -homeassistant/components/adaptive_lighting/* @basnijholt @claytonjn +homeassistant/components/adaptive_lighting/* @basnijholt homeassistant/components/adguard/* @frenck homeassistant/components/advantage_air/* @Bre77 homeassistant/components/agent_dvr/* @ispysoftware diff --git a/homeassistant/components/adaptive_lighting/manifest.json b/homeassistant/components/adaptive_lighting/manifest.json index dc7304499f1b23..1346158421d90f 100644 --- a/homeassistant/components/adaptive_lighting/manifest.json +++ b/homeassistant/components/adaptive_lighting/manifest.json @@ -4,6 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/adaptive_lighting", "config_flow": true, "dependencies": [], - "codeowners": ["@basnijholt", "@claytonjn"], + "codeowners": ["@basnijholt"], "requirements": [] } From 227d3c188f16d9294227ae4f2a965a30310cf675 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 29 Sep 2020 23:06:17 +0200 Subject: [PATCH 035/165] rename to adjust_brightness, adjust_color_temp, adjust_rgb_color Thanks @mountainsandcode --- .../components/adaptive_lighting/const.py | 21 +++++------------- .../components/adaptive_lighting/strings.json | 6 ++--- .../components/adaptive_lighting/switch.py | 22 ++++++++----------- .../adaptive_lighting/translations/en.json | 6 ++--- 4 files changed, 21 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index cdf20d1abd9439..64c384298cc4cd 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -12,18 +12,9 @@ CONF_NAME, DEFAULT_NAME = "name", "default" CONF_LIGHTS, DEFAULT_LIGHTS = "lights", [] -CONF_DISABLE_BRIGHTNESS_ADJUST, DEFAULT_DISABLE_BRIGHTNESS_ADJUST = ( - "disable_brightness_adjust", - False, -) -CONF_DISABLE_COLOR_TEMP_ADJUST, DEFAULT_DISABLE_COLOR_TEMP_ADJUST = ( - "disable_color_temp_adjust", - False, -) -CONF_DISABLE_RGB_COLOR_ADJUST, DEFAULT_DISABLE_RGB_COLOR_ADJUST = ( - "disable_rgb_color_adjust", - False, -) +CONF_ADJUST_BRIGHTNESS, DEFAULT_ADJUST_BRIGHTNESS = "adjust_brightness", True +CONF_ADJUST_COLOR_TEMP, DEFAULT_ADJUST_COLOR_TEMP = "adjust_color_temp", True +CONF_ADJUST_RGB_COLOR, DEFAULT_ADJUST_RGB_COLOR = "adjust_rgb_color", True CONF_DISABLE_ENTITY = "disable_entity" CONF_DISABLE_STATE = "disable_state" CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION = "initial_transition", 1 @@ -62,10 +53,10 @@ def int_between(min_int, max_int): VALIDATION_TUPLES = [ (CONF_LIGHTS, DEFAULT_LIGHTS, cv.entity_ids), - (CONF_DISABLE_BRIGHTNESS_ADJUST, DEFAULT_DISABLE_BRIGHTNESS_ADJUST, bool), - (CONF_DISABLE_COLOR_TEMP_ADJUST, DEFAULT_DISABLE_COLOR_TEMP_ADJUST, bool), + (CONF_ADJUST_BRIGHTNESS, DEFAULT_ADJUST_BRIGHTNESS, bool), + (CONF_ADJUST_COLOR_TEMP, DEFAULT_ADJUST_COLOR_TEMP, bool), + (CONF_ADJUST_RGB_COLOR, DEFAULT_ADJUST_RGB_COLOR, bool), (CONF_DISABLE_ENTITY, NONE_STR, cv.entity_id), - (CONF_DISABLE_RGB_COLOR_ADJUST, DEFAULT_DISABLE_RGB_COLOR_ADJUST, bool), (CONF_DISABLE_STATE, NONE_STR, str), (CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION), (CONF_INTERVAL, DEFAULT_INTERVAL, cv.positive_int), diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index 45a0396a873560..f2ba323e013af7 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -21,10 +21,10 @@ "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", - "disable_brightness_adjust": "disable_brightness_adjust", - "disable_color_temp_adjust": "disable_color_temp_adjust", + "adjust_brightness": "adjust_brightness", + "adjust_color_temp": "adjust_color_temp", + "adjust_rgb_color": "adjust_rgb_color", "disable_entity": "disable_entity", - "disable_rgb_color_adjust": "disable_rgb_color_adjust", "disable_state": "disable_state", "initial_transition": "initial_transition, the transition of the lights when turning them on or when 'disable_state' or 'sleep_state' change", "interval": "interval", diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index ac592efb114092..ec5f3b1bdf54d8 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -59,11 +59,11 @@ from .const import ( ATTR_TURN_ON_OFF_LISTENER, + CONF_ADJUST_BRIGHTNESS, + CONF_ADJUST_COLOR_TEMP, + CONF_ADJUST_RGB_COLOR, CONF_COLORS_ONLY, - CONF_DISABLE_BRIGHTNESS_ADJUST, - CONF_DISABLE_COLOR_TEMP_ADJUST, CONF_DISABLE_ENTITY, - CONF_DISABLE_RGB_COLOR_ADJUST, CONF_DISABLE_STATE, CONF_INITIAL_TRANSITION, CONF_INTERVAL, @@ -182,10 +182,10 @@ def __init__(self, hass, config_entry, turn_on_off_listener): data = validate(config_entry) self._name = data[CONF_NAME] self._lights = data[CONF_LIGHTS] - self._disable_brightness_adjust = data[CONF_DISABLE_BRIGHTNESS_ADJUST] - self._disable_color_temp_adjust = data[CONF_DISABLE_COLOR_TEMP_ADJUST] + self._adjust_brightness = data[CONF_ADJUST_BRIGHTNESS] + self._adjust_color_temp = data[CONF_ADJUST_COLOR_TEMP] + self._adjust_rgb_color = data[CONF_ADJUST_RGB_COLOR] self._disable_entity = data[CONF_DISABLE_ENTITY] - self._disable_rgb_color_adjust = data[CONF_DISABLE_RGB_COLOR_ADJUST] self._disable_state = data[CONF_DISABLE_STATE] self._initial_transition = data[CONF_INITIAL_TRANSITION] self._interval = data[CONF_INTERVAL] @@ -491,23 +491,19 @@ async def _adjust_light(self, light, transition, colors_only=False): transition = self._transition service_data[ATTR_TRANSITION] = transition - if ( - "brightness" in features - and not self._disable_brightness_adjust - and not colors_only - ): + if "brightness" in features and self._adjust_brightness and not colors_only: service_data[ATTR_BRIGHTNESS_PCT] = self._brightness if ( "color_temp" in features - and not self._disable_color_temp_adjust + and self._adjust_color_temp and not (self._prefer_rgb_color and "color" in features) ): attributes = self.hass.states.get(light).attributes min_mireds, max_mireds = attributes["min_mireds"], attributes["max_mireds"] color_temp_mired = max(min(self._color_temp_mired, max_mireds), min_mireds) service_data[ATTR_COLOR_TEMP] = color_temp_mired - elif "color" in features and not self._disable_rgb_color_adjust: + elif "color" in features and self._adjust_rgb_color: service_data[ATTR_RGB_COLOR] = self._rgb_color _LOGGER.debug( diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index 45a0396a873560..f2ba323e013af7 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -21,10 +21,10 @@ "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", - "disable_brightness_adjust": "disable_brightness_adjust", - "disable_color_temp_adjust": "disable_color_temp_adjust", + "adjust_brightness": "adjust_brightness", + "adjust_color_temp": "adjust_color_temp", + "adjust_rgb_color": "adjust_rgb_color", "disable_entity": "disable_entity", - "disable_rgb_color_adjust": "disable_rgb_color_adjust", "disable_state": "disable_state", "initial_transition": "initial_transition, the transition of the lights when turning them on or when 'disable_state' or 'sleep_state' change", "interval": "interval", From 43d6b0785df1847f1b0a14300cbd41c3f49ce8c3 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 29 Sep 2020 23:11:27 +0200 Subject: [PATCH 036/165] rename to adapt_brightness, adapt_color_temp, and adapt_rgb_color --- .../components/adaptive_lighting/const.py | 12 +++---- .../components/adaptive_lighting/strings.json | 6 ++-- .../components/adaptive_lighting/switch.py | 36 +++++++++---------- .../adaptive_lighting/translations/en.json | 6 ++-- 4 files changed, 30 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index 64c384298cc4cd..7683752d21a3d1 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -12,9 +12,9 @@ CONF_NAME, DEFAULT_NAME = "name", "default" CONF_LIGHTS, DEFAULT_LIGHTS = "lights", [] -CONF_ADJUST_BRIGHTNESS, DEFAULT_ADJUST_BRIGHTNESS = "adjust_brightness", True -CONF_ADJUST_COLOR_TEMP, DEFAULT_ADJUST_COLOR_TEMP = "adjust_color_temp", True -CONF_ADJUST_RGB_COLOR, DEFAULT_ADJUST_RGB_COLOR = "adjust_rgb_color", True +CONF_ADAPT_BRIGHTNESS, DEFAULT_ADAPT_BRIGHTNESS = "adapt_brightness", True +CONF_ADAPT_COLOR_TEMP, DEFAULT_ADAPT_COLOR_TEMP = "adapt_color_temp", True +CONF_ADAPT_RGB_COLOR, DEFAULT_ADAPT_RGB_COLOR = "adapt_rgb_color", True CONF_DISABLE_ENTITY = "disable_entity" CONF_DISABLE_STATE = "disable_state" CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION = "initial_transition", 1 @@ -53,9 +53,9 @@ def int_between(min_int, max_int): VALIDATION_TUPLES = [ (CONF_LIGHTS, DEFAULT_LIGHTS, cv.entity_ids), - (CONF_ADJUST_BRIGHTNESS, DEFAULT_ADJUST_BRIGHTNESS, bool), - (CONF_ADJUST_COLOR_TEMP, DEFAULT_ADJUST_COLOR_TEMP, bool), - (CONF_ADJUST_RGB_COLOR, DEFAULT_ADJUST_RGB_COLOR, bool), + (CONF_ADAPT_BRIGHTNESS, DEFAULT_ADAPT_BRIGHTNESS, bool), + (CONF_ADAPT_COLOR_TEMP, DEFAULT_ADAPT_COLOR_TEMP, bool), + (CONF_ADAPT_RGB_COLOR, DEFAULT_ADAPT_RGB_COLOR, bool), (CONF_DISABLE_ENTITY, NONE_STR, cv.entity_id), (CONF_DISABLE_STATE, NONE_STR, str), (CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION), diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index f2ba323e013af7..5317c2e5b1ae9b 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -21,9 +21,9 @@ "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", - "adjust_brightness": "adjust_brightness", - "adjust_color_temp": "adjust_color_temp", - "adjust_rgb_color": "adjust_rgb_color", + "adapt_brightness": "adapt_brightness", + "adapt_color_temp": "adapt_color_temp", + "adapt_rgb_color": "adapt_rgb_color", "disable_entity": "disable_entity", "disable_state": "disable_state", "initial_transition": "initial_transition, the transition of the lights when turning them on or when 'disable_state' or 'sleep_state' change", diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index ec5f3b1bdf54d8..6ff79f28b5f8ce 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -59,9 +59,9 @@ from .const import ( ATTR_TURN_ON_OFF_LISTENER, - CONF_ADJUST_BRIGHTNESS, - CONF_ADJUST_COLOR_TEMP, - CONF_ADJUST_RGB_COLOR, + CONF_ADAPT_BRIGHTNESS, + CONF_ADAPT_COLOR_TEMP, + CONF_ADAPT_RGB_COLOR, CONF_COLORS_ONLY, CONF_DISABLE_ENTITY, CONF_DISABLE_STATE, @@ -116,7 +116,7 @@ async def handle_apply(switch, service_call): raise ValueError("Apply can only be called for a AdaptiveSwitch.") data = service_call.data tasks = [ - await switch._adjust_light( # pylint: disable=protected-access + await switch._adapt_light( # pylint: disable=protected-access light, data[CONF_TRANSITION], data[CONF_COLORS_ONLY], @@ -182,9 +182,9 @@ def __init__(self, hass, config_entry, turn_on_off_listener): data = validate(config_entry) self._name = data[CONF_NAME] self._lights = data[CONF_LIGHTS] - self._adjust_brightness = data[CONF_ADJUST_BRIGHTNESS] - self._adjust_color_temp = data[CONF_ADJUST_COLOR_TEMP] - self._adjust_rgb_color = data[CONF_ADJUST_RGB_COLOR] + self._adapt_brightness = data[CONF_ADAPT_BRIGHTNESS] + self._adapt_color_temp = data[CONF_ADAPT_COLOR_TEMP] + self._adapt_rgb_color = data[CONF_ADAPT_RGB_COLOR] self._disable_entity = data[CONF_DISABLE_ENTITY] self._disable_state = data[CONF_DISABLE_STATE] self._initial_transition = data[CONF_INITIAL_TRANSITION] @@ -272,7 +272,7 @@ async def async_added_to_hass(self): if last_state and last_state.state == STATE_ON: self._state = True await self.async_turn_on( - adjust_lights=not self._only_once, + adapt_lights=not self._only_once, setup_listeners=False, ) else: @@ -343,7 +343,7 @@ def device_state_attributes(self): return attrs async def async_turn_on( - self, adjust_lights=True, setup_listeners=True + self, adapt_lights=True, setup_listeners=True ): # pylint: disable=arguments-differ """Turn on adaptive lighting.""" if self.is_on: @@ -351,7 +351,7 @@ async def async_turn_on( self._state = True if setup_listeners: await self._setup_trackers() - if adjust_lights: + if adapt_lights: await self._update_lights(transition=self._initial_transition, force=True) async def async_turn_off(self, **kwargs): @@ -383,7 +383,7 @@ async def _update_lights(self, lights=None, transition=None, force=False): await self._update_attrs() if self._only_once and not force: return - await self._adjust_lights(lights or self._lights, transition) + await self._adapt_lights(lights or self._lights, transition) def _get_sun_events(self, date): def _replace_time(date, key): @@ -482,7 +482,7 @@ def _is_disabled(self): and self.hass.states.get(self._disable_entity).state in self._disable_state ) - async def _adjust_light(self, light, transition, colors_only=False): + async def _adapt_light(self, light, transition, colors_only=False): service_data = {ATTR_ENTITY_ID: light} features = self._supported_features(light) @@ -491,19 +491,19 @@ async def _adjust_light(self, light, transition, colors_only=False): transition = self._transition service_data[ATTR_TRANSITION] = transition - if "brightness" in features and self._adjust_brightness and not colors_only: + if "brightness" in features and self._adapt_brightness and not colors_only: service_data[ATTR_BRIGHTNESS_PCT] = self._brightness if ( "color_temp" in features - and self._adjust_color_temp + and self._adapt_color_temp and not (self._prefer_rgb_color and "color" in features) ): attributes = self.hass.states.get(light).attributes min_mireds, max_mireds = attributes["min_mireds"], attributes["max_mireds"] color_temp_mired = max(min(self._color_temp_mired, max_mireds), min_mireds) service_data[ATTR_COLOR_TEMP] = color_temp_mired - elif "color" in features and self._adjust_rgb_color: + elif "color" in features and self._adapt_rgb_color: service_data[ATTR_RGB_COLOR] = self._rgb_color _LOGGER.debug( @@ -524,14 +524,14 @@ def _should_adjust(self): return False return True - async def _adjust_lights(self, lights, transition): + async def _adapt_lights(self, lights, transition): if not self._should_adjust(): return _LOGGER.debug( - "%s: '_adjust_lights(%s, %s)' called", self.name, lights, transition + "%s: '_adapt_lights(%s, %s)' called", self.name, lights, transition ) tasks = [ - await self._adjust_light(light, transition) + await self._adapt_light(light, transition) for light in lights if is_on(self.hass, light) ] diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index f2ba323e013af7..5317c2e5b1ae9b 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -21,9 +21,9 @@ "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", - "adjust_brightness": "adjust_brightness", - "adjust_color_temp": "adjust_color_temp", - "adjust_rgb_color": "adjust_rgb_color", + "adapt_brightness": "adapt_brightness", + "adapt_color_temp": "adapt_color_temp", + "adapt_rgb_color": "adapt_rgb_color", "disable_entity": "disable_entity", "disable_state": "disable_state", "initial_transition": "initial_transition, the transition of the lights when turning them on or when 'disable_state' or 'sleep_state' change", From 704ad0ad273dd06d61943271b0844f4f7f6f2873 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 29 Sep 2020 23:21:55 +0200 Subject: [PATCH 037/165] rename on_lights_only -> turn_on_lights --- homeassistant/components/adaptive_lighting/const.py | 2 +- homeassistant/components/adaptive_lighting/services.yaml | 4 ++-- homeassistant/components/adaptive_lighting/switch.py | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index 7683752d21a3d1..02c5e8400c8b6b 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -41,7 +41,7 @@ SERVICE_APPLY = "apply" CONF_COLORS_ONLY = "colors_only" -CONF_ON_LIGHTS_ONLY = "on_lights_only" +CONF_TURN_ON_LIGHTS = "turn_on_lights" TURNING_OFF_DELAY = 5 diff --git a/homeassistant/components/adaptive_lighting/services.yaml b/homeassistant/components/adaptive_lighting/services.yaml index e5b2aa18cd13a4..225100de02f70d 100644 --- a/homeassistant/components/adaptive_lighting/services.yaml +++ b/homeassistant/components/adaptive_lighting/services.yaml @@ -13,6 +13,6 @@ apply: colors_only: description: Only change the color of the lights and leave the brightness as is. example: false - on_lights_only: - description: Only adjust the lights that are already on, otherwise turn the lights on. + turn_on_lights: + description: Turn on the lights that are off. example: false diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 6ff79f28b5f8ce..a0807df76fccda 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -72,7 +72,6 @@ CONF_MAX_COLOR_TEMP, CONF_MIN_BRIGHTNESS, CONF_MIN_COLOR_TEMP, - CONF_ON_LIGHTS_ONLY, CONF_ONLY_ONCE, CONF_PREFER_RGB_COLOR, CONF_SLEEP_BRIGHTNESS, @@ -84,6 +83,7 @@ CONF_SUNSET_OFFSET, CONF_SUNSET_TIME, CONF_TRANSITION, + CONF_TURN_ON_LIGHTS, DOMAIN, EXTRA_VALIDATION, ICON, @@ -122,7 +122,7 @@ async def handle_apply(switch, service_call): data[CONF_COLORS_ONLY], ) for light in data[CONF_LIGHTS] - if not data[CONF_ON_LIGHTS_ONLY] or is_on(switch.hass, light) + if data[CONF_TURN_ON_LIGHTS] or is_on(switch.hass, light) ] if tasks: await asyncio.wait(tasks) @@ -150,7 +150,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): default=switch._initial_transition, # pylint: disable=protected-access ): VALID_TRANSITION, vol.Optional(CONF_COLORS_ONLY, default=False): cv.boolean, - vol.Optional(CONF_ON_LIGHTS_ONLY, default=False): cv.boolean, + vol.Optional(CONF_TURN_ON_LIGHTS, default=False): cv.boolean, }, handle_apply, ) From 02697f507507b0a05a1a20f9db6f43ba1a255b4d Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 29 Sep 2020 23:31:20 +0200 Subject: [PATCH 038/165] use adapt_brightness, adapt_color_temp, adapt_rgb_color in adaptive_lighting.apply service --- .../components/adaptive_lighting/const.py | 1 - .../adaptive_lighting/services.yaml | 14 +++++--- .../components/adaptive_lighting/switch.py | 35 ++++++++++++++----- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index 02c5e8400c8b6b..9d8000c335ba12 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -40,7 +40,6 @@ NONE_STR = "None" SERVICE_APPLY = "apply" -CONF_COLORS_ONLY = "colors_only" CONF_TURN_ON_LIGHTS = "turn_on_lights" TURNING_OFF_DELAY = 5 diff --git a/homeassistant/components/adaptive_lighting/services.yaml b/homeassistant/components/adaptive_lighting/services.yaml index 225100de02f70d..dcc5bbfd69227c 100644 --- a/homeassistant/components/adaptive_lighting/services.yaml +++ b/homeassistant/components/adaptive_lighting/services.yaml @@ -10,9 +10,15 @@ apply: transition: description: Transition of the lights. example: 10 - colors_only: - description: Only change the color of the lights and leave the brightness as is. - example: false + adapt_brightness: + description: "Adapt the 'brightness', default: true" + example: true + adapt_color_temp: + description: "Adapt the 'color_temp', default: true" + example: true + adapt_rgb_color: + description: "Adapt the 'rgb_color', default: true" + example: true turn_on_lights: - description: Turn on the lights that are off. + description: "Turn on the lights that are off, default: false" example: false diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index a0807df76fccda..5eb0b270c08983 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -62,7 +62,6 @@ CONF_ADAPT_BRIGHTNESS, CONF_ADAPT_COLOR_TEMP, CONF_ADAPT_RGB_COLOR, - CONF_COLORS_ONLY, CONF_DISABLE_ENTITY, CONF_DISABLE_STATE, CONF_INITIAL_TRANSITION, @@ -119,7 +118,9 @@ async def handle_apply(switch, service_call): await switch._adapt_light( # pylint: disable=protected-access light, data[CONF_TRANSITION], - data[CONF_COLORS_ONLY], + data[CONF_ADAPT_BRIGHTNESS], + data[CONF_ADAPT_COLOR_TEMP], + data[CONF_ADAPT_RGB_COLOR], ) for light in data[CONF_LIGHTS] if data[CONF_TURN_ON_LIGHTS] or is_on(switch.hass, light) @@ -149,7 +150,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): CONF_TRANSITION, default=switch._initial_transition, # pylint: disable=protected-access ): VALID_TRANSITION, - vol.Optional(CONF_COLORS_ONLY, default=False): cv.boolean, + vol.Optional(CONF_ADAPT_BRIGHTNESS, default=True): cv.boolean, + vol.Optional(CONF_ADAPT_COLOR_TEMP, default=True): cv.boolean, + vol.Optional(CONF_ADAPT_RGB_COLOR, default=True): cv.boolean, vol.Optional(CONF_TURN_ON_LIGHTS, default=False): cv.boolean, }, handle_apply, @@ -482,28 +485,42 @@ def _is_disabled(self): and self.hass.states.get(self._disable_entity).state in self._disable_state ) - async def _adapt_light(self, light, transition, colors_only=False): + async def _adapt_light( + self, + light, + transition=None, + adapt_brightness=None, + adapt_color_temp=None, + adapt_rgb_color=None, + ): service_data = {ATTR_ENTITY_ID: light} features = self._supported_features(light) + if transition is None: + transition = self._transition + if adapt_brightness is None: + adapt_brightness = self._adapt_brightness + if adapt_color_temp is None: + adapt_color_temp = self._adapt_color_temp + if adapt_rgb_color is None: + adapt_rgb_color = self._adapt_rgb_color + if "transition" in features: - if transition is None: - transition = self._transition service_data[ATTR_TRANSITION] = transition - if "brightness" in features and self._adapt_brightness and not colors_only: + if "brightness" in features and adapt_brightness: service_data[ATTR_BRIGHTNESS_PCT] = self._brightness if ( "color_temp" in features - and self._adapt_color_temp + and adapt_color_temp and not (self._prefer_rgb_color and "color" in features) ): attributes = self.hass.states.get(light).attributes min_mireds, max_mireds = attributes["min_mireds"], attributes["max_mireds"] color_temp_mired = max(min(self._color_temp_mired, max_mireds), min_mireds) service_data[ATTR_COLOR_TEMP] = color_temp_mired - elif "color" in features and self._adapt_rgb_color: + elif "color" in features and adapt_rgb_color: service_data[ATTR_RGB_COLOR] = self._rgb_color _LOGGER.debug( From 12037467d5283e1d75d98e6ca13478807219441c Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 30 Sep 2020 00:00:41 +0200 Subject: [PATCH 039/165] use _expand_light_groups in apply service --- .../components/adaptive_lighting/switch.py | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 5eb0b270c08983..19cd62c0b0db3f 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -6,7 +6,7 @@ import datetime from datetime import timedelta import logging -from typing import Dict, Tuple +from typing import Dict, List, Tuple import voluptuous as vol @@ -113,7 +113,9 @@ async def handle_apply(switch, service_call): """Handle the entity service apply.""" if not isinstance(switch, AdaptiveSwitch): raise ValueError("Apply can only be called for a AdaptiveSwitch.") + hass = switch.hass data = service_call.data + all_lights = _expand_light_groups(hass, data[CONF_LIGHTS]) tasks = [ await switch._adapt_light( # pylint: disable=protected-access light, @@ -122,8 +124,8 @@ async def handle_apply(switch, service_call): data[CONF_ADAPT_COLOR_TEMP], data[CONF_ADAPT_RGB_COLOR], ) - for light in data[CONF_LIGHTS] - if data[CONF_TURN_ON_LIGHTS] or is_on(switch.hass, light) + for light in all_lights + if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light) ] if tasks: await asyncio.wait(tasks) @@ -174,6 +176,22 @@ def validate(config_entry): return data +def _expand_light_groups(hass, lights) -> List[str]: + all_lights = set() + for light in lights: + state = hass.states.get(light) + if state is None: + _LOGGER.debug("State of %s is None", light) + all_lights.add(light) + elif "entity_id" in state.attributes: # it's a light group + group = state.attributes["entity_id"] + all_lights.update(group) + _LOGGER.debug("Expanded %s to %s", light, group) + else: + all_lights.add(light) + return list(all_lights) + + class AdaptiveSwitch(SwitchEntity, RestoreEntity): """Representation of a Adaptive Lighting switch.""" @@ -281,25 +299,14 @@ async def async_added_to_hass(self): else: self._state = False - def _unpack_light_groups(self) -> None: - all_lights = set() - for light in self._lights: - state = self.hass.states.get(light) - if state is None: - _LOGGER.debug("%s: State of %s is None", self._name, light) - all_lights.add(light) - elif "entity_id" in state.attributes: # it's a light group - group = state.attributes["entity_id"] - all_lights.update(group) - _LOGGER.debug("%s: Unpacked %s to %s", self._name, light, group) - else: - all_lights.add(light) + def _expand_light_groups(self) -> None: + all_lights = _expand_light_groups(self.hass, self._lights) self.turn_on_off_listener.lights.update(all_lights) self._lights = list(all_lights) async def _setup_trackers(self, _=None): assert not self.unsub_trackers - self._unpack_light_groups() + self._expand_light_groups() rm_interval = async_track_time_interval( self.hass, self._async_update_at_interval, self._interval ) From d655dfe1037331ab8d56f5ad2d272a499b4797f2 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 30 Sep 2020 00:22:29 +0200 Subject: [PATCH 040/165] remove turn on off listener --- homeassistant/components/adaptive_lighting/__init__.py | 10 +++++++++- homeassistant/components/adaptive_lighting/switch.py | 8 +++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/__init__.py b/homeassistant/components/adaptive_lighting/__init__.py index 6b5f6c7dc914d4..668a0cffffceea 100644 --- a/homeassistant/components/adaptive_lighting/__init__.py +++ b/homeassistant/components/adaptive_lighting/__init__.py @@ -32,7 +32,13 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry import homeassistant.helpers.config_validation as cv -from .const import _DOMAIN_SCHEMA, CONF_NAME, DOMAIN, UNDO_UPDATE_LISTENER +from .const import ( + _DOMAIN_SCHEMA, + ATTR_TURN_ON_OFF_LISTENER, + CONF_NAME, + DOMAIN, + UNDO_UPDATE_LISTENER, +) _LOGGER = logging.getLogger(__name__) @@ -94,6 +100,8 @@ async def async_unload_entry(hass, config_entry: ConfigEntry) -> bool: data[config_entry.entry_id][UNDO_UPDATE_LISTENER]() switch = data[config_entry.entry_id][SWITCH_DOMAIN] switch._unsub_trackers() # pylint: disable=protected-access + if len(data) == 1: # no more config_entries + data.pop(ATTR_TURN_ON_OFF_LISTENER).remove_listener() if unload_ok: data.pop(config_entry.entry_id) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 19cd62c0b0db3f..e6c7a3683725e1 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -543,13 +543,13 @@ async def _adapt_light( context=Context(), ) - def _should_adjust(self): + def _should_adapt(self): if not self._lights or not self.is_on or self._is_disabled(): return False return True async def _adapt_lights(self, lights, transition): - if not self._should_adjust(): + if not self._should_adapt(): return _LOGGER.debug( "%s: '_adapt_lights(%s, %s)' called", self.name, lights, transition @@ -631,7 +631,9 @@ def __init__(self, hass): self.sleep_tasks: Dict[str, asyncio.Task] = {} - self.hass.bus.async_listen(EVENT_CALL_SERVICE, self.turn_on_off_event_listener) + self.remove_listener = self.hass.bus.async_listen( + EVENT_CALL_SERVICE, self.turn_on_off_event_listener + ) async def maybe_cancel_adjusting( self, entity_id, off_to_on_event, on_to_off_event From 07e0ed7359fea019633a68381a34e84f9e2d09ed Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 30 Sep 2020 00:27:15 +0200 Subject: [PATCH 041/165] rename trackers -> listeners --- .../components/adaptive_lighting/__init__.py | 2 +- .../components/adaptive_lighting/switch.py | 28 +++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/__init__.py b/homeassistant/components/adaptive_lighting/__init__.py index 668a0cffffceea..88f81dc8fa2c98 100644 --- a/homeassistant/components/adaptive_lighting/__init__.py +++ b/homeassistant/components/adaptive_lighting/__init__.py @@ -99,7 +99,7 @@ async def async_unload_entry(hass, config_entry: ConfigEntry) -> bool: data = hass.data[DOMAIN] data[config_entry.entry_id][UNDO_UPDATE_LISTENER]() switch = data[config_entry.entry_id][SWITCH_DOMAIN] - switch._unsub_trackers() # pylint: disable=protected-access + switch._remove_listeners() # pylint: disable=protected-access if len(data) == 1: # no more config_entries data.pop(ATTR_TURN_ON_OFF_LISTENER).remove_listener() diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index e6c7a3683725e1..b9cd318a4ac335 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -246,7 +246,7 @@ def __init__(self, hass, config_entry, turn_on_off_listener): self._hs_color = None # Set and unset tracker in async_turn_on and async_turn_off - self.unsub_trackers = [] + self.remove_listeners = [] _LOGGER.debug( "%s: Setting up with '%s'," " config_entry.data: '%s'," @@ -284,10 +284,10 @@ async def async_added_to_hass(self): """Call when entity about to be added to hass.""" if self._lights: if self.hass.is_running: - await self._setup_trackers() + await self._setup_listeners() else: self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, self._setup_trackers + EVENT_HOMEASSISTANT_START, self._setup_listeners ) last_state = await self.async_get_last_state() if last_state and last_state.state == STATE_ON: @@ -304,8 +304,8 @@ def _expand_light_groups(self) -> None: self.turn_on_off_listener.lights.update(all_lights) self._lights = list(all_lights) - async def _setup_trackers(self, _=None): - assert not self.unsub_trackers + async def _setup_listeners(self, _=None): + assert not self.remove_listeners self._expand_light_groups() rm_interval = async_track_time_interval( self.hass, self._async_update_at_interval, self._interval @@ -313,23 +313,23 @@ async def _setup_trackers(self, _=None): rm_state = async_track_state_change_event( self.hass, self._lights, self._light_event ) - self.unsub_trackers.extend([rm_interval, rm_state]) + self.remove_listeners.extend([rm_interval, rm_state]) track_kwargs = dict(hass=self.hass, action=self._state_changed) if self._sleep_entity is not None: kwgs = dict(track_kwargs, entity_ids=self._sleep_entity) rm_from = async_track_state_change(**kwgs, from_state=self._sleep_state) rm_to = async_track_state_change(**kwgs, to_state=self._sleep_state) - self.unsub_trackers.extend([rm_from, rm_to]) + self.remove_listeners.extend([rm_from, rm_to]) if self._disable_entity is not None: kwgs = dict(track_kwargs, entity_ids=self._disable_entity) rm_from = async_track_state_change(**kwgs, from_state=self._disable_state) rm_to = async_track_state_change(**kwgs, to_state=self._disable_state) - self.unsub_trackers.extend([rm_from, rm_to]) + self.remove_listeners.extend([rm_from, rm_to]) - def _unsub_trackers(self): - while self.unsub_trackers: - unsub = self.unsub_trackers.pop() - unsub() + def _remove_listeners(self): + while self.remove_listeners: + remove_listener = self.remove_listeners.pop() + remove_listener() @property def icon(self): @@ -360,7 +360,7 @@ async def async_turn_on( return self._state = True if setup_listeners: - await self._setup_trackers() + await self._setup_listeners() if adapt_lights: await self._update_lights(transition=self._initial_transition, force=True) @@ -369,7 +369,7 @@ async def async_turn_off(self, **kwargs): if not self.is_on: return self._state = False - self._unsub_trackers() + self._remove_listeners() async def _update_attrs(self): """Update Adaptive Values.""" From afc5c645fb8052a86165241672ca19de64e1cf38 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 30 Sep 2020 00:34:16 +0200 Subject: [PATCH 042/165] setup_listeners only when turning on --- homeassistant/components/adaptive_lighting/switch.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index b9cd318a4ac335..1cc5da937dc2c6 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -292,10 +292,7 @@ async def async_added_to_hass(self): last_state = await self.async_get_last_state() if last_state and last_state.state == STATE_ON: self._state = True - await self.async_turn_on( - adapt_lights=not self._only_once, - setup_listeners=False, - ) + await self.async_turn_on(adapt_lights=not self._only_once) else: self._state = False @@ -305,6 +302,8 @@ def _expand_light_groups(self) -> None: self._lights = list(all_lights) async def _setup_listeners(self, _=None): + if not self.is_on: + return assert not self.remove_listeners self._expand_light_groups() rm_interval = async_track_time_interval( @@ -353,14 +352,13 @@ def device_state_attributes(self): return attrs async def async_turn_on( - self, adapt_lights=True, setup_listeners=True + self, adapt_lights=True ): # pylint: disable=arguments-differ """Turn on adaptive lighting.""" if self.is_on: return self._state = True - if setup_listeners: - await self._setup_listeners() + await self._setup_listeners() if adapt_lights: await self._update_lights(transition=self._initial_transition, force=True) From 20dcced9486ee1f91c87c24d43bd556c6a30c3c7 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 30 Sep 2020 00:52:06 +0200 Subject: [PATCH 043/165] improve strings.json --- .../components/adaptive_lighting/strings.json | 28 +++++++++---------- .../adaptive_lighting/translations/en.json | 28 +++++++++---------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index 5317c2e5b1ae9b..df2149becf889a 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -27,22 +27,22 @@ "disable_entity": "disable_entity", "disable_state": "disable_state", "initial_transition": "initial_transition, the transition of the lights when turning them on or when 'disable_state' or 'sleep_state' change", - "interval": "interval", - "max_brightness": "max_brightness", - "max_color_temp": "max_color_temp", - "min_brightness": "min_brightness", - "min_color_temp": "min_color_temp", - "only_once": "only_once", - "prefer_rgb_color": "prefer_rgb_color", - "sleep_brightness": "sleep_brightness", - "sleep_color_temp": "sleep_color_temp", + "interval": "interval, time between switch updates in seconds", + "max_brightness": "max_brightness, in %", + "max_color_temp": "max_color_temp, in Kelvin", + "min_brightness": "min_brightness, in %", + "min_color_temp": "min_color_temp, in Kelvin", + "only_once": "only_once, only adapt the lights when turning them on", + "prefer_rgb_color": "prefer_rgb_color, use 'rgb_color' over 'color_temp' when possible", + "sleep_brightness": "sleep_brightness, in %", + "sleep_color_temp": "sleep_color_temp, in Kelvin", "sleep_entity": "sleep_entity", "sleep_state": "sleep_state", - "sunrise_offset": "sunrise_offset", - "sunrise_time": "sunrise_time", - "sunset_offset": "sunset_offset", - "sunset_time": "sunset_time", - "transition": "transition" + "sunrise_offset": "sunrise_offset, in +/- seconds", + "sunrise_time": "sunrise_time, in 'HH:MM:SS' format", + "sunset_offset": "sunset_offset, in +/- seconds", + "sunset_time": "sunset_time, in 'HH:MM:SS' format", + "transition": "transition, in seconds" } } }, diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index 5317c2e5b1ae9b..df2149becf889a 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -27,22 +27,22 @@ "disable_entity": "disable_entity", "disable_state": "disable_state", "initial_transition": "initial_transition, the transition of the lights when turning them on or when 'disable_state' or 'sleep_state' change", - "interval": "interval", - "max_brightness": "max_brightness", - "max_color_temp": "max_color_temp", - "min_brightness": "min_brightness", - "min_color_temp": "min_color_temp", - "only_once": "only_once", - "prefer_rgb_color": "prefer_rgb_color", - "sleep_brightness": "sleep_brightness", - "sleep_color_temp": "sleep_color_temp", + "interval": "interval, time between switch updates in seconds", + "max_brightness": "max_brightness, in %", + "max_color_temp": "max_color_temp, in Kelvin", + "min_brightness": "min_brightness, in %", + "min_color_temp": "min_color_temp, in Kelvin", + "only_once": "only_once, only adapt the lights when turning them on", + "prefer_rgb_color": "prefer_rgb_color, use 'rgb_color' over 'color_temp' when possible", + "sleep_brightness": "sleep_brightness, in %", + "sleep_color_temp": "sleep_color_temp, in Kelvin", "sleep_entity": "sleep_entity", "sleep_state": "sleep_state", - "sunrise_offset": "sunrise_offset", - "sunrise_time": "sunrise_time", - "sunset_offset": "sunset_offset", - "sunset_time": "sunset_time", - "transition": "transition" + "sunrise_offset": "sunrise_offset, in +/- seconds", + "sunrise_time": "sunrise_time, in 'HH:MM:SS' format", + "sunset_offset": "sunset_offset, in +/- seconds", + "sunset_time": "sunset_time, in 'HH:MM:SS' format", + "transition": "transition, in seconds" } } }, From 357661bba5c7a583e39c669ee36e58d42be5c9b2 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 30 Sep 2020 10:05:09 +0200 Subject: [PATCH 044/165] do not set state before calling async_turn_on --- homeassistant/components/adaptive_lighting/switch.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 1cc5da937dc2c6..e76a92e8e1fdae 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -291,7 +291,6 @@ async def async_added_to_hass(self): ) last_state = await self.async_get_last_state() if last_state and last_state.state == STATE_ON: - self._state = True await self.async_turn_on(adapt_lights=not self._only_once) else: self._state = False From 415c3bbdbc62f0620a9d5adfd87a8a805315115f Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 30 Sep 2020 10:15:33 +0200 Subject: [PATCH 045/165] add strings for adapt_color_temp and adapt_rgb_color Thanks mouth4war :tada: --- homeassistant/components/adaptive_lighting/strings.json | 4 ++-- .../components/adaptive_lighting/translations/en.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index df2149becf889a..febdc94be7c993 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -22,8 +22,8 @@ "data": { "lights": "lights", "adapt_brightness": "adapt_brightness", - "adapt_color_temp": "adapt_color_temp", - "adapt_rgb_color": "adapt_rgb_color", + "adapt_color_temp": "adapt_color_temp, adapt color temperature using 'color_temp' if supported", + "adapt_rgb_color": "adapt_rgb_color, adapt color temperature using RGB/XY if supported", "disable_entity": "disable_entity", "disable_state": "disable_state", "initial_transition": "initial_transition, the transition of the lights when turning them on or when 'disable_state' or 'sleep_state' change", diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index df2149becf889a..febdc94be7c993 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -22,8 +22,8 @@ "data": { "lights": "lights", "adapt_brightness": "adapt_brightness", - "adapt_color_temp": "adapt_color_temp", - "adapt_rgb_color": "adapt_rgb_color", + "adapt_color_temp": "adapt_color_temp, adapt color temperature using 'color_temp' if supported", + "adapt_rgb_color": "adapt_rgb_color, adapt color temperature using RGB/XY if supported", "disable_entity": "disable_entity", "disable_state": "disable_state", "initial_transition": "initial_transition, the transition of the lights when turning them on or when 'disable_state' or 'sleep_state' change", From 86f4ad17a6b45368087ccb7d2bbe88fb0e8f68e0 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 30 Sep 2020 10:22:18 +0200 Subject: [PATCH 046/165] add comments --- homeassistant/components/adaptive_lighting/switch.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index e76a92e8e1fdae..640809ccaee55e 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -625,7 +625,7 @@ def __init__(self, hass): self.turn_off_event: Dict[str, Tuple[str, float]] = {} # Tracks 'light.turn_on' service calls self.turn_on_event: Dict[str, Tuple[str]] = {} - + # Keeps 'asyncio.sleep` tasks that can be cancelled by 'light.turn_on' events self.sleep_tasks: Dict[str, asyncio.Task] = {} self.remove_listener = self.hass.bus.async_listen( @@ -709,6 +709,11 @@ async def maybe_cancel_adjusting( # transitioning into 'off'. Maybe needs some discussion/input? return True + # Now we assume that the lights are still on and they were intended + # to be on. In case this still gives problems for some, we might + # choose to **only** adapt on 'light.turn_on' events and ignore + # other 'off' → 'on' state switches resulting from polling. That + # would mean we 'return True' here. return False async def turn_on_off_event_listener(self, event): From 66d6ce439918e0826706f6192db2bc5259015395 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 30 Sep 2020 10:29:08 +0200 Subject: [PATCH 047/165] add strings for disable_entity/state, sleep_entity/state --- homeassistant/components/adaptive_lighting/strings.json | 8 ++++---- .../components/adaptive_lighting/translations/en.json | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index febdc94be7c993..0c08519e4f16a4 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -24,8 +24,8 @@ "adapt_brightness": "adapt_brightness", "adapt_color_temp": "adapt_color_temp, adapt color temperature using 'color_temp' if supported", "adapt_rgb_color": "adapt_rgb_color, adapt color temperature using RGB/XY if supported", - "disable_entity": "disable_entity", - "disable_state": "disable_state", + "disable_entity": "disable_entity, entity_id that stops the switch from adapting lights", + "disable_state": "disable_state, state(s) of 'disable_entity', e.g., 'off' or 'total,half'", "initial_transition": "initial_transition, the transition of the lights when turning them on or when 'disable_state' or 'sleep_state' change", "interval": "interval, time between switch updates in seconds", "max_brightness": "max_brightness, in %", @@ -36,8 +36,8 @@ "prefer_rgb_color": "prefer_rgb_color, use 'rgb_color' over 'color_temp' when possible", "sleep_brightness": "sleep_brightness, in %", "sleep_color_temp": "sleep_color_temp, in Kelvin", - "sleep_entity": "sleep_entity", - "sleep_state": "sleep_state", + "sleep_entity": "sleep_entity, entity_id turns the switch into sleep mode", + "sleep_state": "sleep_state, state(s) of 'sleep_entity', e.g., 'off' or 'total,half'", "sunrise_offset": "sunrise_offset, in +/- seconds", "sunrise_time": "sunrise_time, in 'HH:MM:SS' format", "sunset_offset": "sunset_offset, in +/- seconds", diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index febdc94be7c993..0c08519e4f16a4 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -24,8 +24,8 @@ "adapt_brightness": "adapt_brightness", "adapt_color_temp": "adapt_color_temp, adapt color temperature using 'color_temp' if supported", "adapt_rgb_color": "adapt_rgb_color, adapt color temperature using RGB/XY if supported", - "disable_entity": "disable_entity", - "disable_state": "disable_state", + "disable_entity": "disable_entity, entity_id that stops the switch from adapting lights", + "disable_state": "disable_state, state(s) of 'disable_entity', e.g., 'off' or 'total,half'", "initial_transition": "initial_transition, the transition of the lights when turning them on or when 'disable_state' or 'sleep_state' change", "interval": "interval, time between switch updates in seconds", "max_brightness": "max_brightness, in %", @@ -36,8 +36,8 @@ "prefer_rgb_color": "prefer_rgb_color, use 'rgb_color' over 'color_temp' when possible", "sleep_brightness": "sleep_brightness, in %", "sleep_color_temp": "sleep_color_temp, in Kelvin", - "sleep_entity": "sleep_entity", - "sleep_state": "sleep_state", + "sleep_entity": "sleep_entity, entity_id turns the switch into sleep mode", + "sleep_state": "sleep_state, state(s) of 'sleep_entity', e.g., 'off' or 'total,half'", "sunrise_offset": "sunrise_offset, in +/- seconds", "sunrise_time": "sunrise_time, in 'HH:MM:SS' format", "sunset_offset": "sunset_offset, in +/- seconds", From 21ff5b1074d2f64fc06fcd46897f7380356eb74a Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 30 Sep 2020 10:36:01 +0200 Subject: [PATCH 048/165] shorten initial_transition string --- homeassistant/components/adaptive_lighting/strings.json | 2 +- homeassistant/components/adaptive_lighting/translations/en.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index 0c08519e4f16a4..afc136c909ed99 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -26,7 +26,7 @@ "adapt_rgb_color": "adapt_rgb_color, adapt color temperature using RGB/XY if supported", "disable_entity": "disable_entity, entity_id that stops the switch from adapting lights", "disable_state": "disable_state, state(s) of 'disable_entity', e.g., 'off' or 'total,half'", - "initial_transition": "initial_transition, the transition of the lights when turning them on or when 'disable_state' or 'sleep_state' change", + "initial_transition": "initial_transition, when lights go 'off' → 'on' or when 'disable_state'/'sleep_state' changes", "interval": "interval, time between switch updates in seconds", "max_brightness": "max_brightness, in %", "max_color_temp": "max_color_temp, in Kelvin", diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index 0c08519e4f16a4..afc136c909ed99 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -26,7 +26,7 @@ "adapt_rgb_color": "adapt_rgb_color, adapt color temperature using RGB/XY if supported", "disable_entity": "disable_entity, entity_id that stops the switch from adapting lights", "disable_state": "disable_state, state(s) of 'disable_entity', e.g., 'off' or 'total,half'", - "initial_transition": "initial_transition, the transition of the lights when turning them on or when 'disable_state' or 'sleep_state' change", + "initial_transition": "initial_transition, when lights go 'off' → 'on' or when 'disable_state'/'sleep_state' changes", "interval": "interval, time between switch updates in seconds", "max_brightness": "max_brightness, in %", "max_color_temp": "max_color_temp, in Kelvin", From 3a4a0519a7702b7a90dd36774a9369d57fe7a158 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 30 Sep 2020 12:52:50 +0200 Subject: [PATCH 049/165] turn on switch upon adding to Home Assistant --- homeassistant/components/adaptive_lighting/switch.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 640809ccaee55e..c3e6e358070152 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -290,7 +290,8 @@ async def async_added_to_hass(self): EVENT_HOMEASSISTANT_START, self._setup_listeners ) last_state = await self.async_get_last_state() - if last_state and last_state.state == STATE_ON: + is_new_entry = last_state is None # newly added to HA + if is_new_entry or last_state.state == STATE_ON: await self.async_turn_on(adapt_lights=not self._only_once) else: self._state = False From 3d0a20843e352c1c69ba0da36100be3889068eac Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 30 Sep 2020 12:53:07 +0200 Subject: [PATCH 050/165] reword sleep_entity's string --- homeassistant/components/adaptive_lighting/strings.json | 2 +- homeassistant/components/adaptive_lighting/translations/en.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index afc136c909ed99..97fb913b086137 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -36,7 +36,7 @@ "prefer_rgb_color": "prefer_rgb_color, use 'rgb_color' over 'color_temp' when possible", "sleep_brightness": "sleep_brightness, in %", "sleep_color_temp": "sleep_color_temp, in Kelvin", - "sleep_entity": "sleep_entity, entity_id turns the switch into sleep mode", + "sleep_entity": "sleep_entity, 'entity_id' that manages the switch's sleep mode", "sleep_state": "sleep_state, state(s) of 'sleep_entity', e.g., 'off' or 'total,half'", "sunrise_offset": "sunrise_offset, in +/- seconds", "sunrise_time": "sunrise_time, in 'HH:MM:SS' format", diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index afc136c909ed99..97fb913b086137 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -36,7 +36,7 @@ "prefer_rgb_color": "prefer_rgb_color, use 'rgb_color' over 'color_temp' when possible", "sleep_brightness": "sleep_brightness, in %", "sleep_color_temp": "sleep_color_temp, in Kelvin", - "sleep_entity": "sleep_entity, entity_id turns the switch into sleep mode", + "sleep_entity": "sleep_entity, 'entity_id' that manages the switch's sleep mode", "sleep_state": "sleep_state, state(s) of 'sleep_entity', e.g., 'off' or 'total,half'", "sunrise_offset": "sunrise_offset, in +/- seconds", "sunrise_time": "sunrise_time, in 'HH:MM:SS' format", From 57354348da82f8b57308038df0e3e09105070edf Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 30 Sep 2020 19:28:55 +0200 Subject: [PATCH 051/165] pass context, add type annotation, and fix several small bugs --- .../components/adaptive_lighting/strings.json | 2 +- .../components/adaptive_lighting/switch.py | 200 +++++++++++------- .../adaptive_lighting/translations/en.json | 2 +- 3 files changed, 122 insertions(+), 82 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index 97fb913b086137..a64e8a88ff0228 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -26,7 +26,7 @@ "adapt_rgb_color": "adapt_rgb_color, adapt color temperature using RGB/XY if supported", "disable_entity": "disable_entity, entity_id that stops the switch from adapting lights", "disable_state": "disable_state, state(s) of 'disable_entity', e.g., 'off' or 'total,half'", - "initial_transition": "initial_transition, when lights go 'off' → 'on' or when 'disable_state'/'sleep_state' changes", + "initial_transition": "initial_transition, when lights go 'off' to 'on' or when 'disable_state'/'sleep_state' changes", "interval": "interval, time between switch updates in seconds", "max_brightness": "max_brightness, in %", "max_color_temp": "max_color_temp, in Kelvin", diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index c3e6e358070152..1a8a3eed7a0a09 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -1,4 +1,5 @@ """Switch for the Adaptive Lighting integration.""" +from __future__ import annotations import asyncio import bisect @@ -6,7 +7,7 @@ import datetime from datetime import timedelta import logging -from typing import Dict, List, Tuple +from typing import Dict, List, Optional, Tuple import voluptuous as vol @@ -24,6 +25,7 @@ is_on, ) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DOMAIN, ATTR_ENTITY_ID, @@ -38,11 +40,10 @@ SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, ) -from homeassistant.core import Context, Event +from homeassistant.core import Context, Event, ServiceCall from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import ( - async_track_state_change, async_track_state_change_event, async_track_time_interval, ) @@ -109,29 +110,28 @@ SCAN_INTERVAL = timedelta(seconds=10) -async def handle_apply(switch, service_call): +async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall): """Handle the entity service apply.""" if not isinstance(switch, AdaptiveSwitch): raise ValueError("Apply can only be called for a AdaptiveSwitch.") hass = switch.hass data = service_call.data all_lights = _expand_light_groups(hass, data[CONF_LIGHTS]) - tasks = [ - await switch._adapt_light( # pylint: disable=protected-access - light, - data[CONF_TRANSITION], - data[CONF_ADAPT_BRIGHTNESS], - data[CONF_ADAPT_COLOR_TEMP], - data[CONF_ADAPT_RGB_COLOR], - ) - for light in all_lights - if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light) - ] - if tasks: - await asyncio.wait(tasks) + 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[CONF_ADAPT_BRIGHTNESS], + data[CONF_ADAPT_COLOR_TEMP], + data[CONF_ADAPT_RGB_COLOR], + service_call.context, + ) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry(hass, config_entry: ConfigEntry, async_add_entities: bool): """Set up the AdaptiveLighting switch.""" data = hass.data[DOMAIN] @@ -176,7 +176,19 @@ def validate(config_entry): return data -def _expand_light_groups(hass, lights) -> List[str]: +def match_state_event(event: Event, from_or_to_state: List[str]): + """Match state event when either 'from_state' or 'to_state' matches.""" + old_state = event.data.get("old_state") + from_state_match = old_state is not None and old_state.state in from_or_to_state + + new_state = event.data.get("new_state") + to_state_match = new_state is not None and new_state.state in from_or_to_state + + match = from_state_match or to_state_match + return match + + +def _expand_light_groups(hass, lights: List[str]) -> List[str]: all_lights = set() for light in lights: state = hass.states.get(light) @@ -195,7 +207,7 @@ def _expand_light_groups(hass, lights) -> List[str]: class AdaptiveSwitch(SwitchEntity, RestoreEntity): """Representation of a Adaptive Lighting switch.""" - def __init__(self, hass, config_entry, turn_on_off_listener): + def __init__(self, hass, config_entry, turn_on_off_listener: TurnOnOffListener): """Initialize the Adaptive Lighting switch.""" self.hass = hass self.turn_on_off_listener = turn_on_off_listener @@ -269,11 +281,11 @@ def name(self): return self._name @property - def is_on(self): + def is_on(self) -> Optional[bool]: """Return true if adaptive lighting is on.""" return self._state - def _supported_features(self, light): + def _supported_features(self, light: str): state = self.hass.states.get(light) supported_features = state.attributes["supported_features"] return { @@ -282,19 +294,19 @@ def _supported_features(self, light): async def async_added_to_hass(self): """Call when entity about to be added to hass.""" - if self._lights: - if self.hass.is_running: - await self._setup_listeners() - else: - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, self._setup_listeners - ) + if self.hass.is_running: + await self._setup_listeners() + else: + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, self._setup_listeners + ) last_state = await self.async_get_last_state() is_new_entry = last_state is None # newly added to HA if is_new_entry or last_state.state == STATE_ON: await self.async_turn_on(adapt_lights=not self._only_once) else: self._state = False + assert not self.remove_listeners def _expand_light_groups(self) -> None: all_lights = _expand_light_groups(self.hass, self._lights) @@ -302,28 +314,35 @@ def _expand_light_groups(self) -> None: self._lights = list(all_lights) async def _setup_listeners(self, _=None): - if not self.is_on: + _LOGGER.debug("%s: Called '_setup_listeners'", self._name) + if not self.is_on or not self.hass.is_running: + _LOGGER.debug("%s: Cancelled '_setup_listeners'", self._name) return assert not self.remove_listeners - self._expand_light_groups() - rm_interval = async_track_time_interval( + remove_interval = async_track_time_interval( self.hass, self._async_update_at_interval, self._interval ) - rm_state = async_track_state_change_event( - self.hass, self._lights, self._light_event - ) - self.remove_listeners.extend([rm_interval, rm_state]) - track_kwargs = dict(hass=self.hass, action=self._state_changed) + self.remove_listeners.append(remove_interval) + if self._lights: + self._expand_light_groups() + remove_state = async_track_state_change_event( + self.hass, self._lights, self._light_event + ) + self.remove_listeners.append(remove_state) if self._sleep_entity is not None: - kwgs = dict(track_kwargs, entity_ids=self._sleep_entity) - rm_from = async_track_state_change(**kwgs, from_state=self._sleep_state) - rm_to = async_track_state_change(**kwgs, to_state=self._sleep_state) - self.remove_listeners.extend([rm_from, rm_to]) + remove_sleep = async_track_state_change_event( + self.hass, + self._sleep_entity, + self._sleep_state_event, + ) + self.remove_listeners.append(remove_sleep) if self._disable_entity is not None: - kwgs = dict(track_kwargs, entity_ids=self._disable_entity) - rm_from = async_track_state_change(**kwgs, from_state=self._disable_state) - rm_to = async_track_state_change(**kwgs, to_state=self._disable_state) - self.remove_listeners.extend([rm_from, rm_to]) + remove_disable = async_track_state_change_event( + self.hass, + self._disable_entity, + self._disable_state_event, + ) + self.remove_listeners.append(remove_disable) def _remove_listeners(self): while self.remove_listeners: @@ -352,15 +371,20 @@ def device_state_attributes(self): return attrs async def async_turn_on( - self, adapt_lights=True + self, adapt_lights: bool = True ): # pylint: disable=arguments-differ """Turn on adaptive lighting.""" + _LOGGER.debug( + "%s: Called 'async_turn_on', current state is '%s'", self._name, self._state + ) if self.is_on: return self._state = True await self._setup_listeners() if adapt_lights: - await self._update_lights(transition=self._initial_transition, force=True) + await self._maybe_adapt_lights( + transition=self._initial_transition, force=True + ) async def async_turn_off(self, **kwargs): """Turn off adaptive lighting.""" @@ -385,15 +409,21 @@ async def _update_attrs(self): _LOGGER.debug("%s: '_update_attrs' called", self._name) async def _async_update_at_interval(self, now=None): - await self._update_lights(force=False) + await self._maybe_adapt_lights(force=False) - async def _update_lights(self, lights=None, transition=None, force=False): + async def _maybe_adapt_lights( + self, + lights: Optional[List[str]] = None, + transition: Optional[int] = None, + force: bool = False, + context: Optional[Context] = None, + ): await self._update_attrs() if self._only_once and not force: return - await self._adapt_lights(lights or self._lights, transition) + await self._adapt_lights(lights or self._lights, transition, context) - def _get_sun_events(self, date): + def _get_sun_events(self, date: datetime.datetime): def _replace_time(date, key): time = getattr(self, f"_{key}_time") date_time = datetime.datetime.combine(datetime.date.today(), time) @@ -438,7 +468,7 @@ def _replace_time(date, key): return events - def _relevant_events(self, now): + def _relevant_events(self, now: datetime.datetime): events = [ self._get_sun_events(now + timedelta(days=days)) for days in [-1, 0, 1] ] @@ -492,12 +522,18 @@ def _is_disabled(self): async def _adapt_light( self, - light, - transition=None, - adapt_brightness=None, - adapt_color_temp=None, - adapt_rgb_color=None, + light: str, + transition: Optional[int] = None, + adapt_brightness: Optional[bool] = None, + adapt_color_temp: Optional[bool] = None, + adapt_rgb_color: Optional[bool] = None, + context: Optional[Context] = None, ): + lock = self._locks.get(light) + if lock is not None and lock.locked(): + _LOGGER.debug("%s: '%s' is locked", self._name, light) + return + service_data = {ATTR_ENTITY_ID: light} features = self._supported_features(light) @@ -534,11 +570,11 @@ async def _adapt_light( service_data, ) - return self.hass.services.async_call( + await self.hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, service_data, - context=Context(), + context=context, ) def _should_adapt(self): @@ -546,33 +582,35 @@ def _should_adapt(self): return False return True - async def _adapt_lights(self, lights, transition): + async def _adapt_lights( + self, lights: List[str], transition: Optional[int], context=Optional[Context] + ): if not self._should_adapt(): return _LOGGER.debug( "%s: '_adapt_lights(%s, %s)' called", self.name, lights, transition ) - tasks = [ - await self._adapt_light(light, transition) - for light in lights - if is_on(self.hass, light) - ] - if tasks: - await asyncio.wait(tasks) + for light in lights: + if is_on(self.hass, light): + await self._adapt_light(light, transition, context=context) - async def _state_changed(self, entity_id, from_state, to_state): - _LOGGER.debug( - "%s: _state_changed, from_state: '%s', to_state: '%s'", - self._name, - from_state, - to_state, + async def _disable_state_event(self, event: Event): + if not match_state_event(event, self._disable_state): + return + _LOGGER.debug("%s: _disable_state_event, event: '%s'", self._name, event) + await self._maybe_adapt_lights( + transition=self._initial_transition, force=True, context=event.context ) - lock = self._locks.get(entity_id) - if lock is not None and lock.locked: + + async def _sleep_state_event(self, event: Event): + if not match_state_event(event, self._sleep_state): return - await self._update_lights(transition=self._initial_transition, force=True) + _LOGGER.debug("%s: _sleep_state_event, event: '%s'", self._name, event) + await self._maybe_adapt_lights( + transition=self._initial_transition, force=True, context=event.context + ) - async def _light_event(self, event): + async def _light_event(self, event: Event): old_state = event.data.get("old_state") new_state = event.data.get("new_state") entity_id = event.data.get("entity_id") @@ -599,10 +637,12 @@ async def _light_event(self, event): "%s: Cancelling adjusting lights for %s", self._name, entity_id ) return - await self._update_lights( + + await self._maybe_adapt_lights( lights=[entity_id], transition=self._initial_transition, force=True, + context=event.context, ) elif ( old_state is not None @@ -634,7 +674,7 @@ def __init__(self, hass): ) async def maybe_cancel_adjusting( - self, entity_id, off_to_on_event, on_to_off_event + self, entity_id: str, off_to_on_event: Event, on_to_off_event: Optional[Event] ) -> bool: """Cancel the adjusting of a light if it has just been turned off. @@ -717,7 +757,7 @@ async def maybe_cancel_adjusting( # would mean we 'return True' here. return False - async def turn_on_off_event_listener(self, event): + async def turn_on_off_event_listener(self, event: Event): """Track 'light.turn_off' and 'light.turn_on' service calls.""" domain = event.data.get(ATTR_DOMAIN) if domain != LIGHT_DOMAIN: diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index 97fb913b086137..a64e8a88ff0228 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -26,7 +26,7 @@ "adapt_rgb_color": "adapt_rgb_color, adapt color temperature using RGB/XY if supported", "disable_entity": "disable_entity, entity_id that stops the switch from adapting lights", "disable_state": "disable_state, state(s) of 'disable_entity', e.g., 'off' or 'total,half'", - "initial_transition": "initial_transition, when lights go 'off' → 'on' or when 'disable_state'/'sleep_state' changes", + "initial_transition": "initial_transition, when lights go 'off' to 'on' or when 'disable_state'/'sleep_state' changes", "interval": "interval, time between switch updates in seconds", "max_brightness": "max_brightness, in %", "max_color_temp": "max_color_temp, in Kelvin", From 2f5fb45cfd12e2fadc5f42c0d36a0cf414949fc2 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 30 Sep 2020 23:45:22 +0200 Subject: [PATCH 052/165] mark _update_attrs as callback --- homeassistant/components/adaptive_lighting/switch.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 1a8a3eed7a0a09..d891683a27a956 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -40,7 +40,7 @@ SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, ) -from homeassistant.core import Context, Event, ServiceCall +from homeassistant.core import Context, Event, ServiceCall, callback from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import ( @@ -393,7 +393,8 @@ async def async_turn_off(self, **kwargs): self._state = False self._remove_listeners() - async def _update_attrs(self): + @callback + def _update_attrs(self): """Update Adaptive Values.""" # Setting all values because this method takes <0.5ms to execute. self._percent = self._calc_percent() @@ -418,7 +419,7 @@ async def _maybe_adapt_lights( force: bool = False, context: Optional[Context] = None, ): - await self._update_attrs() + self._update_attrs() if self._only_once and not force: return await self._adapt_lights(lights or self._lights, transition, context) From 1e717cccd9ebdf4b57d45ff13ef086aadbef7fad Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Thu, 1 Oct 2020 10:07:53 +0200 Subject: [PATCH 053/165] raise an exception instead of assert --- homeassistant/components/adaptive_lighting/switch.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index d891683a27a956..c6f9fe372c5d3b 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -465,7 +465,15 @@ def _replace_time(date, key): # Check whether order is correct events = sorted(events, key=lambda x: x[1]) events_names, _ = zip(*events) - assert events_names in _ALLOWED_ORDERS, events_names + if events_names not in _ALLOWED_ORDERS: + msg = ( + f"{self._name}: The sun events {events_names} are not in the expected" + " order. The Adaptive Lighting integration will not work!" + " This might happen if your sunrise/sunset offset is too large or" + " your manually set sunrise/sunset time is past/before noon/midnight." + ) + _LOGGER.error(msg) + raise ValueError(msg) return events From 82452e7a40fa7edd10cca6bbdf71ad83eaa1c680 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Thu, 1 Oct 2020 19:41:47 +0200 Subject: [PATCH 054/165] reorder and rename methods and keep Event objects in TurnOnOffListener --- .../components/adaptive_lighting/switch.py | 133 +++++++++--------- 1 file changed, 70 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index c6f9fe372c5d3b..dd114eb095388d 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -7,7 +7,7 @@ import datetime from datetime import timedelta import logging -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional import voluptuous as vol @@ -245,6 +245,8 @@ def __init__(self, hass, config_entry, turn_on_off_listener: TurnOnOffListener): # Tracks 'off' → 'on' state changes self._on_to_off_event: Dict[str, Event] = {} + # Tracks 'on' → 'off' state changes + self._off_to_on_event: Dict[str, Event] = {} # Locks that prevent light adjusting when waiting for a light to 'turn_off' self._locks: Dict[str, asyncio.Lock] = {} @@ -382,7 +384,7 @@ async def async_turn_on( self._state = True await self._setup_listeners() if adapt_lights: - await self._maybe_adapt_lights( + await self._update_attrs_and_maybe_adapt_lights( transition=self._initial_transition, force=True ) @@ -410,19 +412,7 @@ def _update_attrs(self): _LOGGER.debug("%s: '_update_attrs' called", self._name) async def _async_update_at_interval(self, now=None): - await self._maybe_adapt_lights(force=False) - - async def _maybe_adapt_lights( - self, - lights: Optional[List[str]] = None, - transition: Optional[int] = None, - force: bool = False, - context: Optional[Context] = None, - ): - self._update_attrs() - if self._only_once and not force: - return - await self._adapt_lights(lights or self._lights, transition, context) + await self._update_attrs_and_maybe_adapt_lights(force=False) def _get_sun_events(self, date: datetime.datetime): def _replace_time(date, key): @@ -586,28 +576,37 @@ async def _adapt_light( context=context, ) - def _should_adapt(self): - if not self._lights or not self.is_on or self._is_disabled(): - return False - return True + async def _update_attrs_and_maybe_adapt_lights( + self, + lights: Optional[List[str]] = None, + transition: Optional[int] = None, + force: bool = False, + context: Optional[Context] = None, + ): + assert self.is_on + self._update_attrs() + if self._only_once and not force: + return + await self._adapt_lights(lights or self._lights, transition, context) async def _adapt_lights( self, lights: List[str], transition: Optional[int], context=Optional[Context] ): - if not self._should_adapt(): + if self._is_disabled() or not lights: return _LOGGER.debug( "%s: '_adapt_lights(%s, %s)' called", self.name, lights, transition ) for light in lights: - if is_on(self.hass, light): - await self._adapt_light(light, transition, context=context) + if not is_on(self.hass, light): + continue + await self._adapt_light(light, transition, context=context) async def _disable_state_event(self, event: Event): if not match_state_event(event, self._disable_state): return _LOGGER.debug("%s: _disable_state_event, event: '%s'", self._name, event) - await self._maybe_adapt_lights( + await self._update_attrs_and_maybe_adapt_lights( transition=self._initial_transition, force=True, context=event.context ) @@ -615,7 +614,7 @@ async def _sleep_state_event(self, event: Event): if not match_state_event(event, self._sleep_state): return _LOGGER.debug("%s: _sleep_state_event, event: '%s'", self._name, event) - await self._maybe_adapt_lights( + await self._update_attrs_and_maybe_adapt_lights( transition=self._initial_transition, force=True, context=event.context ) @@ -632,6 +631,8 @@ async def _light_event(self, event: Event): _LOGGER.debug( "%s: Detected an 'off' → 'on' event for '%s'", self._name, entity_id ) + # Tracks 'off' → 'on' state changes + self._off_to_on_event[entity_id] = event lock = self._locks.get(entity_id) if lock is None: lock = self._locks[entity_id] = asyncio.Lock() @@ -647,7 +648,7 @@ async def _light_event(self, event: Event): ) return - await self._maybe_adapt_lights( + await self._update_attrs_and_maybe_adapt_lights( lights=[entity_id], transition=self._initial_transition, force=True, @@ -672,9 +673,9 @@ def __init__(self, hass): self.lights = set() # Tracks 'light.turn_off' service calls - self.turn_off_event: Dict[str, Tuple[str, float]] = {} + self.turn_off_event: Dict[str, Event] = {} # Tracks 'light.turn_on' service calls - self.turn_on_event: Dict[str, Tuple[str]] = {} + self.turn_on_event: Dict[str, Event] = {} # Keeps 'asyncio.sleep` tasks that can be cancelled by 'light.turn_on' events self.sleep_tasks: Dict[str, asyncio.Task] = {} @@ -682,6 +683,40 @@ def __init__(self, hass): EVENT_CALL_SERVICE, self.turn_on_off_event_listener ) + async def turn_on_off_event_listener(self, event: Event): + """Track 'light.turn_off' and 'light.turn_on' service calls.""" + domain = event.data.get(ATTR_DOMAIN) + if domain != LIGHT_DOMAIN: + return + + service = event.data.get(ATTR_SERVICE) + service_data = event.data.get(ATTR_SERVICE_DATA, {}) + + entity_ids = service_data.get(ATTR_ENTITY_ID) + if isinstance(entity_ids, str): + entity_ids = [entity_ids] + + if not any(eid in self.lights for eid in entity_ids): + return + + if service == SERVICE_TURN_OFF: + transition = service_data.get(ATTR_TRANSITION) + _LOGGER.debug( + "Detected an 'light.turn_off('%s', transition=%s)' event", + entity_ids, + transition, + ) + for eid in entity_ids: + self.turn_off_event[eid] = event + + elif service == SERVICE_TURN_ON: + _LOGGER.debug("Detected an 'light.turn_on('%s')' event", entity_ids) + for eid in entity_ids: + task = self.sleep_tasks.get(eid) + if task is not None: + task.cancel() + self.turn_on_event[eid] = event + async def maybe_cancel_adjusting( self, entity_id: str, off_to_on_event: Event, on_to_off_event: Optional[Event] ) -> bool: @@ -702,8 +737,14 @@ async def maybe_cancel_adjusting( return False id_on_to_off = on_to_off_event.context.id - id_turn_off, transition = self.turn_off_event.get(entity_id, (None, None)) - id_turn_on = self.turn_on_event.get(entity_id) + + turn_off_event = self.turn_off_event.get(entity_id) + id_turn_off = turn_off_event.context.id + transition = turn_off_event.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION) + + turn_on_event = self.turn_on_event.get(entity_id) + id_turn_on = turn_on_event.context.id + id_off_to_on = off_to_on_event.context.id if id_off_to_on == id_turn_on and id_off_to_on is not None: @@ -765,37 +806,3 @@ async def maybe_cancel_adjusting( # other 'off' → 'on' state switches resulting from polling. That # would mean we 'return True' here. return False - - async def turn_on_off_event_listener(self, event: Event): - """Track 'light.turn_off' and 'light.turn_on' service calls.""" - domain = event.data.get(ATTR_DOMAIN) - if domain != LIGHT_DOMAIN: - return - - service = event.data.get(ATTR_SERVICE) - service_data = event.data.get(ATTR_SERVICE_DATA, {}) - - entity_ids = service_data.get(ATTR_ENTITY_ID) - if isinstance(entity_ids, str): - entity_ids = [entity_ids] - - if not any(eid in self.lights for eid in entity_ids): - return - - if service == SERVICE_TURN_OFF: - transition = service_data.get(ATTR_TRANSITION) - _LOGGER.debug( - "Detected an 'light.turn_off('%s', transition=%s)' event", - entity_ids, - transition, - ) - for eid in entity_ids: - self.turn_off_event[eid] = (event.context.id, transition) - - elif service == SERVICE_TURN_ON: - _LOGGER.debug("Detected an 'light.turn_on('%s')' event", entity_ids) - for eid in entity_ids: - task = self.sleep_tasks.get(eid) - if task is not None: - task.cancel() - self.turn_on_event[eid] = event.context.id From 72335be8f0b4751d2df6bb0a93cedd4939686a1f Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Thu, 1 Oct 2020 19:44:20 +0200 Subject: [PATCH 055/165] start implementing 'take_over_control' feature --- .../components/adaptive_lighting/const.py | 2 ++ .../components/adaptive_lighting/strings.json | 1 + .../components/adaptive_lighting/switch.py | 15 +++++++++++++++ .../adaptive_lighting/translations/en.json | 1 + 4 files changed, 19 insertions(+) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index 9d8000c335ba12..b9cc36325bae88 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -33,6 +33,7 @@ CONF_SUNRISE_TIME = "sunrise_time" CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET = "sunset_offset", 0 CONF_SUNSET_TIME = "sunset_time" +CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", True CONF_TRANSITION, DEFAULT_TRANSITION = "transition", 60 ATTR_TURN_ON_OFF_LISTENER = "turn_on_off_listener" @@ -73,6 +74,7 @@ def int_between(min_int, max_int): (CONF_SUNRISE_TIME, NONE_STR, str), (CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET, int), (CONF_SUNSET_TIME, NONE_STR, str), + (CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL, bool), (CONF_TRANSITION, DEFAULT_TRANSITION, VALID_TRANSITION), ] diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index a64e8a88ff0228..17fbec0199a460 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -42,6 +42,7 @@ "sunrise_time": "sunrise_time, in 'HH:MM:SS' format", "sunset_offset": "sunset_offset, in +/- seconds", "sunset_time": "sunset_time, in 'HH:MM:SS' format", + "take_over_control": "take_over_control, if manually adjusting the lights when they are already on", "transition": "transition, in seconds" } } diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index dd114eb095388d..69e4cb47d78cb9 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -82,6 +82,7 @@ CONF_SUNRISE_TIME, CONF_SUNSET_OFFSET, CONF_SUNSET_TIME, + CONF_TAKE_OVER_CONTROL, CONF_TRANSITION, CONF_TURN_ON_LIGHTS, DOMAIN, @@ -236,6 +237,7 @@ def __init__(self, hass, config_entry, turn_on_off_listener: TurnOnOffListener): self._sunrise_time = data[CONF_SUNRISE_TIME] self._sunset_offset = data[CONF_SUNSET_OFFSET] self._sunset_time = data[CONF_SUNSET_TIME] + self._take_over_control = data[CONF_TAKE_OVER_CONTROL] self._transition = data[CONF_TRANSITION] # Set other attributes @@ -600,6 +602,12 @@ async def _adapt_lights( for light in lights: if not is_on(self.hass, light): continue + if self._take_over_control: + if await self.turn_on_off_listener.is_manually_adjusted( + light, + off_to_on_event=self._off_to_on_event.get(light), + ): + continue await self._adapt_light(light, transition, context=context) async def _disable_state_event(self, event: Event): @@ -717,6 +725,13 @@ async def turn_on_off_event_listener(self, event: Event): task.cancel() self.turn_on_event[eid] = event + async def is_manually_adjusted(self, light: str, off_to_on_event: Optional[Event]): + """Check if the light has been 'on' and is now manually being adjusted.""" + if off_to_on_event is None: + # No state change has been registered before, so we can't tell. + return False + return False + async def maybe_cancel_adjusting( self, entity_id: str, off_to_on_event: Event, on_to_off_event: Optional[Event] ) -> bool: diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index a64e8a88ff0228..17fbec0199a460 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -42,6 +42,7 @@ "sunrise_time": "sunrise_time, in 'HH:MM:SS' format", "sunset_offset": "sunset_offset, in +/- seconds", "sunset_time": "sunset_time, in 'HH:MM:SS' format", + "take_over_control": "take_over_control, if manually adjusting the lights when they are already on", "transition": "transition, in seconds" } } From 6f668462fbf9b4420cb9fe48415684e17270aac8 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 2 Oct 2020 09:24:37 +0200 Subject: [PATCH 056/165] break out in _update_attrs_and_maybe_adapt_lights --- homeassistant/components/adaptive_lighting/switch.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 69e4cb47d78cb9..11ac63593d1c6b 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -587,15 +587,15 @@ async def _update_attrs_and_maybe_adapt_lights( ): assert self.is_on self._update_attrs() - if self._only_once and not force: + if lights is None: + lights = self._lights + if (self._only_once and not force) or self._is_disabled() or not lights: return - await self._adapt_lights(lights or self._lights, transition, context) + await self._adapt_lights(lights, transition, context) async def _adapt_lights( self, lights: List[str], transition: Optional[int], context=Optional[Context] ): - if self._is_disabled() or not lights: - return _LOGGER.debug( "%s: '_adapt_lights(%s, %s)' called", self.name, lights, transition ) From d61cc142ce46ca7f272584d9cdc6914fe9264391 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 2 Oct 2020 09:59:52 +0200 Subject: [PATCH 057/165] make supported_features into a function --- .../components/adaptive_lighting/switch.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 11ac63593d1c6b..5a71cedb36b38b 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -205,6 +205,12 @@ def _expand_light_groups(hass, lights: List[str]) -> List[str]: return list(all_lights) +def _supported_features(hass, light: str): + state = hass.states.get(light) + supported_features = state.attributes["supported_features"] + return {key for key, value in _SUPPORT_OPTS.items() if supported_features & value} + + class AdaptiveSwitch(SwitchEntity, RestoreEntity): """Representation of a Adaptive Lighting switch.""" @@ -289,13 +295,6 @@ def is_on(self) -> Optional[bool]: """Return true if adaptive lighting is on.""" return self._state - def _supported_features(self, light: str): - state = self.hass.states.get(light) - supported_features = state.attributes["supported_features"] - return { - key for key, value in _SUPPORT_OPTS.items() if supported_features & value - } - async def async_added_to_hass(self): """Call when entity about to be added to hass.""" if self.hass.is_running: @@ -401,6 +400,7 @@ async def async_turn_off(self, **kwargs): def _update_attrs(self): """Update Adaptive Values.""" # Setting all values because this method takes <0.5ms to execute. + _LOGGER.debug("%s: '_update_attrs' called", self._name) self._percent = self._calc_percent() self._brightness = self._calc_brightness() self._color_temp_kelvin = self._calc_color_temp_kelvin() @@ -411,7 +411,6 @@ def _update_attrs(self): self._xy_color = color_RGB_to_xy(*self._rgb_color) self._hs_color = color_xy_to_hs(*self._xy_color) self.async_write_ha_state() - _LOGGER.debug("%s: '_update_attrs' called", self._name) async def _async_update_at_interval(self, now=None): await self._update_attrs_and_maybe_adapt_lights(force=False) @@ -536,7 +535,7 @@ async def _adapt_light( return service_data = {ATTR_ENTITY_ID: light} - features = self._supported_features(light) + features = _supported_features(self.hass, light) if transition is None: transition = self._transition From 0ed8320a64efc6fc7841e7105dabc97ba04b1b32 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 2 Oct 2020 10:49:49 +0200 Subject: [PATCH 058/165] extract light settings into SunLightSettings dataclass --- .../components/adaptive_lighting/switch.py | 304 ++++++++++-------- 1 file changed, 169 insertions(+), 135 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 5a71cedb36b38b..e667bda0a2a0c8 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -4,11 +4,13 @@ import asyncio import bisect from copy import deepcopy +from dataclasses import dataclass import datetime from datetime import timedelta import logging -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Tuple, Union +import astral import voluptuous as vol from homeassistant.components.light import ( @@ -229,23 +231,29 @@ def __init__(self, hass, config_entry, turn_on_off_listener: TurnOnOffListener): self._disable_state = data[CONF_DISABLE_STATE] self._initial_transition = data[CONF_INITIAL_TRANSITION] self._interval = data[CONF_INTERVAL] - self._max_brightness = data[CONF_MAX_BRIGHTNESS] - self._max_color_temp = data[CONF_MAX_COLOR_TEMP] - self._min_brightness = data[CONF_MIN_BRIGHTNESS] - self._min_color_temp = data[CONF_MIN_COLOR_TEMP] self._only_once = data[CONF_ONLY_ONCE] self._prefer_rgb_color = data[CONF_PREFER_RGB_COLOR] - self._sleep_brightness = data[CONF_SLEEP_BRIGHTNESS] - self._sleep_color_temp = data[CONF_SLEEP_COLOR_TEMP] self._sleep_entity = data[CONF_SLEEP_ENTITY] self._sleep_state = data[CONF_SLEEP_STATE] - self._sunrise_offset = data[CONF_SUNRISE_OFFSET] - self._sunrise_time = data[CONF_SUNRISE_TIME] - self._sunset_offset = data[CONF_SUNSET_OFFSET] - self._sunset_time = data[CONF_SUNSET_TIME] self._take_over_control = data[CONF_TAKE_OVER_CONTROL] self._transition = data[CONF_TRANSITION] + self._sun_light_settings = SunLightSettings( + name=self._name, + astral_location=get_astral_location(self.hass), + max_brightness=data[CONF_MAX_BRIGHTNESS], + max_color_temp=data[CONF_MAX_COLOR_TEMP], + min_brightness=data[CONF_MIN_BRIGHTNESS], + min_color_temp=data[CONF_MIN_COLOR_TEMP], + sleep_brightness=data[CONF_SLEEP_BRIGHTNESS], + sleep_color_temp=data[CONF_SLEEP_COLOR_TEMP], + sunrise_offset=data[CONF_SUNRISE_OFFSET], + sunrise_time=data[CONF_SUNRISE_TIME], + sunset_offset=data[CONF_SUNSET_OFFSET], + sunset_time=data[CONF_SUNSET_TIME], + time_zone=self.hass.config.time_zone, + ) + # Set other attributes self._icon = ICON self._entity_id = f"switch.{DOMAIN}_{slugify(self._name)}" @@ -259,13 +267,7 @@ def __init__(self, hass, config_entry, turn_on_off_listener: TurnOnOffListener): self._locks: Dict[str, asyncio.Lock] = {} # Initialize attributes that will be set in self._update_attrs - self._percent = None - self._brightness = None - self._color_temp_kelvin = None - self._color_temp_mired = None - self._rgb_color = None - self._xy_color = None - self._hs_color = None + self._light_settings = {} # Set and unset tracker in async_turn_on and async_turn_off self.remove_listeners = [] @@ -360,18 +362,9 @@ def icon(self): @property def device_state_attributes(self): """Return the attributes of the switch.""" - attrs = { - "percent": self._percent, - "brightness": self._brightness, - "color_temp_kelvin": self._color_temp_kelvin, - "color_temp_mired": self._color_temp_mired, - "rgb_color": self._rgb_color, - "xy_color": self._xy_color, - "hs_color": self._hs_color, - } if not self.is_on: - return {key: None for key in attrs} - return attrs + return {key: None for key in self._light_settings} + return self._light_settings async def async_turn_on( self, adapt_lights: bool = True @@ -399,121 +392,19 @@ async def async_turn_off(self, **kwargs): @callback def _update_attrs(self): """Update Adaptive Values.""" - # Setting all values because this method takes <0.5ms to execute. _LOGGER.debug("%s: '_update_attrs' called", self._name) - self._percent = self._calc_percent() - self._brightness = self._calc_brightness() - self._color_temp_kelvin = self._calc_color_temp_kelvin() - self._color_temp_mired = color_temperature_kelvin_to_mired( - self._color_temp_kelvin - ) - self._rgb_color = color_temperature_to_rgb(self._color_temp_kelvin) - self._xy_color = color_RGB_to_xy(*self._rgb_color) - self._hs_color = color_xy_to_hs(*self._xy_color) + self._light_settings = self._sun_light_settings.get_settings(self._is_sleep()) self.async_write_ha_state() async def _async_update_at_interval(self, now=None): await self._update_attrs_and_maybe_adapt_lights(force=False) - def _get_sun_events(self, date: datetime.datetime): - def _replace_time(date, key): - time = getattr(self, f"_{key}_time") - date_time = datetime.datetime.combine(datetime.date.today(), time) - time_zone = self.hass.config.time_zone - utc_time = time_zone.localize(date_time).astimezone(dt_util.UTC) - return date.replace( - hour=utc_time.hour, - minute=utc_time.minute, - second=utc_time.second, - microsecond=utc_time.microsecond, - ) - - location = get_astral_location(self.hass) - sunrise = ( - location.sunrise(date, local=False) - if self._sunrise_time is None - else _replace_time(date, "sunrise") - ) + self._sunrise_offset - sunset = ( - location.sunset(date, local=False) - if self._sunset_time is None - else _replace_time(date, "sunset") - ) + self._sunset_offset - - if self._sunrise_time is None and self._sunset_time is None: - solar_noon = location.solar_noon(date, local=False) - solar_midnight = location.solar_midnight(date, local=False) - else: - solar_noon = sunrise + (sunset - sunrise) / 2 - solar_midnight = sunset + ((sunrise + timedelta(days=1)) - sunset) / 2 - - events = [ - (SUN_EVENT_SUNRISE, sunrise.timestamp()), - (SUN_EVENT_SUNSET, sunset.timestamp()), - (SUN_EVENT_NOON, solar_noon.timestamp()), - (SUN_EVENT_MIDNIGHT, solar_midnight.timestamp()), - ] - # Check whether order is correct - events = sorted(events, key=lambda x: x[1]) - events_names, _ = zip(*events) - if events_names not in _ALLOWED_ORDERS: - msg = ( - f"{self._name}: The sun events {events_names} are not in the expected" - " order. The Adaptive Lighting integration will not work!" - " This might happen if your sunrise/sunset offset is too large or" - " your manually set sunrise/sunset time is past/before noon/midnight." - ) - _LOGGER.error(msg) - raise ValueError(msg) - - return events - - def _relevant_events(self, now: datetime.datetime): - events = [ - self._get_sun_events(now + timedelta(days=days)) for days in [-1, 0, 1] - ] - events = sum(events, []) # flatten lists - events = sorted(events, key=lambda x: x[1]) - i_now = bisect.bisect([ts for _, ts in events], now.timestamp()) - return events[i_now - 1 : i_now + 1] - - def _calc_percent(self): - now = dt_util.utcnow() - now_ts = now.timestamp() - today = self._relevant_events(now) - (_, prev_ts), (next_event, next_ts) = today - h, x = ( # pylint: disable=invalid-name - (prev_ts, next_ts) - if next_event in (SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE) - else (next_ts, prev_ts) - ) - k = 1 if next_event in (SUN_EVENT_SUNSET, SUN_EVENT_NOON) else -1 - percentage = (0 - k) * ((now_ts - h) / (h - x)) ** 2 + k - return percentage - def _is_sleep(self): return ( self._sleep_entity is not None and self.hass.states.get(self._sleep_entity).state in self._sleep_state ) - def _calc_color_temp_kelvin(self): - if self._is_sleep(): - return self._sleep_color_temp - if self._percent > 0: - delta = self._max_color_temp - self._min_color_temp - return (delta * self._percent) + self._min_color_temp - return self._min_color_temp - - def _calc_brightness(self) -> float: - if self._is_sleep(): - return self._sleep_brightness - if self._percent > 0: - return self._max_brightness - delta_brightness = self._max_brightness - self._min_brightness - percent = 1 + self._percent - return (delta_brightness * percent) + self._min_brightness - def _is_disabled(self): return ( self._disable_entity is not None @@ -550,7 +441,7 @@ async def _adapt_light( service_data[ATTR_TRANSITION] = transition if "brightness" in features and adapt_brightness: - service_data[ATTR_BRIGHTNESS_PCT] = self._brightness + service_data[ATTR_BRIGHTNESS_PCT] = self._light_settings["brightness_pct"] if ( "color_temp" in features @@ -559,10 +450,11 @@ async def _adapt_light( ): attributes = self.hass.states.get(light).attributes min_mireds, max_mireds = attributes["min_mireds"], attributes["max_mireds"] - color_temp_mired = max(min(self._color_temp_mired, max_mireds), min_mireds) + color_temp_mired = self._light_settings["color_temp_mired"] + color_temp_mired = max(min(color_temp_mired, max_mireds), min_mireds) service_data[ATTR_COLOR_TEMP] = color_temp_mired elif "color" in features and adapt_rgb_color: - service_data[ATTR_RGB_COLOR] = self._rgb_color + service_data[ATTR_RGB_COLOR] = self._light_settings["rgb_color"] _LOGGER.debug( "%s: Scheduling 'light.turn_on' with the following 'service_data': %s", @@ -671,6 +563,148 @@ async def _light_event(self, event: Event): self._on_to_off_event[entity_id] = event +@dataclass +class SunLightSettings: + """Track the state of the sun and associated light settings.""" + + name: str + astral_location: astral.Location + max_brightness: int + max_color_temp: int + min_brightness: int + min_color_temp: int + sleep_brightness: int + sleep_color_temp: int + sunrise_offset: Optional[datetime.timedelta] + sunrise_time: Optional[datetime.time] + sunset_offset: Optional[datetime.timedelta] + sunset_time: Optional[datetime.time] + time_zone: datetime.tzinfo + + def get_sun_events(self, date: datetime.datetime) -> Dict[str, float]: + """Get the four sun event's timestamps at 'date'.""" + + def _replace_time(date: datetime.datetime, key: str) -> datetime.datetime: + time = getattr(self, f"{key}_time") + date_time = datetime.datetime.combine(datetime.date.today(), time) + utc_time = self.time_zone.localize(date_time).astimezone(dt_util.UTC) + return date.replace( + hour=utc_time.hour, + minute=utc_time.minute, + second=utc_time.second, + microsecond=utc_time.microsecond, + ) + + location = self.astral_location + sunrise = ( + location.sunrise(date, local=False) + if self.sunrise_time is None + else _replace_time(date, "sunrise") + ) + self.sunrise_offset + sunset = ( + location.sunset(date, local=False) + if self.sunset_time is None + else _replace_time(date, "sunset") + ) + self.sunset_offset + + if self.sunrise_time is None and self.sunset_time is None: + solar_noon = location.solar_noon(date, local=False) + solar_midnight = location.solar_midnight(date, local=False) + else: + solar_noon = sunrise + (sunset - sunrise) / 2 + solar_midnight = sunset + ((sunrise + timedelta(days=1)) - sunset) / 2 + + events = [ + (SUN_EVENT_SUNRISE, sunrise.timestamp()), + (SUN_EVENT_SUNSET, sunset.timestamp()), + (SUN_EVENT_NOON, solar_noon.timestamp()), + (SUN_EVENT_MIDNIGHT, solar_midnight.timestamp()), + ] + # Check whether order is correct + events = sorted(events, key=lambda x: x[1]) + events_names, _ = zip(*events) + if events_names not in _ALLOWED_ORDERS: + msg = ( + f"{self.name}: The sun events {events_names} are not in the expected" + " order. The Adaptive Lighting integration will not work!" + " This might happen if your sunrise/sunset offset is too large or" + " your manually set sunrise/sunset time is past/before noon/midnight." + ) + _LOGGER.error(msg) + raise ValueError(msg) + + return events + + def relevant_events(self, now: datetime.datetime) -> List[Tuple[str, float]]: + """Get the previous and next sun event.""" + events = [ + self.get_sun_events(now + timedelta(days=days)) for days in [-1, 0, 1] + ] + events = sum(events, []) # flatten lists + events = sorted(events, key=lambda x: x[1]) + i_now = bisect.bisect([ts for _, ts in events], now.timestamp()) + return events[i_now - 1 : i_now + 1] + + def calc_percent(self) -> float: + """Calculate the position of the sun in %.""" + now = dt_util.utcnow() + now_ts = now.timestamp() + today = self.relevant_events(now) + (_, prev_ts), (next_event, next_ts) = today + h, x = ( # pylint: disable=invalid-name + (prev_ts, next_ts) + if next_event in (SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE) + else (next_ts, prev_ts) + ) + k = 1 if next_event in (SUN_EVENT_SUNSET, SUN_EVENT_NOON) else -1 + percentage = (0 - k) * ((now_ts - h) / (h - x)) ** 2 + k + return percentage + + def calc_brightness_pct(self, percent: float, is_sleep: bool) -> float: + """Calculate the brightness in %.""" + if is_sleep: + return self.sleep_brightness + if percent > 0: + return self.max_brightness + delta_brightness = self.max_brightness - self.min_brightness + percent = 1 + percent + return (delta_brightness * percent) + self.min_brightness + + def calc_color_temp_kelvin(self, percent: float, is_sleep: bool) -> float: + """Calculate the color temperature in Kelvin.""" + if is_sleep: + return self.sleep_color_temp + if percent > 0: + delta = self.max_color_temp - self.min_color_temp + return (delta * percent) + self.min_color_temp + return self.min_color_temp + + def get_settings( + self, is_sleep + ) -> Dict[str, Union[float, Tuple[float, float], Tuple[float, float, float]]]: + """Get all light settings. + + Calculating all values takes <0.5ms. + """ + percent = self.calc_percent() + brightness_pct = self.calc_brightness_pct(percent, is_sleep) + color_temp_kelvin = self.calc_color_temp_kelvin(percent, is_sleep) + color_temp_mired: float = color_temperature_kelvin_to_mired(color_temp_kelvin) + rgb_color: Tuple[float, float, float] = color_temperature_to_rgb( + color_temp_kelvin + ) + xy_color: Tuple[float, float] = color_RGB_to_xy(*rgb_color) + hs_color: Tuple[float, float] = color_xy_to_hs(*xy_color) + return { + "brightness_pct": brightness_pct, + "color_temp_kelvin": color_temp_kelvin, + "color_temp_mired": color_temp_mired, + "rgb_color": rgb_color, + "xy_color": xy_color, + "hs_color": hs_color, + } + + class TurnOnOffListener: """Track 'light.turn_off' and 'light.turn_on' service calls.""" From b0bed5c8871cba36e2460da1d524d2aadb19b0ef Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 2 Oct 2020 19:16:52 +0200 Subject: [PATCH 059/165] add a sleep_mode switch and remove config options --- .../adaptive_lighting/config_flow.py | 2 - .../components/adaptive_lighting/const.py | 6 - .../components/adaptive_lighting/strings.json | 2 - .../components/adaptive_lighting/switch.py | 134 ++++++++++++------ 4 files changed, 91 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/config_flow.py b/homeassistant/components/adaptive_lighting/config_flow.py index a0c746a06c952c..f68a412bd4a59b 100644 --- a/homeassistant/components/adaptive_lighting/config_flow.py +++ b/homeassistant/components/adaptive_lighting/config_flow.py @@ -10,7 +10,6 @@ from .const import ( # pylint: disable=unused-import CONF_DISABLE_ENTITY, CONF_LIGHTS, - CONF_SLEEP_ENTITY, DOMAIN, EXTRA_VALIDATION, NONE_STR, @@ -96,7 +95,6 @@ async def async_step_init(self, user_input=None): to_replace = { CONF_LIGHTS: cv.multi_select(all_lights), CONF_DISABLE_ENTITY: vol.In([NONE_STR] + all_entities), - CONF_SLEEP_ENTITY: vol.In([NONE_STR] + all_entities), } options_schema = {} diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index b9cc36325bae88..c0af677b4ad542 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -27,8 +27,6 @@ CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR = "prefer_rgb_color", False CONF_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_BRIGHTNESS = "sleep_brightness", 1 CONF_SLEEP_COLOR_TEMP, DEFAULT_SLEEP_COLOR_TEMP = "sleep_color_temp", 1000 -CONF_SLEEP_ENTITY = "sleep_entity" -CONF_SLEEP_STATE = "sleep_state" CONF_SUNRISE_OFFSET, DEFAULT_SUNRISE_OFFSET = "sunrise_offset", 0 CONF_SUNRISE_TIME = "sunrise_time" CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET = "sunset_offset", 0 @@ -68,8 +66,6 @@ def int_between(min_int, max_int): (CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR, bool), (CONF_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_BRIGHTNESS, int_between(1, 100)), (CONF_SLEEP_COLOR_TEMP, DEFAULT_SLEEP_COLOR_TEMP, int_between(1000, 10000)), - (CONF_SLEEP_ENTITY, NONE_STR, cv.entity_id), - (CONF_SLEEP_STATE, NONE_STR, str), (CONF_SUNRISE_OFFSET, DEFAULT_SUNRISE_OFFSET, int), (CONF_SUNRISE_TIME, NONE_STR, str), (CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET, int), @@ -98,8 +94,6 @@ def join_strings(lst): CONF_DISABLE_ENTITY: (cv.entity_id, str), CONF_DISABLE_STATE: (vol.All(cv.ensure_list_csv, [cv.string]), join_strings), CONF_INTERVAL: (cv.time_period, timedelta_as_int), - CONF_SLEEP_ENTITY: (cv.entity_id, str), - CONF_SLEEP_STATE: (vol.All(cv.ensure_list_csv, [cv.string]), join_strings), CONF_SUNRISE_OFFSET: (cv.time_period, timedelta_as_int), CONF_SUNRISE_TIME: (cv.time, str), CONF_SUNSET_OFFSET: (cv.time_period, timedelta_as_int), diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index 17fbec0199a460..f0c29619409502 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -36,8 +36,6 @@ "prefer_rgb_color": "prefer_rgb_color, use 'rgb_color' over 'color_temp' when possible", "sleep_brightness": "sleep_brightness, in %", "sleep_color_temp": "sleep_color_temp, in Kelvin", - "sleep_entity": "sleep_entity, 'entity_id' that manages the switch's sleep mode", - "sleep_state": "sleep_state, state(s) of 'sleep_entity', e.g., 'off' or 'total,half'", "sunrise_offset": "sunrise_offset, in +/- seconds", "sunrise_time": "sunrise_time, in 'HH:MM:SS' format", "sunset_offset": "sunset_offset, in +/- seconds", diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index e667bda0a2a0c8..d4bad35bb9165b 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -8,7 +8,7 @@ import datetime from datetime import timedelta import logging -from typing import Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union import astral import voluptuous as vol @@ -38,11 +38,12 @@ EVENT_HOMEASSISTANT_START, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_OFF, STATE_ON, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, ) -from homeassistant.core import Context, Event, ServiceCall, callback +from homeassistant.core import Context, Event, ServiceCall from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import ( @@ -78,8 +79,6 @@ CONF_PREFER_RGB_COLOR, CONF_SLEEP_BRIGHTNESS, CONF_SLEEP_COLOR_TEMP, - CONF_SLEEP_ENTITY, - CONF_SLEEP_STATE, CONF_SUNRISE_OFFSET, CONF_SUNRISE_TIME, CONF_SUNSET_OFFSET, @@ -142,7 +141,10 @@ async def async_setup_entry(hass, config_entry: ConfigEntry, async_add_entities: data[ATTR_TURN_ON_OFF_LISTENER] = TurnOnOffListener(hass) turn_on_off_listener = data[ATTR_TURN_ON_OFF_LISTENER] - switch = AdaptiveSwitch(hass, config_entry, turn_on_off_listener) + sleep_mode_switch = AdaptiveSleepModeSwitch(hass, config_entry) + switch = AdaptiveSwitch(hass, config_entry, turn_on_off_listener, sleep_mode_switch) + + data[config_entry.entry_id]["sleep_mode_switch"] = sleep_mode_switch data[config_entry.entry_id][SWITCH_DOMAIN] = switch # Register `apply` service @@ -162,7 +164,7 @@ async def async_setup_entry(hass, config_entry: ConfigEntry, async_add_entities: }, handle_apply, ) - async_add_entities([switch], update_before_add=True) + async_add_entities([switch, sleep_mode_switch], update_before_add=True) def validate(config_entry): @@ -216,14 +218,22 @@ def _supported_features(hass, light: str): class AdaptiveSwitch(SwitchEntity, RestoreEntity): """Representation of a Adaptive Lighting switch.""" - def __init__(self, hass, config_entry, turn_on_off_listener: TurnOnOffListener): + def __init__( + self, + hass, + config_entry: ConfigEntry, + turn_on_off_listener: TurnOnOffListener, + sleep_mode_switch: AdaptiveSleepModeSwitch, + ): """Initialize the Adaptive Lighting switch.""" self.hass = hass self.turn_on_off_listener = turn_on_off_listener + self.sleep_mode_switch = sleep_mode_switch data = validate(config_entry) self._name = data[CONF_NAME] self._lights = data[CONF_LIGHTS] + self._adapt_brightness = data[CONF_ADAPT_BRIGHTNESS] self._adapt_color_temp = data[CONF_ADAPT_COLOR_TEMP] self._adapt_rgb_color = data[CONF_ADAPT_RGB_COLOR] @@ -233,8 +243,6 @@ def __init__(self, hass, config_entry, turn_on_off_listener: TurnOnOffListener): self._interval = data[CONF_INTERVAL] self._only_once = data[CONF_ONLY_ONCE] self._prefer_rgb_color = data[CONF_PREFER_RGB_COLOR] - self._sleep_entity = data[CONF_SLEEP_ENTITY] - self._sleep_state = data[CONF_SLEEP_STATE] self._take_over_control = data[CONF_TAKE_OVER_CONTROL] self._transition = data[CONF_TRANSITION] @@ -266,7 +274,7 @@ def __init__(self, hass, config_entry, turn_on_off_listener: TurnOnOffListener): # Locks that prevent light adjusting when waiting for a light to 'turn_off' self._locks: Dict[str, asyncio.Lock] = {} - # Initialize attributes that will be set in self._update_attrs + # Set in self._update_attrs_and_maybe_adapt_lights self._light_settings = {} # Set and unset tracker in async_turn_on and async_turn_off @@ -290,14 +298,14 @@ def entity_id(self): @property def name(self): """Return the name of the device if any.""" - return self._name + return f"Adaptive Lighting: {self._name}" @property def is_on(self) -> Optional[bool]: """Return true if adaptive lighting is on.""" return self._state - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call when entity about to be added to hass.""" if self.hass.is_running: await self._setup_listeners() @@ -318,7 +326,7 @@ def _expand_light_groups(self) -> None: self.turn_on_off_listener.lights.update(all_lights) self._lights = list(all_lights) - async def _setup_listeners(self, _=None): + async def _setup_listeners(self, _=None) -> None: _LOGGER.debug("%s: Called '_setup_listeners'", self._name) if not self.is_on or not self.hass.is_running: _LOGGER.debug("%s: Cancelled '_setup_listeners'", self._name) @@ -328,19 +336,18 @@ async def _setup_listeners(self, _=None): self.hass, self._async_update_at_interval, self._interval ) self.remove_listeners.append(remove_interval) + remove_sleep = async_track_state_change_event( + self.hass, + self.sleep_mode_switch.entity_id, + self._sleep_state_event, + ) + self.remove_listeners.append(remove_sleep) if self._lights: self._expand_light_groups() remove_state = async_track_state_change_event( self.hass, self._lights, self._light_event ) self.remove_listeners.append(remove_state) - if self._sleep_entity is not None: - remove_sleep = async_track_state_change_event( - self.hass, - self._sleep_entity, - self._sleep_state_event, - ) - self.remove_listeners.append(remove_sleep) if self._disable_entity is not None: remove_disable = async_track_state_change_event( self.hass, @@ -349,18 +356,18 @@ async def _setup_listeners(self, _=None): ) self.remove_listeners.append(remove_disable) - def _remove_listeners(self): + def _remove_listeners(self) -> None: while self.remove_listeners: remove_listener = self.remove_listeners.pop() remove_listener() @property - def icon(self): + def icon(self) -> str: """Icon to use in the frontend, if any.""" return self._icon @property - def device_state_attributes(self): + def device_state_attributes(self) -> Dict[str, Any]: """Return the attributes of the switch.""" if not self.is_on: return {key: None for key in self._light_settings} @@ -368,7 +375,7 @@ def device_state_attributes(self): async def async_turn_on( self, adapt_lights: bool = True - ): # pylint: disable=arguments-differ + ) -> None: # pylint: disable=arguments-differ """Turn on adaptive lighting.""" _LOGGER.debug( "%s: Called 'async_turn_on', current state is '%s'", self._name, self._state @@ -382,30 +389,20 @@ async def async_turn_on( transition=self._initial_transition, force=True ) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs) -> None: """Turn off adaptive lighting.""" if not self.is_on: return self._state = False self._remove_listeners() - @callback - def _update_attrs(self): - """Update Adaptive Values.""" - _LOGGER.debug("%s: '_update_attrs' called", self._name) - self._light_settings = self._sun_light_settings.get_settings(self._is_sleep()) - self.async_write_ha_state() - - async def _async_update_at_interval(self, now=None): + async def _async_update_at_interval(self, now=None) -> None: await self._update_attrs_and_maybe_adapt_lights(force=False) - def _is_sleep(self): - return ( - self._sleep_entity is not None - and self.hass.states.get(self._sleep_entity).state in self._sleep_state - ) + def _is_sleep(self) -> bool: + return self.sleep_mode_switch.is_on - def _is_disabled(self): + def _is_disabled(self) -> bool: return ( self._disable_entity is not None and self.hass.states.get(self._disable_entity).state in self._disable_state @@ -419,7 +416,7 @@ async def _adapt_light( adapt_color_temp: Optional[bool] = None, adapt_rgb_color: Optional[bool] = None, context: Optional[Context] = None, - ): + ) -> None: lock = self._locks.get(light) if lock is not None and lock.locked(): _LOGGER.debug("%s: '%s' is locked", self._name, light) @@ -476,8 +473,10 @@ async def _update_attrs_and_maybe_adapt_lights( force: bool = False, context: Optional[Context] = None, ): + _LOGGER.debug("%s: '_update_attrs_and_maybe_adapt_lights' called", self._name) assert self.is_on - self._update_attrs() + self._light_settings = self._sun_light_settings.get_settings(self._is_sleep()) + self.async_write_ha_state() if lights is None: lights = self._lights if (self._only_once and not force) or self._is_disabled() or not lights: @@ -510,7 +509,7 @@ async def _disable_state_event(self, event: Event): ) async def _sleep_state_event(self, event: Event): - if not match_state_event(event, self._sleep_state): + if not match_state_event(event, ("on", "off")): return _LOGGER.debug("%s: _sleep_state_event, event: '%s'", self._name, event) await self._update_attrs_and_maybe_adapt_lights( @@ -563,7 +562,56 @@ async def _light_event(self, event: Event): self._on_to_off_event[entity_id] = event -@dataclass +class AdaptiveSleepModeSwitch(SwitchEntity, RestoreEntity): + """Representation of a Adaptive Lighting switch.""" + + def __init__(self, hass, config_entry): + """Initialize the Adaptive Lighting switch.""" + self.hass = hass + data = validate(config_entry) + self._name = data[CONF_NAME] + self._icon = ICON + self._entity_id = f"switch.{DOMAIN}_sleep_mode_{slugify(self._name)}" + self._state = None + + @property + def entity_id(self): + """Return the entity ID of the switch.""" + return self._entity_id + + @property + def name(self): + """Return the name of the device if any.""" + return f"Adaptive Lighting Sleep Mode: {self._name}" + + @property + def icon(self) -> str: + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def is_on(self) -> Optional[bool]: + """Return true if adaptive lighting is on.""" + return self._state + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + last_state = await self.async_get_last_state() + if last_state is None or STATE_OFF: # newly added to HA + await self.async_turn_off() + else: + await self.async_turn_on() + + async def async_turn_on(self) -> None: + """Turn on adaptive lighting sleep mode.""" + self._state = True + + async def async_turn_off(self) -> None: + """Turn off adaptive lighting sleep mode.""" + self._state = False + + +@dataclass(frozen=True) class SunLightSettings: """Track the state of the sun and associated light settings.""" From fa8275391b9781e2469d94501cc0ae1ffa93ba02 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 2 Oct 2020 19:32:52 +0200 Subject: [PATCH 060/165] add note in strings.json --- homeassistant/components/adaptive_lighting/strings.json | 2 +- .../components/adaptive_lighting/translations/en.json | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index f0c29619409502..e1b80cb442e240 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -40,7 +40,7 @@ "sunrise_time": "sunrise_time, in 'HH:MM:SS' format", "sunset_offset": "sunset_offset, in +/- seconds", "sunset_time": "sunset_time, in 'HH:MM:SS' format", - "take_over_control": "take_over_control, if manually adjusting the lights when they are already on", + "take_over_control": "take_over_control, (NOT YET IMPLEMENTED!) if manually adjusting the lights when they are already on", "transition": "transition, in seconds" } } diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index 17fbec0199a460..e1b80cb442e240 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -36,13 +36,11 @@ "prefer_rgb_color": "prefer_rgb_color, use 'rgb_color' over 'color_temp' when possible", "sleep_brightness": "sleep_brightness, in %", "sleep_color_temp": "sleep_color_temp, in Kelvin", - "sleep_entity": "sleep_entity, 'entity_id' that manages the switch's sleep mode", - "sleep_state": "sleep_state, state(s) of 'sleep_entity', e.g., 'off' or 'total,half'", "sunrise_offset": "sunrise_offset, in +/- seconds", "sunrise_time": "sunrise_time, in 'HH:MM:SS' format", "sunset_offset": "sunset_offset, in +/- seconds", "sunset_time": "sunset_time, in 'HH:MM:SS' format", - "take_over_control": "take_over_control, if manually adjusting the lights when they are already on", + "take_over_control": "take_over_control, (NOT YET IMPLEMENTED!) if manually adjusting the lights when they are already on", "transition": "transition, in seconds" } } From 52aad67541d3f5db1ef8a68d6fd806295a6c7af3 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 2 Oct 2020 19:36:41 +0200 Subject: [PATCH 061/165] deprecate disable_entity and disable_state --- .../adaptive_lighting/config_flow.py | 5 +--- .../components/adaptive_lighting/const.py | 6 ---- .../components/adaptive_lighting/strings.json | 4 +-- .../components/adaptive_lighting/switch.py | 29 ++----------------- .../adaptive_lighting/translations/en.json | 2 -- 5 files changed, 4 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/config_flow.py b/homeassistant/components/adaptive_lighting/config_flow.py index f68a412bd4a59b..bb75cc07bd6288 100644 --- a/homeassistant/components/adaptive_lighting/config_flow.py +++ b/homeassistant/components/adaptive_lighting/config_flow.py @@ -8,7 +8,6 @@ import homeassistant.helpers.config_validation as cv from .const import ( # pylint: disable=unused-import - CONF_DISABLE_ENTITY, CONF_LIGHTS, DOMAIN, EXTRA_VALIDATION, @@ -63,8 +62,8 @@ def validate_options(user_input, errors): """ for key, (validate, _) in EXTRA_VALIDATION.items(): # these are unserializable validators + value = user_input.get(key) try: - value = user_input.get(key) if value is not None and value != NONE_STR: validate(value) except vol.Invalid: @@ -91,10 +90,8 @@ async def async_step_init(self, user_input=None): return self.async_create_entry(title="", data=user_input) all_lights = sorted(self.hass.states.async_entity_ids("light")) - all_entities = sorted(self.hass.states.async_entity_ids()) to_replace = { CONF_LIGHTS: cv.multi_select(all_lights), - CONF_DISABLE_ENTITY: vol.In([NONE_STR] + all_entities), } options_schema = {} diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index c0af677b4ad542..6cf2ec9273cfd9 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -15,8 +15,6 @@ CONF_ADAPT_BRIGHTNESS, DEFAULT_ADAPT_BRIGHTNESS = "adapt_brightness", True CONF_ADAPT_COLOR_TEMP, DEFAULT_ADAPT_COLOR_TEMP = "adapt_color_temp", True CONF_ADAPT_RGB_COLOR, DEFAULT_ADAPT_RGB_COLOR = "adapt_rgb_color", True -CONF_DISABLE_ENTITY = "disable_entity" -CONF_DISABLE_STATE = "disable_state" CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION = "initial_transition", 1 CONF_INTERVAL, DEFAULT_INTERVAL = "interval", 90 CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS = "max_brightness", 100 @@ -54,8 +52,6 @@ def int_between(min_int, max_int): (CONF_ADAPT_BRIGHTNESS, DEFAULT_ADAPT_BRIGHTNESS, bool), (CONF_ADAPT_COLOR_TEMP, DEFAULT_ADAPT_COLOR_TEMP, bool), (CONF_ADAPT_RGB_COLOR, DEFAULT_ADAPT_RGB_COLOR, bool), - (CONF_DISABLE_ENTITY, NONE_STR, cv.entity_id), - (CONF_DISABLE_STATE, NONE_STR, str), (CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION), (CONF_INTERVAL, DEFAULT_INTERVAL, cv.positive_int), (CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS, int_between(1, 100)), @@ -91,8 +87,6 @@ def join_strings(lst): # conf_option: (validator, coerce) tuples # these validators cannot be serialized but can be serialized when coerced by coerce. EXTRA_VALIDATION = { - CONF_DISABLE_ENTITY: (cv.entity_id, str), - CONF_DISABLE_STATE: (vol.All(cv.ensure_list_csv, [cv.string]), join_strings), CONF_INTERVAL: (cv.time_period, timedelta_as_int), CONF_SUNRISE_OFFSET: (cv.time_period, timedelta_as_int), CONF_SUNRISE_TIME: (cv.time, str), diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index e1b80cb442e240..e89e0b5a4d88a2 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -24,9 +24,7 @@ "adapt_brightness": "adapt_brightness", "adapt_color_temp": "adapt_color_temp, adapt color temperature using 'color_temp' if supported", "adapt_rgb_color": "adapt_rgb_color, adapt color temperature using RGB/XY if supported", - "disable_entity": "disable_entity, entity_id that stops the switch from adapting lights", - "disable_state": "disable_state, state(s) of 'disable_entity', e.g., 'off' or 'total,half'", - "initial_transition": "initial_transition, when lights go 'off' to 'on' or when 'disable_state'/'sleep_state' changes", + "initial_transition": "initial_transition, when lights go 'off' to 'on' or when 'sleep_state' changes", "interval": "interval, time between switch updates in seconds", "max_brightness": "max_brightness, in %", "max_color_temp": "max_color_temp, in Kelvin", diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index d4bad35bb9165b..b2022ffc7c57b0 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -66,8 +66,6 @@ CONF_ADAPT_BRIGHTNESS, CONF_ADAPT_COLOR_TEMP, CONF_ADAPT_RGB_COLOR, - CONF_DISABLE_ENTITY, - CONF_DISABLE_STATE, CONF_INITIAL_TRANSITION, CONF_INTERVAL, CONF_LIGHTS, @@ -237,8 +235,6 @@ def __init__( self._adapt_brightness = data[CONF_ADAPT_BRIGHTNESS] self._adapt_color_temp = data[CONF_ADAPT_COLOR_TEMP] self._adapt_rgb_color = data[CONF_ADAPT_RGB_COLOR] - self._disable_entity = data[CONF_DISABLE_ENTITY] - self._disable_state = data[CONF_DISABLE_STATE] self._initial_transition = data[CONF_INITIAL_TRANSITION] self._interval = data[CONF_INTERVAL] self._only_once = data[CONF_ONLY_ONCE] @@ -348,13 +344,6 @@ async def _setup_listeners(self, _=None) -> None: self.hass, self._lights, self._light_event ) self.remove_listeners.append(remove_state) - if self._disable_entity is not None: - remove_disable = async_track_state_change_event( - self.hass, - self._disable_entity, - self._disable_state_event, - ) - self.remove_listeners.append(remove_disable) def _remove_listeners(self) -> None: while self.remove_listeners: @@ -402,12 +391,6 @@ async def _async_update_at_interval(self, now=None) -> None: def _is_sleep(self) -> bool: return self.sleep_mode_switch.is_on - def _is_disabled(self) -> bool: - return ( - self._disable_entity is not None - and self.hass.states.get(self._disable_entity).state in self._disable_state - ) - async def _adapt_light( self, light: str, @@ -479,7 +462,7 @@ async def _update_attrs_and_maybe_adapt_lights( self.async_write_ha_state() if lights is None: lights = self._lights - if (self._only_once and not force) or self._is_disabled() or not lights: + if (self._only_once and not force) or not lights: return await self._adapt_lights(lights, transition, context) @@ -500,14 +483,6 @@ async def _adapt_lights( continue await self._adapt_light(light, transition, context=context) - async def _disable_state_event(self, event: Event): - if not match_state_event(event, self._disable_state): - return - _LOGGER.debug("%s: _disable_state_event, event: '%s'", self._name, event) - await self._update_attrs_and_maybe_adapt_lights( - transition=self._initial_transition, force=True, context=event.context - ) - async def _sleep_state_event(self, event: Event): if not match_state_event(event, ("on", "off")): return @@ -866,7 +841,7 @@ async def maybe_cancel_adjusting( # Here we could just `return True` but because we want to prevent any updates # from happening to this light (through async_track_time_interval or - # sleep_state or disable_state) for some time, we wait below until the light + # sleep_state) for some time, we wait below until the light # is 'off' or the time has passed. delay -= delta_time # delta_time has passed since the 'off' → 'on' event diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index e1b80cb442e240..fcf38d3d5c1bd3 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -24,8 +24,6 @@ "adapt_brightness": "adapt_brightness", "adapt_color_temp": "adapt_color_temp, adapt color temperature using 'color_temp' if supported", "adapt_rgb_color": "adapt_rgb_color, adapt color temperature using RGB/XY if supported", - "disable_entity": "disable_entity, entity_id that stops the switch from adapting lights", - "disable_state": "disable_state, state(s) of 'disable_entity', e.g., 'off' or 'total,half'", "initial_transition": "initial_transition, when lights go 'off' to 'on' or when 'disable_state'/'sleep_state' changes", "interval": "interval, time between switch updates in seconds", "max_brightness": "max_brightness, in %", From 30b6c0cc367455adbec22b999795b7bfa89722c0 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 2 Oct 2020 19:40:17 +0200 Subject: [PATCH 062/165] style --- .../components/adaptive_lighting/switch.py | 21 ++++++++++--------- .../adaptive_lighting/translations/en.json | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index b2022ffc7c57b0..8104fc931076d5 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -327,17 +327,19 @@ async def _setup_listeners(self, _=None) -> None: if not self.is_on or not self.hass.is_running: _LOGGER.debug("%s: Cancelled '_setup_listeners'", self._name) return + assert not self.remove_listeners + remove_interval = async_track_time_interval( self.hass, self._async_update_at_interval, self._interval ) - self.remove_listeners.append(remove_interval) remove_sleep = async_track_state_change_event( self.hass, self.sleep_mode_switch.entity_id, self._sleep_state_event, ) - self.remove_listeners.append(remove_sleep) + self.remove_listeners.extend([remove_interval, remove_sleep]) + if self._lights: self._expand_light_groups() remove_state = async_track_state_change_event( @@ -362,9 +364,9 @@ def device_state_attributes(self) -> Dict[str, Any]: return {key: None for key in self._light_settings} return self._light_settings - async def async_turn_on( + async def async_turn_on( # pylint: disable=arguments-differ self, adapt_lights: bool = True - ) -> None: # pylint: disable=arguments-differ + ) -> None: """Turn on adaptive lighting.""" _LOGGER.debug( "%s: Called 'async_turn_on', current state is '%s'", self._name, self._state @@ -388,9 +390,6 @@ async def async_turn_off(self, **kwargs) -> None: async def _async_update_at_interval(self, now=None) -> None: await self._update_attrs_and_maybe_adapt_lights(force=False) - def _is_sleep(self) -> bool: - return self.sleep_mode_switch.is_on - async def _adapt_light( self, light: str, @@ -458,7 +457,9 @@ async def _update_attrs_and_maybe_adapt_lights( ): _LOGGER.debug("%s: '_update_attrs_and_maybe_adapt_lights' called", self._name) assert self.is_on - self._light_settings = self._sun_light_settings.get_settings(self._is_sleep()) + self._light_settings = self._sun_light_settings.get_settings( + self.sleep_mode_switch.is_on + ) self.async_write_ha_state() if lights is None: lights = self._lights @@ -577,11 +578,11 @@ async def async_added_to_hass(self) -> None: else: await self.async_turn_on() - async def async_turn_on(self) -> None: + async def async_turn_on(self) -> None: # pylint: disable=arguments-differ """Turn on adaptive lighting sleep mode.""" self._state = True - async def async_turn_off(self) -> None: + async def async_turn_off(self) -> None: # pylint: disable=arguments-differ """Turn off adaptive lighting sleep mode.""" self._state = False diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index fcf38d3d5c1bd3..e89e0b5a4d88a2 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -24,7 +24,7 @@ "adapt_brightness": "adapt_brightness", "adapt_color_temp": "adapt_color_temp, adapt color temperature using 'color_temp' if supported", "adapt_rgb_color": "adapt_rgb_color, adapt color temperature using RGB/XY if supported", - "initial_transition": "initial_transition, when lights go 'off' to 'on' or when 'disable_state'/'sleep_state' changes", + "initial_transition": "initial_transition, when lights go 'off' to 'on' or when 'sleep_state' changes", "interval": "interval, time between switch updates in seconds", "max_brightness": "max_brightness, in %", "max_color_temp": "max_color_temp, in Kelvin", From 1b6432a50dd9e87540304161c1882d1139972755 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 2 Oct 2020 20:52:43 +0200 Subject: [PATCH 063/165] add 'unique_id' --- .../components/adaptive_lighting/switch.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 8104fc931076d5..f60d7fc7bf5b42 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -296,6 +296,11 @@ def name(self): """Return the name of the device if any.""" return f"Adaptive Lighting: {self._name}" + @property + def unique_id(self): + """Return the unique ID of entity.""" + return self._name + @property def is_on(self) -> Optional[bool]: """Return true if adaptive lighting is on.""" @@ -560,6 +565,11 @@ def name(self): """Return the name of the device if any.""" return f"Adaptive Lighting Sleep Mode: {self._name}" + @property + def unique_id(self): + """Return the unique ID of entity.""" + return f"{self._name}_sleep_mode" + @property def icon(self) -> str: """Icon to use in the frontend, if any.""" @@ -578,11 +588,11 @@ async def async_added_to_hass(self) -> None: else: await self.async_turn_on() - async def async_turn_on(self) -> None: # pylint: disable=arguments-differ + async def async_turn_on(self, **kwargs) -> None: """Turn on adaptive lighting sleep mode.""" self._state = True - async def async_turn_off(self) -> None: # pylint: disable=arguments-differ + async def async_turn_off(self, **kwargs) -> None: """Turn off adaptive lighting sleep mode.""" self._state = False From 3ca719b655d208e70c120eb6fd96a0a6be83c072 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 3 Oct 2020 00:40:46 +0200 Subject: [PATCH 064/165] uncomment unique_id because of exception: ``` 2020-10-02 15:39:49 ERROR (MainThread) [homeassistant.components.switch] Error while setting up adaptive_lighting platform for switch Traceback (most recent call last): File "/usr/src/homeassistant/homeassistant/helpers/entity_platform.py", line 192, in _async_setup_platform await asyncio.gather(*pending) File "/usr/src/homeassistant/homeassistant/helpers/entity_platform.py", line 301, in async_add_entities await asyncio.gather(*tasks) File "/usr/src/homeassistant/homeassistant/helpers/entity_platform.py", line 405, in _async_add_entity entity.entity_id = entry.entity_id AttributeError: can't set attribute ``` --- .../components/adaptive_lighting/switch.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index f60d7fc7bf5b42..25c10a706c8d9b 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -296,10 +296,10 @@ def name(self): """Return the name of the device if any.""" return f"Adaptive Lighting: {self._name}" - @property - def unique_id(self): - """Return the unique ID of entity.""" - return self._name + # @property + # def unique_id(self): + # """Return the unique ID of entity.""" + # return self._name @property def is_on(self) -> Optional[bool]: @@ -565,10 +565,10 @@ def name(self): """Return the name of the device if any.""" return f"Adaptive Lighting Sleep Mode: {self._name}" - @property - def unique_id(self): - """Return the unique ID of entity.""" - return f"{self._name}_sleep_mode" + # @property + # def unique_id(self): + # """Return the unique ID of entity.""" + # return f"{self._name}_sleep_mode" @property def icon(self) -> str: From 759f5852acf5be07d7038b4dca8649b20c6e5ba1 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 3 Oct 2020 14:44:24 +0200 Subject: [PATCH 065/165] implement "take_over_control" feature --- .../components/adaptive_lighting/const.py | 2 +- .../components/adaptive_lighting/strings.json | 2 +- .../components/adaptive_lighting/switch.py | 76 ++++++++++++++----- .../adaptive_lighting/translations/en.json | 2 +- 4 files changed, 62 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index 6cf2ec9273cfd9..5b11e768b78ccd 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -29,7 +29,7 @@ CONF_SUNRISE_TIME = "sunrise_time" CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET = "sunset_offset", 0 CONF_SUNSET_TIME = "sunset_time" -CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", True +CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", False CONF_TRANSITION, DEFAULT_TRANSITION = "transition", 60 ATTR_TURN_ON_OFF_LISTENER = "turn_on_off_listener" diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index e89e0b5a4d88a2..f9a82c9822204c 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -38,7 +38,7 @@ "sunrise_time": "sunrise_time, in 'HH:MM:SS' format", "sunset_offset": "sunset_offset, in +/- seconds", "sunset_time": "sunset_time, in 'HH:MM:SS' format", - "take_over_control": "take_over_control, (NOT YET IMPLEMENTED!) if manually adjusting the lights when they are already on", + "take_over_control": "take_over_control, (beta feature) if manually adjusting the lights when they are already on", "transition": "transition, in seconds" } } diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 25c10a706c8d9b..358bb7779c67d9 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -127,7 +127,6 @@ async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall): data[CONF_ADAPT_BRIGHTNESS], data[CONF_ADAPT_COLOR_TEMP], data[CONF_ADAPT_RGB_COLOR], - service_call.context, ) @@ -269,6 +268,8 @@ def __init__( self._off_to_on_event: Dict[str, Event] = {} # Locks that prevent light adjusting when waiting for a light to 'turn_off' self._locks: Dict[str, asyncio.Lock] = {} + # To identify that this integration made a change + self.__context = Context() # self._context will be overwritten # Set in self._update_attrs_and_maybe_adapt_lights self._light_settings = {} @@ -369,6 +370,10 @@ def device_state_attributes(self) -> Dict[str, Any]: return {key: None for key in self._light_settings} return self._light_settings + def _reset_take_over_control(self): + for light in self._lights: + self.turn_on_off_listener.manually_controlled[light] = False + async def async_turn_on( # pylint: disable=arguments-differ self, adapt_lights: bool = True ) -> None: @@ -379,6 +384,7 @@ async def async_turn_on( # pylint: disable=arguments-differ if self.is_on: return self._state = True + self._reset_take_over_control() await self._setup_listeners() if adapt_lights: await self._update_attrs_and_maybe_adapt_lights( @@ -391,6 +397,7 @@ async def async_turn_off(self, **kwargs) -> None: return self._state = False self._remove_listeners() + self._reset_take_over_control() async def _async_update_at_interval(self, now=None) -> None: await self._update_attrs_and_maybe_adapt_lights(force=False) @@ -402,7 +409,6 @@ async def _adapt_light( adapt_brightness: Optional[bool] = None, adapt_color_temp: Optional[bool] = None, adapt_rgb_color: Optional[bool] = None, - context: Optional[Context] = None, ) -> None: lock = self._locks.get(light) if lock is not None and lock.locked(): @@ -450,7 +456,7 @@ async def _adapt_light( LIGHT_DOMAIN, SERVICE_TURN_ON, service_data, - context=context, + context=self.__context, ) async def _update_attrs_and_maybe_adapt_lights( @@ -458,7 +464,6 @@ async def _update_attrs_and_maybe_adapt_lights( lights: Optional[List[str]] = None, transition: Optional[int] = None, force: bool = False, - context: Optional[Context] = None, ): _LOGGER.debug("%s: '_update_attrs_and_maybe_adapt_lights' called", self._name) assert self.is_on @@ -470,31 +475,42 @@ async def _update_attrs_and_maybe_adapt_lights( lights = self._lights if (self._only_once and not force) or not lights: return - await self._adapt_lights(lights, transition, context) + await self._adapt_lights(lights, transition, force) async def _adapt_lights( - self, lights: List[str], transition: Optional[int], context=Optional[Context] + self, lights: List[str], transition: Optional[int], force: bool ): _LOGGER.debug( - "%s: '_adapt_lights(%s, %s)' called", self.name, lights, transition + "%s: '_adapt_lights(%s, %s, %s)' called", + self.name, + lights, + transition, + force, ) for light in lights: if not is_on(self.hass, light): continue if self._take_over_control: - if await self.turn_on_off_listener.is_manually_adjusted( + if self.turn_on_off_listener.is_manually_controlled( light, - off_to_on_event=self._off_to_on_event.get(light), + force, + adaptive_lighting_context=self.__context, ): + _LOGGER.debug( + "%s: '%s' is being manually controlled, stop adapting.", + self._name, + light, + ) continue - await self._adapt_light(light, transition, context=context) + await self._adapt_light(light, transition) async def _sleep_state_event(self, event: Event): if not match_state_event(event, ("on", "off")): return _LOGGER.debug("%s: _sleep_state_event, event: '%s'", self._name, event) + self._reset_take_over_control() await self._update_attrs_and_maybe_adapt_lights( - transition=self._initial_transition, force=True, context=event.context + transition=self._initial_transition, force=True ) async def _light_event(self, event: Event): @@ -531,7 +547,6 @@ async def _light_event(self, event: Event): lights=[entity_id], transition=self._initial_transition, force=True, - context=event.context, ) elif ( old_state is not None @@ -541,6 +556,7 @@ async def _light_event(self, event: Event): ): # Tracks 'off' → 'on' state changes self._on_to_off_event[entity_id] = event + self.turn_on_off_listener.manually_controlled[entity_id] = False class AdaptiveSleepModeSwitch(SwitchEntity, RestoreEntity): @@ -753,6 +769,8 @@ def __init__(self, hass): self.turn_on_event: Dict[str, Event] = {} # Keeps 'asyncio.sleep` tasks that can be cancelled by 'light.turn_on' events self.sleep_tasks: Dict[str, asyncio.Task] = {} + # Tracks which lights are manually controlled + self.manually_controlled: Dict[str, bool] = {} self.remove_listener = self.hass.bus.async_listen( EVENT_CALL_SERVICE, self.turn_on_off_event_listener @@ -783,6 +801,7 @@ async def turn_on_off_event_listener(self, event: Event): ) for eid in entity_ids: self.turn_off_event[eid] = event + self.manually_controlled[eid] = False elif service == SERVICE_TURN_ON: _LOGGER.debug("Detected an 'light.turn_on('%s')' event", entity_ids) @@ -792,12 +811,35 @@ async def turn_on_off_event_listener(self, event: Event): task.cancel() self.turn_on_event[eid] = event - async def is_manually_adjusted(self, light: str, off_to_on_event: Optional[Event]): + def is_manually_controlled( + self, + light: str, + force: bool, + adaptive_lighting_context: Context, + ): """Check if the light has been 'on' and is now manually being adjusted.""" - if off_to_on_event is None: - # No state change has been registered before, so we can't tell. - return False - return False + manually_controlled = self.manually_controlled[light] + if manually_controlled: + # Manually controlled until light is turned on and off + return True + + turn_on_event = self.turn_on_event.get(light) + if ( + turn_on_event is not None + and adaptive_lighting_context.id != turn_on_event.context.id + and not force + ): + # Light was already on and 'light.turn_on' was not called by + # the adaptive_lighting integration. + manually_controlled = self.manually_controlled[light] = True + _LOGGER.debug( + "'%s' was already on and 'light.turn_on' was not called by the" + " adaptive_lighting integration, the Adaptive Lighting will stop" + " adapting the light until the switch or the light turns off and" + " then on again.", + light, + ) + return manually_controlled async def maybe_cancel_adjusting( self, entity_id: str, off_to_on_event: Event, on_to_off_event: Optional[Event] diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index e89e0b5a4d88a2..f9a82c9822204c 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -38,7 +38,7 @@ "sunrise_time": "sunrise_time, in 'HH:MM:SS' format", "sunset_offset": "sunset_offset, in +/- seconds", "sunset_time": "sunset_time, in 'HH:MM:SS' format", - "take_over_control": "take_over_control, (NOT YET IMPLEMENTED!) if manually adjusting the lights when they are already on", + "take_over_control": "take_over_control, (beta feature) if manually adjusting the lights when they are already on", "transition": "transition, in seconds" } } From b294a8e9dc0f140b093208085fc5292da1886206 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 3 Oct 2020 16:38:36 +0200 Subject: [PATCH 066/165] add async_will_remove_from_hass --- homeassistant/components/adaptive_lighting/__init__.py | 3 --- homeassistant/components/adaptive_lighting/switch.py | 4 ++++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/__init__.py b/homeassistant/components/adaptive_lighting/__init__.py index 88f81dc8fa2c98..b5ec5bc5db6855 100644 --- a/homeassistant/components/adaptive_lighting/__init__.py +++ b/homeassistant/components/adaptive_lighting/__init__.py @@ -28,7 +28,6 @@ import voluptuous as vol -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry import homeassistant.helpers.config_validation as cv @@ -98,8 +97,6 @@ async def async_unload_entry(hass, config_entry: ConfigEntry) -> bool: ) data = hass.data[DOMAIN] data[config_entry.entry_id][UNDO_UPDATE_LISTENER]() - switch = data[config_entry.entry_id][SWITCH_DOMAIN] - switch._remove_listeners() # pylint: disable=protected-access if len(data) == 1: # no more config_entries data.pop(ATTR_TURN_ON_OFF_LISTENER).remove_listener() diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 358bb7779c67d9..aaa8b59ea9fa4c 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -323,6 +323,10 @@ async def async_added_to_hass(self) -> None: self._state = False assert not self.remove_listeners + async def async_will_remove_from_hass(self): + """Remove the listeners upon removing the component.""" + self._remove_listeners() + def _expand_light_groups(self) -> None: all_lights = _expand_light_groups(self.hass, self._lights) self.turn_on_off_listener.lights.update(all_lights) From bf4e9e186f587d700cfbde1db01c7f4027663072 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 3 Oct 2020 16:50:44 +0200 Subject: [PATCH 067/165] remove entity_id and use unique_id --- .../components/adaptive_lighting/switch.py | 29 +++++-------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index aaa8b59ea9fa4c..371c9df3cba703 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -52,7 +52,6 @@ ) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.sun import get_astral_location -from homeassistant.util import slugify from homeassistant.util.color import ( color_RGB_to_xy, color_temperature_kelvin_to_mired, @@ -259,7 +258,6 @@ def __init__( # Set other attributes self._icon = ICON - self._entity_id = f"switch.{DOMAIN}_{slugify(self._name)}" self._state = None # Tracks 'off' → 'on' state changes @@ -287,20 +285,15 @@ def __init__( data, ) - @property - def entity_id(self): - """Return the entity ID of the switch.""" - return self._entity_id - @property def name(self): """Return the name of the device if any.""" return f"Adaptive Lighting: {self._name}" - # @property - # def unique_id(self): - # """Return the unique ID of entity.""" - # return self._name + @property + def unique_id(self): + """Return the unique ID of entity.""" + return self._name @property def is_on(self) -> Optional[bool]: @@ -572,23 +565,17 @@ def __init__(self, hass, config_entry): data = validate(config_entry) self._name = data[CONF_NAME] self._icon = ICON - self._entity_id = f"switch.{DOMAIN}_sleep_mode_{slugify(self._name)}" self._state = None - @property - def entity_id(self): - """Return the entity ID of the switch.""" - return self._entity_id - @property def name(self): """Return the name of the device if any.""" return f"Adaptive Lighting Sleep Mode: {self._name}" - # @property - # def unique_id(self): - # """Return the unique ID of entity.""" - # return f"{self._name}_sleep_mode" + @property + def unique_id(self): + """Return the unique ID of entity.""" + return f"{self._name}_sleep_mode" @property def icon(self) -> str: From ebe60e45f4e369773ef7a0801f565a000d980300 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 3 Oct 2020 17:16:16 +0200 Subject: [PATCH 068/165] set default manually_controlled[light]=False --- homeassistant/components/adaptive_lighting/switch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 371c9df3cba703..2e5820c33ea941 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -809,7 +809,7 @@ def is_manually_controlled( adaptive_lighting_context: Context, ): """Check if the light has been 'on' and is now manually being adjusted.""" - manually_controlled = self.manually_controlled[light] + manually_controlled = self.manually_controlled.setdefault(light, False) if manually_controlled: # Manually controlled until light is turned on and off return True From 79d9a4063d90656c300c24993fba90081b06a7f6 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 3 Oct 2020 18:42:52 +0200 Subject: [PATCH 069/165] use cv.ensure_list --- homeassistant/components/adaptive_lighting/switch.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 2e5820c33ea941..ca968a32d93e79 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -775,10 +775,7 @@ async def turn_on_off_event_listener(self, event: Event): service = event.data.get(ATTR_SERVICE) service_data = event.data.get(ATTR_SERVICE_DATA, {}) - - entity_ids = service_data.get(ATTR_ENTITY_ID) - if isinstance(entity_ids, str): - entity_ids = [entity_ids] + entity_ids = cv.ensure_list(service_data[ATTR_ENTITY_ID]) if not any(eid in self.lights for eid in entity_ids): return From 3a933cd14f04e78746ca426edca7e2696451365c Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 3 Oct 2020 18:51:26 +0200 Subject: [PATCH 070/165] use __getitem__ --- homeassistant/components/adaptive_lighting/switch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index ca968a32d93e79..900085a45e097c 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -773,8 +773,8 @@ async def turn_on_off_event_listener(self, event: Event): if domain != LIGHT_DOMAIN: return - service = event.data.get(ATTR_SERVICE) - service_data = event.data.get(ATTR_SERVICE_DATA, {}) + service = event.data[ATTR_SERVICE] + service_data = event.data[ATTR_SERVICE_DATA] entity_ids = cv.ensure_list(service_data[ATTR_ENTITY_ID]) if not any(eid in self.lights for eid in entity_ids): From 428395c7b21a89da64eb50dd02d1f10c1c96c5cf Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 3 Oct 2020 20:59:08 +0200 Subject: [PATCH 071/165] simplify conditional --- .../components/adaptive_lighting/switch.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 900085a45e097c..a5902a724ea83b 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -487,18 +487,20 @@ async def _adapt_lights( for light in lights: if not is_on(self.hass, light): continue - if self._take_over_control: - if self.turn_on_off_listener.is_manually_controlled( + if ( + self._take_over_control + and self.turn_on_off_listener.is_manually_controlled( light, force, adaptive_lighting_context=self.__context, - ): - _LOGGER.debug( - "%s: '%s' is being manually controlled, stop adapting.", - self._name, - light, - ) - continue + ) + ): + _LOGGER.debug( + "%s: '%s' is being manually controlled, stop adapting.", + self._name, + light, + ) + continue await self._adapt_light(light, transition) async def _sleep_state_event(self, event: Event): From f5222e861751a7c2afa76443a44b2731e4526940 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 4 Oct 2020 14:25:24 +0200 Subject: [PATCH 072/165] detect significant changes for lights that are changed outside of HA --- .../components/adaptive_lighting/switch.py | 123 ++++++++++++++++-- 1 file changed, 109 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index a5902a724ea83b..aec93ff292d92a 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -14,6 +14,7 @@ import voluptuous as vol from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, @@ -126,6 +127,7 @@ async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall): data[CONF_ADAPT_BRIGHTNESS], data[CONF_ADAPT_COLOR_TEMP], data[CONF_ADAPT_RGB_COLOR], + force=True, ) @@ -211,6 +213,11 @@ def _supported_features(hass, light: str): return {key for key, value in _SUPPORT_OPTS.items() if supported_features & value} +def abs_rel_diff(a, b): + """Absolute relative difference in %.""" + return abs((a - b) / b) * 100 + + class AdaptiveSwitch(SwitchEntity, RestoreEntity): """Representation of a Adaptive Lighting switch.""" @@ -238,7 +245,9 @@ def __init__( self._only_once = data[CONF_ONLY_ONCE] self._prefer_rgb_color = data[CONF_PREFER_RGB_COLOR] self._take_over_control = data[CONF_TAKE_OVER_CONTROL] - self._transition = data[CONF_TRANSITION] + self._transition = min( + data[CONF_TRANSITION], self._interval.total_seconds() // 2 + ) self._sun_light_settings = SunLightSettings( name=self._name, @@ -270,7 +279,7 @@ def __init__( self.__context = Context() # self._context will be overwritten # Set in self._update_attrs_and_maybe_adapt_lights - self._light_settings = {} + self._settings: Dict[str, Any] = {} # Set and unset tracker in async_turn_on and async_turn_off self.remove_listeners = [] @@ -364,12 +373,11 @@ def icon(self) -> str: def device_state_attributes(self) -> Dict[str, Any]: """Return the attributes of the switch.""" if not self.is_on: - return {key: None for key in self._light_settings} - return self._light_settings + return {key: None for key in self._settings} + return self._settings def _reset_take_over_control(self): - for light in self._lights: - self.turn_on_off_listener.manually_controlled[light] = False + self.turn_on_off_listener.reset(*self._lights) async def async_turn_on( # pylint: disable=arguments-differ self, adapt_lights: bool = True @@ -406,6 +414,7 @@ async def _adapt_light( adapt_brightness: Optional[bool] = None, adapt_color_temp: Optional[bool] = None, adapt_rgb_color: Optional[bool] = None, + force: bool = False, ) -> None: lock = self._locks.get(light) if lock is not None and lock.locked(): @@ -428,7 +437,7 @@ async def _adapt_light( service_data[ATTR_TRANSITION] = transition if "brightness" in features and adapt_brightness: - service_data[ATTR_BRIGHTNESS_PCT] = self._light_settings["brightness_pct"] + service_data[ATTR_BRIGHTNESS_PCT] = self._settings["brightness_pct"] if ( "color_temp" in features @@ -437,18 +446,29 @@ async def _adapt_light( ): attributes = self.hass.states.get(light).attributes min_mireds, max_mireds = attributes["min_mireds"], attributes["max_mireds"] - color_temp_mired = self._light_settings["color_temp_mired"] + color_temp_mired = self._settings["color_temp_mired"] color_temp_mired = max(min(color_temp_mired, max_mireds), min_mireds) service_data[ATTR_COLOR_TEMP] = color_temp_mired elif "color" in features and adapt_rgb_color: - service_data[ATTR_RGB_COLOR] = self._light_settings["rgb_color"] + service_data[ATTR_RGB_COLOR] = self._settings["rgb_color"] + if ( + self._take_over_control + and not force + and self.turn_on_off_listener.significant_change( + light, + self._adapt_brightness, + self._adapt_color_temp, + self._adapt_rgb_color, + ) + ): + return + self.turn_on_off_listener.last_service_data[light] = service_data _LOGGER.debug( "%s: Scheduling 'light.turn_on' with the following 'service_data': %s", self._name, service_data, ) - await self.hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -464,7 +484,7 @@ async def _update_attrs_and_maybe_adapt_lights( ): _LOGGER.debug("%s: '_update_attrs_and_maybe_adapt_lights' called", self._name) assert self.is_on - self._light_settings = self._sun_light_settings.get_settings( + self._settings = self._sun_light_settings.get_settings( self.sleep_mode_switch.is_on ) self.async_write_ha_state() @@ -501,7 +521,7 @@ async def _adapt_lights( light, ) continue - await self._adapt_light(light, transition) + await self._adapt_light(light, transition, force=force) async def _sleep_state_event(self, event: Event): if not match_state_event(event, ("on", "off")): @@ -555,7 +575,7 @@ async def _light_event(self, event: Event): ): # Tracks 'off' → 'on' state changes self._on_to_off_event[entity_id] = event - self.turn_on_off_listener.manually_controlled[entity_id] = False + self.turn_on_off_listener.reset(entity_id) class AdaptiveSleepModeSwitch(SwitchEntity, RestoreEntity): @@ -764,11 +784,19 @@ def __init__(self, hass): self.sleep_tasks: Dict[str, asyncio.Task] = {} # Tracks which lights are manually controlled self.manually_controlled: Dict[str, bool] = {} + # Track which settings were applied to a light + self.last_service_data: Dict[str, Dict[str, Any]] = {} self.remove_listener = self.hass.bus.async_listen( EVENT_CALL_SERVICE, self.turn_on_off_event_listener ) + def reset(self, *lights): + """Reset the 'manually_controlled' status of the lights.""" + for light in lights: + self.manually_controlled[light] = False + self.last_service_data.pop(light, None) + async def turn_on_off_event_listener(self, event: Event): """Track 'light.turn_off' and 'light.turn_on' service calls.""" domain = event.data.get(ATTR_DOMAIN) @@ -791,7 +819,7 @@ async def turn_on_off_event_listener(self, event: Event): ) for eid in entity_ids: self.turn_off_event[eid] = event - self.manually_controlled[eid] = False + self.reset(eid) elif service == SERVICE_TURN_ON: _LOGGER.debug("Detected an 'light.turn_on('%s')' event", entity_ids) @@ -831,6 +859,73 @@ def is_manually_controlled( ) return manually_controlled + def significant_change( + self, light, adapt_brightness, adapt_color_temp, adapt_rgb_color, threshold=5 + ): + """Has the light made a significant change since last update. + + This method will detect changes that were made to the light without + calling 'light.turn_on', so outside of Home Assistant. If a change is + detected, we mark the light as 'manually_controlled' until the light + or switch is turned 'off' and 'on' again. + """ + if light not in self.last_service_data: + return False + changed = False + service_data = self.last_service_data[light] + attributes = self.hass.states.get(light).attributes + if ( + adapt_brightness + and ATTR_BRIGHTNESS_PCT in service_data + and ATTR_BRIGHTNESS in attributes + ): + applied_brightness = round(255 * service_data[ATTR_BRIGHTNESS_PCT] / 100) + current_brightness = attributes["brightness"] + if abs_rel_diff(current_brightness, applied_brightness) > threshold: + _LOGGER.debug("Brightness of '%s' significantly changed", light) + changed = True + + if ( + adapt_color_temp + and ATTR_COLOR_TEMP in service_data + and ATTR_COLOR_TEMP in attributes + ): + applied_color_temp = service_data[ATTR_COLOR_TEMP] + current_color_temp = attributes[ATTR_COLOR_TEMP] + if abs_rel_diff(current_color_temp, applied_color_temp) > threshold: + _LOGGER.debug( + "Color temperature of '%s' significantly changed", + light, + ) + changed = True + + if ( + adapt_rgb_color + and ATTR_RGB_COLOR in service_data + and ATTR_RGB_COLOR in attributes + ): + applied_rgb_color = service_data[ATTR_RGB_COLOR] + current_rgb_color = attributes[ATTR_RGB_COLOR] + for col_applied, col_current in zip(applied_rgb_color, current_rgb_color): + if abs_rel_diff(col_applied, col_current) > threshold: + _LOGGER.debug( + "color RGB of '%s' significantly changed", + light, + ) + changed = True + + if (ATTR_RGB_COLOR in service_data and ATTR_RGB_COLOR not in attributes) or ( + ATTR_COLOR_TEMP in service_data and ATTR_COLOR_TEMP not in attributes + ): + # Light switched from RGB mode to color_temp or visa versa + _LOGGER.debug( + "'%s' switched from RGB mode to color_temp or visa versa", + light, + ) + changed = True + self.manually_controlled[light] = changed + return changed + async def maybe_cancel_adjusting( self, entity_id: str, off_to_on_event: Event, on_to_off_event: Optional[Event] ) -> bool: From 1be93f1341587abaf2551ac3eeacde2026679890 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 4 Oct 2020 14:51:05 +0200 Subject: [PATCH 073/165] add "detect_non_ha_changes" option --- .../components/adaptive_lighting/const.py | 7 ++++++- .../components/adaptive_lighting/strings.json | 3 ++- .../components/adaptive_lighting/switch.py | 18 ++++++++++++++++-- .../adaptive_lighting/translations/en.json | 3 ++- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index 5b11e768b78ccd..3d749503f9a7d8 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -15,6 +15,10 @@ CONF_ADAPT_BRIGHTNESS, DEFAULT_ADAPT_BRIGHTNESS = "adapt_brightness", True CONF_ADAPT_COLOR_TEMP, DEFAULT_ADAPT_COLOR_TEMP = "adapt_color_temp", True CONF_ADAPT_RGB_COLOR, DEFAULT_ADAPT_RGB_COLOR = "adapt_rgb_color", True +CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES = ( + "detect_non_ha_changes", + False, +) CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION = "initial_transition", 1 CONF_INTERVAL, DEFAULT_INTERVAL = "interval", 90 CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS = "max_brightness", 100 @@ -29,7 +33,7 @@ CONF_SUNRISE_TIME = "sunrise_time" CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET = "sunset_offset", 0 CONF_SUNSET_TIME = "sunset_time" -CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", False +CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", True CONF_TRANSITION, DEFAULT_TRANSITION = "transition", 60 ATTR_TURN_ON_OFF_LISTENER = "turn_on_off_listener" @@ -52,6 +56,7 @@ def int_between(min_int, max_int): (CONF_ADAPT_BRIGHTNESS, DEFAULT_ADAPT_BRIGHTNESS, bool), (CONF_ADAPT_COLOR_TEMP, DEFAULT_ADAPT_COLOR_TEMP, bool), (CONF_ADAPT_RGB_COLOR, DEFAULT_ADAPT_RGB_COLOR, bool), + (CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES, bool), (CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION), (CONF_INTERVAL, DEFAULT_INTERVAL, cv.positive_int), (CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS, int_between(1, 100)), diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index f9a82c9822204c..d0ad5ce987db54 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -38,7 +38,8 @@ "sunrise_time": "sunrise_time, in 'HH:MM:SS' format", "sunset_offset": "sunset_offset, in +/- seconds", "sunset_time": "sunset_time, in 'HH:MM:SS' format", - "take_over_control": "take_over_control, (beta feature) if manually adjusting the lights when they are already on", + "take_over_control": "take_over_control, if manually adjusting the lights when they are already on", + "detect_non_ha_changes": "detect_non_ha_changes, detect changes to lights made outside of HA (calls 'homeassistant.update_entity'!)", "transition": "transition, in seconds" } } diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index aec93ff292d92a..6867a59ecfa106 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -13,6 +13,10 @@ import astral import voluptuous as vol +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, @@ -66,6 +70,7 @@ CONF_ADAPT_BRIGHTNESS, CONF_ADAPT_COLOR_TEMP, CONF_ADAPT_RGB_COLOR, + CONF_DETECT_NON_HA_CHANGES, CONF_INITIAL_TRANSITION, CONF_INTERVAL, CONF_LIGHTS, @@ -240,6 +245,7 @@ def __init__( self._adapt_brightness = data[CONF_ADAPT_BRIGHTNESS] self._adapt_color_temp = data[CONF_ADAPT_COLOR_TEMP] self._adapt_rgb_color = data[CONF_ADAPT_RGB_COLOR] + self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES] self._initial_transition = data[CONF_INITIAL_TRANSITION] self._interval = data[CONF_INTERVAL] self._only_once = data[CONF_ONLY_ONCE] @@ -454,8 +460,9 @@ async def _adapt_light( if ( self._take_over_control + and self._detect_non_ha_changes and not force - and self.turn_on_off_listener.significant_change( + and await self.turn_on_off_listener.significant_change( light, self._adapt_brightness, self._adapt_color_temp, @@ -859,7 +866,7 @@ def is_manually_controlled( ) return manually_controlled - def significant_change( + async def significant_change( self, light, adapt_brightness, adapt_color_temp, adapt_rgb_color, threshold=5 ): """Has the light made a significant change since last update. @@ -873,6 +880,12 @@ def significant_change( return False changed = False service_data = self.last_service_data[light] + await self.hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: light}, + blocking=True, + ) attributes = self.hass.states.get(light).attributes if ( adapt_brightness @@ -913,6 +926,7 @@ def significant_change( light, ) changed = True + break if (ATTR_RGB_COLOR in service_data and ATTR_RGB_COLOR not in attributes) or ( ATTR_COLOR_TEMP in service_data and ATTR_COLOR_TEMP not in attributes diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index f9a82c9822204c..d0ad5ce987db54 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -38,7 +38,8 @@ "sunrise_time": "sunrise_time, in 'HH:MM:SS' format", "sunset_offset": "sunset_offset, in +/- seconds", "sunset_time": "sunset_time, in 'HH:MM:SS' format", - "take_over_control": "take_over_control, (beta feature) if manually adjusting the lights when they are already on", + "take_over_control": "take_over_control, if manually adjusting the lights when they are already on", + "detect_non_ha_changes": "detect_non_ha_changes, detect changes to lights made outside of HA (calls 'homeassistant.update_entity'!)", "transition": "transition, in seconds" } } From d63872ed706ab204f94f42a2860663cb984e8bba Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 4 Oct 2020 15:11:42 +0200 Subject: [PATCH 074/165] change order of options --- homeassistant/components/adaptive_lighting/const.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index 3d749503f9a7d8..e438321fa578fa 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -56,8 +56,8 @@ def int_between(min_int, max_int): (CONF_ADAPT_BRIGHTNESS, DEFAULT_ADAPT_BRIGHTNESS, bool), (CONF_ADAPT_COLOR_TEMP, DEFAULT_ADAPT_COLOR_TEMP, bool), (CONF_ADAPT_RGB_COLOR, DEFAULT_ADAPT_RGB_COLOR, bool), - (CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES, bool), (CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION), + (CONF_TRANSITION, DEFAULT_TRANSITION, VALID_TRANSITION), (CONF_INTERVAL, DEFAULT_INTERVAL, cv.positive_int), (CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS, int_between(1, 100)), (CONF_MAX_COLOR_TEMP, DEFAULT_MAX_COLOR_TEMP, int_between(1000, 10000)), @@ -72,7 +72,7 @@ def int_between(min_int, max_int): (CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET, int), (CONF_SUNSET_TIME, NONE_STR, str), (CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL, bool), - (CONF_TRANSITION, DEFAULT_TRANSITION, VALID_TRANSITION), + (CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES, bool), ] From 0f8732068207148ac3bd2d0e1af2b8ac9cb8d8dd Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 4 Oct 2020 15:17:29 +0200 Subject: [PATCH 075/165] fix pylint issue --- homeassistant/components/adaptive_lighting/switch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 6867a59ecfa106..8e43734bb15cad 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -218,9 +218,9 @@ def _supported_features(hass, light: str): return {key for key, value in _SUPPORT_OPTS.items() if supported_features & value} -def abs_rel_diff(a, b): +def abs_rel_diff(val_a, val_b): """Absolute relative difference in %.""" - return abs((a - b) / b) * 100 + return abs((val_a - val_b) / val_b) * 100 class AdaptiveSwitch(SwitchEntity, RestoreEntity): From 1599a285470b600c0171d12df8e76fb0f43641f8 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 4 Oct 2020 16:16:24 +0200 Subject: [PATCH 076/165] avoid ZeroDivisionError --- homeassistant/components/adaptive_lighting/switch.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 8e43734bb15cad..30a968b601abde 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -220,6 +220,9 @@ def _supported_features(hass, light: str): def abs_rel_diff(val_a, val_b): """Absolute relative difference in %.""" + if val_b == 0: + # To avoid ZeroDivisionError + val_b = 1e-6 return abs((val_a - val_b) / val_b) * 100 From df058777ce4a6c038ee066ec60bdcce33210ed75 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 4 Oct 2020 16:41:52 +0200 Subject: [PATCH 077/165] update strings.json --- homeassistant/components/adaptive_lighting/strings.json | 4 ++-- .../components/adaptive_lighting/translations/en.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index d0ad5ce987db54..d08cbc191b6c7c 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -38,8 +38,8 @@ "sunrise_time": "sunrise_time, in 'HH:MM:SS' format", "sunset_offset": "sunset_offset, in +/- seconds", "sunset_time": "sunset_time, in 'HH:MM:SS' format", - "take_over_control": "take_over_control, if manually adjusting the lights when they are already on", - "detect_non_ha_changes": "detect_non_ha_changes, detect changes to lights made outside of HA (calls 'homeassistant.update_entity'!)", + "take_over_control": "take_over_control, if anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on.", + "detect_non_ha_changes": "detect_non_ha_changes, detects all >5% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)", "transition": "transition, in seconds" } } diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index d0ad5ce987db54..d08cbc191b6c7c 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -38,8 +38,8 @@ "sunrise_time": "sunrise_time, in 'HH:MM:SS' format", "sunset_offset": "sunset_offset, in +/- seconds", "sunset_time": "sunset_time, in 'HH:MM:SS' format", - "take_over_control": "take_over_control, if manually adjusting the lights when they are already on", - "detect_non_ha_changes": "detect_non_ha_changes, detect changes to lights made outside of HA (calls 'homeassistant.update_entity'!)", + "take_over_control": "take_over_control, if anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on.", + "detect_non_ha_changes": "detect_non_ha_changes, detects all >5% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)", "transition": "transition, in seconds" } } From 1599a34cea36c78739e40dfd6a122f753019c913 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 4 Oct 2020 16:54:00 +0200 Subject: [PATCH 078/165] call self.turn_on_off_listener.reset directly --- homeassistant/components/adaptive_lighting/switch.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 30a968b601abde..9977e6af84328e 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -385,9 +385,6 @@ def device_state_attributes(self) -> Dict[str, Any]: return {key: None for key in self._settings} return self._settings - def _reset_take_over_control(self): - self.turn_on_off_listener.reset(*self._lights) - async def async_turn_on( # pylint: disable=arguments-differ self, adapt_lights: bool = True ) -> None: @@ -398,7 +395,7 @@ async def async_turn_on( # pylint: disable=arguments-differ if self.is_on: return self._state = True - self._reset_take_over_control() + self.turn_on_off_listener.reset(*self._lights) await self._setup_listeners() if adapt_lights: await self._update_attrs_and_maybe_adapt_lights( @@ -411,7 +408,7 @@ async def async_turn_off(self, **kwargs) -> None: return self._state = False self._remove_listeners() - self._reset_take_over_control() + self.turn_on_off_listener.reset(*self._lights) async def _async_update_at_interval(self, now=None) -> None: await self._update_attrs_and_maybe_adapt_lights(force=False) @@ -537,7 +534,7 @@ async def _sleep_state_event(self, event: Event): if not match_state_event(event, ("on", "off")): return _LOGGER.debug("%s: _sleep_state_event, event: '%s'", self._name, event) - self._reset_take_over_control() + self.turn_on_off_listener.reset(*self._lights) await self._update_attrs_and_maybe_adapt_lights( transition=self._initial_transition, force=True ) @@ -555,6 +552,7 @@ async def _light_event(self, event: Event): _LOGGER.debug( "%s: Detected an 'off' → 'on' event for '%s'", self._name, entity_id ) + self.turn_on_off_listener.reset(entity_id) # Tracks 'off' → 'on' state changes self._off_to_on_event[entity_id] = event lock = self._locks.get(entity_id) From 6e0b28802f6c9229bce49009b691966bc7385bfa Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 4 Oct 2020 16:58:14 +0200 Subject: [PATCH 079/165] pass context to homeassistant.update_entity --- homeassistant/components/adaptive_lighting/switch.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 9977e6af84328e..4b9973b3a03f5f 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -467,6 +467,7 @@ async def _adapt_light( self._adapt_brightness, self._adapt_color_temp, self._adapt_rgb_color, + self.__context, ) ): return @@ -868,7 +869,13 @@ def is_manually_controlled( return manually_controlled async def significant_change( - self, light, adapt_brightness, adapt_color_temp, adapt_rgb_color, threshold=5 + self, + light, + adapt_brightness, + adapt_color_temp, + adapt_rgb_color, + context, + threshold=5, ): """Has the light made a significant change since last update. @@ -886,6 +893,7 @@ async def significant_change( SERVICE_UPDATE_ENTITY, {ATTR_ENTITY_ID: light}, blocking=True, + context=context, ) attributes = self.hass.states.get(light).attributes if ( From f6cd487fc39b32dca490bf06c06962803587efe3 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 4 Oct 2020 22:57:36 +0200 Subject: [PATCH 080/165] fix case where turn_off_event is None --- homeassistant/components/adaptive_lighting/switch.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 4b9973b3a03f5f..ce0b43c9542d74 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -971,8 +971,7 @@ async def maybe_cancel_adjusting( id_on_to_off = on_to_off_event.context.id turn_off_event = self.turn_off_event.get(entity_id) - id_turn_off = turn_off_event.context.id - transition = turn_off_event.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION) + transition = turn_off_event.data.get(ATTR_SERVICE_DATA, {}).get(ATTR_TRANSITION) turn_on_event = self.turn_on_event.get(entity_id) id_turn_on = turn_on_event.context.id @@ -984,7 +983,8 @@ async def maybe_cancel_adjusting( return False if ( - id_on_to_off == id_turn_off + turn_off_event is not None + and id_on_to_off == turn_off_event.context.id and id_on_to_off is not None and transition is not None # 'turn_off' is called with transition=... ): From fadd945d617cd710739c90b21d1cb999a6f030ee Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 4 Oct 2020 22:58:03 +0200 Subject: [PATCH 081/165] print values in significant_change debug log --- .../components/adaptive_lighting/switch.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index ce0b43c9542d74..0ce86683f38d12 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -904,7 +904,12 @@ async def significant_change( applied_brightness = round(255 * service_data[ATTR_BRIGHTNESS_PCT] / 100) current_brightness = attributes["brightness"] if abs_rel_diff(current_brightness, applied_brightness) > threshold: - _LOGGER.debug("Brightness of '%s' significantly changed", light) + _LOGGER.debug( + "Brightness of '%s' significantly changed from %s to %s", + light, + applied_brightness, + current_brightness, + ) changed = True if ( @@ -916,8 +921,10 @@ async def significant_change( current_color_temp = attributes[ATTR_COLOR_TEMP] if abs_rel_diff(current_color_temp, applied_color_temp) > threshold: _LOGGER.debug( - "Color temperature of '%s' significantly changed", + "Color temperature of '%s' significantly changed from %s to %s", light, + applied_color_temp, + current_color_temp, ) changed = True @@ -931,8 +938,10 @@ async def significant_change( for col_applied, col_current in zip(applied_rgb_color, current_rgb_color): if abs_rel_diff(col_applied, col_current) > threshold: _LOGGER.debug( - "color RGB of '%s' significantly changed", + "color RGB of '%s' significantly changed from %s to %s", light, + applied_rgb_color, + current_rgb_color, ) changed = True break From 854a6d15b077feabb44350122f4db60bb53afc14 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 4 Oct 2020 23:08:12 +0200 Subject: [PATCH 082/165] only show lights that can be controlled usefully --- .../components/adaptive_lighting/config_flow.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/config_flow.py b/homeassistant/components/adaptive_lighting/config_flow.py index bb75cc07bd6288..575d7cd726200f 100644 --- a/homeassistant/components/adaptive_lighting/config_flow.py +++ b/homeassistant/components/adaptive_lighting/config_flow.py @@ -14,6 +14,7 @@ NONE_STR, VALIDATION_TUPLES, ) +from .switch import _supported_features _LOGGER = logging.getLogger(__name__) @@ -89,10 +90,12 @@ async def async_step_init(self, user_input=None): if not errors: return self.async_create_entry(title="", data=user_input) - all_lights = sorted(self.hass.states.async_entity_ids("light")) - to_replace = { - CONF_LIGHTS: cv.multi_select(all_lights), - } + all_lights = [ + light + for light in self.hass.states.async_entity_ids("light") + if _supported_features(self.hass, light) + ] + to_replace = {CONF_LIGHTS: cv.multi_select(sorted(all_lights))} options_schema = {} for name, default, validation in VALIDATION_TUPLES: From 042c964e276922fe4d1fcc409d90781215a11193 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 5 Oct 2020 09:37:11 +0200 Subject: [PATCH 083/165] fix: if turn_off_event is None it has no data attr --- homeassistant/components/adaptive_lighting/switch.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 0ce86683f38d12..4fa0627f7e2983 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -980,7 +980,10 @@ async def maybe_cancel_adjusting( id_on_to_off = on_to_off_event.context.id turn_off_event = self.turn_off_event.get(entity_id) - transition = turn_off_event.data.get(ATTR_SERVICE_DATA, {}).get(ATTR_TRANSITION) + if turn_off_event is not None: + transition = turn_off_event.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION) + else: + transition = None turn_on_event = self.turn_on_event.get(entity_id) id_turn_on = turn_on_event.context.id From a7d51a6d43903900e8466f6100820f9158ba1079 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 5 Oct 2020 12:16:34 +0200 Subject: [PATCH 084/165] register service after async_add_entities --- homeassistant/components/adaptive_lighting/switch.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 4fa0627f7e2983..9272413bdd9146 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -150,6 +150,8 @@ async def async_setup_entry(hass, config_entry: ConfigEntry, async_add_entities: data[config_entry.entry_id]["sleep_mode_switch"] = sleep_mode_switch data[config_entry.entry_id][SWITCH_DOMAIN] = switch + async_add_entities([switch, sleep_mode_switch], update_before_add=True) + # Register `apply` service platform = entity_platform.current_platform.get() platform.async_register_entity_service( @@ -167,7 +169,6 @@ async def async_setup_entry(hass, config_entry: ConfigEntry, async_add_entities: }, handle_apply, ) - async_add_entities([switch, sleep_mode_switch], update_before_add=True) def validate(config_entry): From be8b17e273ab9ef7a37a20e4cbc6fb6759377d17 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 5 Oct 2020 14:40:50 +0200 Subject: [PATCH 085/165] change order in OptionsFlow --- .../components/adaptive_lighting/const.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index e438321fa578fa..d3d7ebed7e3704 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -24,7 +24,7 @@ CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS = "max_brightness", 100 CONF_MAX_COLOR_TEMP, DEFAULT_MAX_COLOR_TEMP = "max_color_temp", 5500 CONF_MIN_BRIGHTNESS, DEFAULT_MIN_BRIGHTNESS = "min_brightness", 1 -CONF_MIN_COLOR_TEMP, DEFAULT_MIN_COLOR_TEMP = "min_color_temp", 2500 +CONF_MIN_COLOR_TEMP, DEFAULT_MIN_COLOR_TEMP = "min_color_temp", 2000 CONF_ONLY_ONCE, DEFAULT_ONLY_ONCE = "only_once", False CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR = "prefer_rgb_color", False CONF_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_BRIGHTNESS = "sleep_brightness", 1 @@ -34,7 +34,7 @@ CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET = "sunset_offset", 0 CONF_SUNSET_TIME = "sunset_time" CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", True -CONF_TRANSITION, DEFAULT_TRANSITION = "transition", 60 +CONF_TRANSITION, DEFAULT_TRANSITION = "transition", 45 ATTR_TURN_ON_OFF_LISTENER = "turn_on_off_listener" UNDO_UPDATE_LISTENER = "undo_update_listener" @@ -59,18 +59,18 @@ def int_between(min_int, max_int): (CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION), (CONF_TRANSITION, DEFAULT_TRANSITION, VALID_TRANSITION), (CONF_INTERVAL, DEFAULT_INTERVAL, cv.positive_int), - (CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS, int_between(1, 100)), - (CONF_MAX_COLOR_TEMP, DEFAULT_MAX_COLOR_TEMP, int_between(1000, 10000)), (CONF_MIN_BRIGHTNESS, DEFAULT_MIN_BRIGHTNESS, int_between(1, 100)), + (CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS, int_between(1, 100)), (CONF_MIN_COLOR_TEMP, DEFAULT_MIN_COLOR_TEMP, int_between(1000, 10000)), - (CONF_ONLY_ONCE, DEFAULT_ONLY_ONCE, bool), + (CONF_MAX_COLOR_TEMP, DEFAULT_MAX_COLOR_TEMP, int_between(1000, 10000)), (CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR, bool), (CONF_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_BRIGHTNESS, int_between(1, 100)), (CONF_SLEEP_COLOR_TEMP, DEFAULT_SLEEP_COLOR_TEMP, int_between(1000, 10000)), - (CONF_SUNRISE_OFFSET, DEFAULT_SUNRISE_OFFSET, int), (CONF_SUNRISE_TIME, NONE_STR, str), - (CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET, int), + (CONF_SUNRISE_OFFSET, DEFAULT_SUNRISE_OFFSET, int), (CONF_SUNSET_TIME, NONE_STR, str), + (CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET, int), + (CONF_ONLY_ONCE, DEFAULT_ONLY_ONCE, bool), (CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL, bool), (CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES, bool), ] From 6249bd5194fbd7c472cda8144aa0e332cc955b73 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 5 Oct 2020 18:38:53 +0200 Subject: [PATCH 086/165] add 'manually_controlled' attribute to the switch --- homeassistant/components/adaptive_lighting/switch.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 9272413bdd9146..69d92b47c62dc2 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -384,7 +384,12 @@ def device_state_attributes(self) -> Dict[str, Any]: """Return the attributes of the switch.""" if not self.is_on: return {key: None for key in self._settings} - return self._settings + manually_controlled = [ + light + for light in self._lights + if self.turn_on_off_listener.manually_controlled.get(light) + ] + return dict(self._settings, manually_controlled=manually_controlled) async def async_turn_on( # pylint: disable=arguments-differ self, adapt_lights: bool = True From 249f81178c997c933aa2c583ffc3d23642cc3f34 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 5 Oct 2020 21:21:46 +0200 Subject: [PATCH 087/165] implement suggestions by iMicknl and KTibow and add more type-annotations --- .../components/adaptive_lighting/__init__.py | 11 +++++---- .../adaptive_lighting/config_flow.py | 11 +++++---- .../components/adaptive_lighting/strings.json | 4 ++-- .../components/adaptive_lighting/switch.py | 24 ++++++++++--------- .../adaptive_lighting/translations/en.json | 4 ++-- 5 files changed, 30 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/__init__.py b/homeassistant/components/adaptive_lighting/__init__.py index b5ec5bc5db6855..64e3654446b055 100644 --- a/homeassistant/components/adaptive_lighting/__init__.py +++ b/homeassistant/components/adaptive_lighting/__init__.py @@ -25,10 +25,13 @@ lights to 2700K (warm white) until your hub goes into "Sleep mode". """ import logging +from typing import Any, Dict import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_SOURCE +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from .const import ( @@ -45,7 +48,7 @@ def _all_unique_names(value): - """Validate that all enties have a unique profile name.""" + """Validate that all entities have a unique profile name.""" hosts = [device[CONF_NAME] for device in value] schema = vol.Schema(vol.Unique()) schema(hosts) @@ -58,20 +61,20 @@ def _all_unique_names(value): ) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: Dict[str, Any]): """Import integration from config.""" if DOMAIN in config: for entry in config[DOMAIN]: hass.async_create_task( hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=entry + DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=entry ) ) return True -async def async_setup_entry(hass, config_entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): """Set up the component.""" data = hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/adaptive_lighting/config_flow.py b/homeassistant/components/adaptive_lighting/config_flow.py index 575d7cd726200f..6bf5e4302ea52b 100644 --- a/homeassistant/components/adaptive_lighting/config_flow.py +++ b/homeassistant/components/adaptive_lighting/config_flow.py @@ -4,6 +4,7 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_NAME from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -29,24 +30,24 @@ async def async_step_user(self, user_input=None): errors = {} if user_input is not None: - await self.async_set_unique_id(user_input["name"]) + await self.async_set_unique_id(user_input[CONF_NAME]) self._abort_if_unique_id_configured() - return self.async_create_entry(title=user_input["name"], data=user_input) + return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) return self.async_show_form( step_id="user", - data_schema=vol.Schema({vol.Required("name"): str}), + data_schema=vol.Schema({vol.Required(CONF_NAME): str}), errors=errors, ) async def async_step_import(self, user_input=None): """Handle configuration by yaml file.""" - await self.async_set_unique_id(user_input["name"]) + await self.async_set_unique_id(user_input[CONF_NAME]) for entry in self._async_current_entries(): if entry.unique_id == self.unique_id: self.hass.config_entries.async_update_entry(entry, data=user_input) self._abort_if_unique_id_configured() - return self.async_create_entry(title=user_input["name"], data=user_input) + return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) @staticmethod @callback diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index d08cbc191b6c7c..57949ea07e9c43 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -6,12 +6,12 @@ "title": "Choose a name for the Adaptive Lighting", "description": "Every instance can contain multiple lights!", "data": { - "name": "Name" + "name": "[%key:common::config_flow::data::name%]" } } }, "abort": { - "already_configured": "This name is already configured." + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, "options": { diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 69d92b47c62dc2..b809d1e6867811 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -48,7 +48,7 @@ SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, ) -from homeassistant.core import Context, Event, ServiceCall +from homeassistant.core import Context, Event, HomeAssistant, ServiceCall from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import ( @@ -136,7 +136,9 @@ async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall): ) -async def async_setup_entry(hass, config_entry: ConfigEntry, async_add_entities: bool): +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: bool +): """Set up the AdaptiveLighting switch.""" data = hass.data[DOMAIN] @@ -197,7 +199,7 @@ def match_state_event(event: Event, from_or_to_state: List[str]): return match -def _expand_light_groups(hass, lights: List[str]) -> List[str]: +def _expand_light_groups(hass: HomeAssistant, lights: List[str]) -> List[str]: all_lights = set() for light in lights: state = hass.states.get(light) @@ -213,13 +215,13 @@ def _expand_light_groups(hass, lights: List[str]) -> List[str]: return list(all_lights) -def _supported_features(hass, light: str): +def _supported_features(hass: HomeAssistant, light: str): state = hass.states.get(light) supported_features = state.attributes["supported_features"] return {key for key, value in _SUPPORT_OPTS.items() if supported_features & value} -def abs_rel_diff(val_a, val_b): +def abs_rel_diff(val_a, val_b) -> float: """Absolute relative difference in %.""" if val_b == 0: # To avoid ZeroDivisionError @@ -538,7 +540,7 @@ async def _adapt_lights( await self._adapt_light(light, transition, force=force) async def _sleep_state_event(self, event: Event): - if not match_state_event(event, ("on", "off")): + if not match_state_event(event, (STATE_ON, STATE_OFF)): return _LOGGER.debug("%s: _sleep_state_event, event: '%s'", self._name, event) self.turn_on_off_listener.reset(*self._lights) @@ -552,9 +554,9 @@ async def _light_event(self, event: Event): entity_id = event.data.get("entity_id") if ( old_state is not None - and old_state.state == "off" + and old_state.state == STATE_OFF and new_state is not None - and new_state.state == "on" + and new_state.state == STATE_ON ): _LOGGER.debug( "%s: Detected an 'off' → 'on' event for '%s'", self._name, entity_id @@ -584,9 +586,9 @@ async def _light_event(self, event: Event): ) elif ( old_state is not None - and old_state.state == "on" + and old_state.state == STATE_ON and new_state is not None - and new_state.state == "off" + and new_state.state == STATE_OFF ): # Tracks 'off' → 'on' state changes self._on_to_off_event[entity_id] = event @@ -596,7 +598,7 @@ async def _light_event(self, event: Event): class AdaptiveSleepModeSwitch(SwitchEntity, RestoreEntity): """Representation of a Adaptive Lighting switch.""" - def __init__(self, hass, config_entry): + def __init__(self, hass: HomeAssistant, config_entry): """Initialize the Adaptive Lighting switch.""" self.hass = hass data = validate(config_entry) diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index d08cbc191b6c7c..57949ea07e9c43 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -6,12 +6,12 @@ "title": "Choose a name for the Adaptive Lighting", "description": "Every instance can contain multiple lights!", "data": { - "name": "Name" + "name": "[%key:common::config_flow::data::name%]" } } }, "abort": { - "already_configured": "This name is already configured." + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, "options": { From 21491ed305d39d4c909601374a016b2fcf274f82 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 5 Oct 2020 22:45:45 +0200 Subject: [PATCH 088/165] use 'state_changed' events to check if states changed --- .../components/adaptive_lighting/__init__.py | 4 +- .../components/adaptive_lighting/switch.py | 80 +++++++++++++------ 2 files changed, 58 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/__init__.py b/homeassistant/components/adaptive_lighting/__init__.py index 64e3654446b055..be8868f516c226 100644 --- a/homeassistant/components/adaptive_lighting/__init__.py +++ b/homeassistant/components/adaptive_lighting/__init__.py @@ -101,7 +101,9 @@ async def async_unload_entry(hass, config_entry: ConfigEntry) -> bool: data = hass.data[DOMAIN] data[config_entry.entry_id][UNDO_UPDATE_LISTENER]() if len(data) == 1: # no more config_entries - data.pop(ATTR_TURN_ON_OFF_LISTENER).remove_listener() + turn_on_off_listener = data.pop(ATTR_TURN_ON_OFF_LISTENER) + turn_on_off_listener.remove_listener() + turn_on_off_listener.remove_listener2() if unload_ok: data.pop(config_entry.entry_id) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index b809d1e6867811..2dad5ab7e1eb9a 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -41,6 +41,7 @@ CONF_NAME, EVENT_CALL_SERVICE, EVENT_HOMEASSISTANT_START, + EVENT_STATE_CHANGED, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, @@ -48,7 +49,7 @@ SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, ) -from homeassistant.core import Context, Event, HomeAssistant, ServiceCall +from homeassistant.core import Context, Event, HomeAssistant, ServiceCall, State from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import ( @@ -114,6 +115,11 @@ SCAN_INTERVAL = timedelta(seconds=10) +# Consider it a significant change when attribute changes more than +BRIGHTNESS_CHANGE = 25 # ≈10% of total range +COLOR_TEMP_CHANGE = 250 # ≈5% of total range +RGB_CHANGE = 30 # ≈12% of total range per component + async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall): """Handle the entity service apply.""" @@ -289,6 +295,7 @@ def __init__( self._locks: Dict[str, asyncio.Lock] = {} # To identify that this integration made a change self.__context = Context() # self._context will be overwritten + self.turn_on_off_listener.contexts.add(self.__context) # Set in self._update_attrs_and_maybe_adapt_lights self._settings: Dict[str, Any] = {} @@ -298,12 +305,14 @@ def __init__( _LOGGER.debug( "%s: Setting up with '%s'," " config_entry.data: '%s'," - " config_entry.options: '%s', converted to '%s'.", + " config_entry.options: '%s', converted to '%s'," + " with context '%s'.", self._name, self._lights, config_entry.data, config_entry.options, data, + self.__context, ) @property @@ -479,7 +488,6 @@ async def _adapt_light( ) ): return - self.turn_on_off_listener.last_service_data[light] = service_data _LOGGER.debug( "%s: Scheduling 'light.turn_on' with the following 'service_data': %s", self._name, @@ -792,6 +800,7 @@ def __init__(self, hass): """Initialize the TurnOnOffListener that is shared among all switches.""" self.hass = hass self.lights = set() + self.contexts = set() # contexts of different AdaptiveSwitch instances # Tracks 'light.turn_off' service calls self.turn_off_event: Dict[str, Event] = {} @@ -801,18 +810,21 @@ def __init__(self, hass): self.sleep_tasks: Dict[str, asyncio.Task] = {} # Tracks which lights are manually controlled self.manually_controlled: Dict[str, bool] = {} - # Track which settings were applied to a light - self.last_service_data: Dict[str, Dict[str, Any]] = {} + # Track 'state_changed' events of self.lights resulting from this integration + self.last_state_change: Dict[str, State] = {} self.remove_listener = self.hass.bus.async_listen( EVENT_CALL_SERVICE, self.turn_on_off_event_listener ) + self.remove_listener2 = self.hass.bus.async_listen( + EVENT_STATE_CHANGED, self.state_changed_event_listener + ) def reset(self, *lights): """Reset the 'manually_controlled' status of the lights.""" for light in lights: self.manually_controlled[light] = False - self.last_service_data.pop(light, None) + self.last_state_change.pop(light, None) async def turn_on_off_event_listener(self, event: Event): """Track 'light.turn_off' and 'light.turn_on' service calls.""" @@ -846,6 +858,25 @@ async def turn_on_off_event_listener(self, event: Event): task.cancel() self.turn_on_event[eid] = event + async def state_changed_event_listener(self, event: Event): + """Track 'state_changed' events.""" + entity_id = event.data.get(ATTR_ENTITY_ID, "") + if entity_id not in self.lights and entity_id.split(".")[0] != LIGHT_DOMAIN: + return + + new_state = event.data.get("new_state") + if ( + new_state is not None + and new_state.state == STATE_ON + and new_state.context in self.contexts + ): + _LOGGER.debug( + "Detected a '%s' 'state_changed' event: '%s'", + entity_id, + new_state.attributes, + ) + self.last_state_change[entity_id] = new_state + def is_manually_controlled( self, light: str, @@ -883,7 +914,6 @@ async def significant_change( adapt_color_temp, adapt_rgb_color, context, - threshold=5, ): """Has the light made a significant change since last update. @@ -892,10 +922,10 @@ async def significant_change( detected, we mark the light as 'manually_controlled' until the light or switch is turned 'off' and 'on' again. """ - if light not in self.last_service_data: + if light not in self.last_state_change: return False changed = False - service_data = self.last_service_data[light] + old_attributes = self.last_state_change[light].attributes await self.hass.services.async_call( HA_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -906,56 +936,56 @@ async def significant_change( attributes = self.hass.states.get(light).attributes if ( adapt_brightness - and ATTR_BRIGHTNESS_PCT in service_data + and ATTR_BRIGHTNESS in old_attributes and ATTR_BRIGHTNESS in attributes ): - applied_brightness = round(255 * service_data[ATTR_BRIGHTNESS_PCT] / 100) - current_brightness = attributes["brightness"] - if abs_rel_diff(current_brightness, applied_brightness) > threshold: + last_brightness = old_attributes[ATTR_BRIGHTNESS] + current_brightness = attributes[ATTR_BRIGHTNESS] + if abs(current_brightness - last_brightness) > BRIGHTNESS_CHANGE: _LOGGER.debug( "Brightness of '%s' significantly changed from %s to %s", light, - applied_brightness, + last_brightness, current_brightness, ) changed = True if ( adapt_color_temp - and ATTR_COLOR_TEMP in service_data + and ATTR_COLOR_TEMP in old_attributes and ATTR_COLOR_TEMP in attributes ): - applied_color_temp = service_data[ATTR_COLOR_TEMP] + last_color_temp = old_attributes[ATTR_COLOR_TEMP] current_color_temp = attributes[ATTR_COLOR_TEMP] - if abs_rel_diff(current_color_temp, applied_color_temp) > threshold: + if abs(current_color_temp - last_color_temp) > COLOR_TEMP_CHANGE: _LOGGER.debug( "Color temperature of '%s' significantly changed from %s to %s", light, - applied_color_temp, + last_color_temp, current_color_temp, ) changed = True if ( adapt_rgb_color - and ATTR_RGB_COLOR in service_data + and ATTR_RGB_COLOR in old_attributes and ATTR_RGB_COLOR in attributes ): - applied_rgb_color = service_data[ATTR_RGB_COLOR] + last_rgb_color = old_attributes[ATTR_RGB_COLOR] current_rgb_color = attributes[ATTR_RGB_COLOR] - for col_applied, col_current in zip(applied_rgb_color, current_rgb_color): - if abs_rel_diff(col_applied, col_current) > threshold: + for last_col, current_col in zip(last_rgb_color, current_rgb_color): + if abs(last_col - current_col) > RGB_CHANGE: _LOGGER.debug( "color RGB of '%s' significantly changed from %s to %s", light, - applied_rgb_color, + last_rgb_color, current_rgb_color, ) changed = True break - if (ATTR_RGB_COLOR in service_data and ATTR_RGB_COLOR not in attributes) or ( - ATTR_COLOR_TEMP in service_data and ATTR_COLOR_TEMP not in attributes + if (ATTR_RGB_COLOR in old_attributes and ATTR_RGB_COLOR not in attributes) or ( + ATTR_COLOR_TEMP in old_attributes and ATTR_COLOR_TEMP not in attributes ): # Light switched from RGB mode to color_temp or visa versa _LOGGER.debug( From 6b77d61ef7dc0a1ddf5830842f92eda08d4e886d Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 6 Oct 2020 11:18:01 +0200 Subject: [PATCH 089/165] reduce COLOR_TEMP_CHANGE to 20 (range is in mired not Kelvin) --- homeassistant/components/adaptive_lighting/switch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 2dad5ab7e1eb9a..f7af10303ad1b9 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -117,7 +117,7 @@ # Consider it a significant change when attribute changes more than BRIGHTNESS_CHANGE = 25 # ≈10% of total range -COLOR_TEMP_CHANGE = 250 # ≈5% of total range +COLOR_TEMP_CHANGE = 20 # ≈5% of total range RGB_CHANGE = 30 # ≈12% of total range per component From b27f80db78f3308591803db832e67c65cf053aff Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 6 Oct 2020 11:19:02 +0200 Subject: [PATCH 090/165] remove unused function abs_rel_diff --- homeassistant/components/adaptive_lighting/switch.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index f7af10303ad1b9..f347473b38345d 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -227,14 +227,6 @@ def _supported_features(hass: HomeAssistant, light: str): return {key for key, value in _SUPPORT_OPTS.items() if supported_features & value} -def abs_rel_diff(val_a, val_b) -> float: - """Absolute relative difference in %.""" - if val_b == 0: - # To avoid ZeroDivisionError - val_b = 1e-6 - return abs((val_a - val_b) / val_b) * 100 - - class AdaptiveSwitch(SwitchEntity, RestoreEntity): """Representation of a Adaptive Lighting switch.""" From 43d681f72e9f7db5f2256be8d84b55427dd2745a Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 6 Oct 2020 13:42:05 +0200 Subject: [PATCH 091/165] listen to EVENT_HOMEASSISTANT_STARTED instead of EVENT_HOMEASSISTANT_START --- homeassistant/components/adaptive_lighting/switch.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index f347473b38345d..10dbbc91479681 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -40,7 +40,7 @@ ATTR_SERVICE_DATA, CONF_NAME, EVENT_CALL_SERVICE, - EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -286,7 +286,7 @@ def __init__( # Locks that prevent light adjusting when waiting for a light to 'turn_off' self._locks: Dict[str, asyncio.Lock] = {} # To identify that this integration made a change - self.__context = Context() # self._context will be overwritten + self.__context = Context() # self._context would be overwritten self.turn_on_off_listener.contexts.add(self.__context) # Set in self._update_attrs_and_maybe_adapt_lights @@ -328,7 +328,7 @@ async def async_added_to_hass(self) -> None: await self._setup_listeners() else: self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, self._setup_listeners + EVENT_HOMEASSISTANT_STARTED, self._setup_listeners ) last_state = await self.async_get_last_state() is_new_entry = last_state is None # newly added to HA From 3815859347bb500892c57e72e1df807940e342ad Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 6 Oct 2020 23:17:53 +0200 Subject: [PATCH 092/165] pass unique identifiable contexts to solve bug Fixes the following from happening: ``` 16:09:13 Detected a 'light.ceiling_bedroom' 'state_changed' event: '{'min_mireds': 153, 'max_mireds': 500, 'effect_list': ['colorloop'], 'brightness': 3, 'hs_color': (27.0, 23.529), 'rgb_color': (255, 222, 195), 'xy_color': (0.384, 0.354), 'is_deconz_group': False, 'friendly_name': 'Ceiling bedroom', 'supported_features': 63}' 16:09:42 Adaptive Lighting: default: '_adapt_lights(['light.bed_reading_up', 'light.philips_go', 'light.stairs_up', 'light.hall_2', 'light.ceiling_kitchen', 'light.toilet', 'light.hall_3', 'light.stairs_down', 'light.hall_1', 'light.ceiling_bathroom', 'light.bamboo', 'light.ceiling_bedroom', 'light.bed_reading_down', 'light.lampan'], None, False)' called 16:09:42 default: Scheduling 'light.turn_on' with the following 'service_data': {'entity_id': 'light.ceiling_bedroom', 'transition': 15.0, 'brightness_pct': 100, 'rgb_color': (255, 222.2289452819071, 195.78772649225692)} 16:09:42 Detected an 'light.turn_on('['light.ceiling_bedroom']')' event 16:09:42 Detected a 'light.ceiling_bedroom' 'state_changed' event: '{'min_mireds': 153, 'max_mireds': 500, 'effect_list': ['colorloop'], 'brightness': 254, 'hs_color': (27.0, 23.529), 'rgb_color': (255, 222, 195), 'xy_color': (0.384, 0.354), 'is_deconz_group': False, 'friendly_name': 'Ceiling bedroom', 'supported_features': 63}' 16:09:43 Detected a 'light.ceiling_bedroom' 'state_changed' event: '{'min_mireds': 153, 'max_mireds': 500, 'effect_list': ['colorloop'], 'brightness': 13, 'hs_color': (27.0, 23.529), 'rgb_color': (255, 222, 195), 'xy_color': (0.384, 0.354), 'is_deconz_group': False, 'friendly_name': 'Ceiling bedroom', 'supported_features': 63}' 16:10:12 Adaptive Lighting: default: '_adapt_lights(['light.bed_reading_up', 'light.philips_go', 'light.stairs_up', 'light.hall_2', 'light.ceiling_kitchen', 'light.toilet', 'light.hall_3', 'light.stairs_down', 'light.hall_1', 'light.ceiling_bathroom', 'light.bamboo', 'light.ceiling_bedroom', 'light.bed_reading_down', 'light.lampan'], None, False)' called 16:10:12 Brightness of 'light.ceiling_bedroom' significantly changed from 13 to 254 # it needs to be 254 here! 16:10:42 Adaptive Lighting: default: '_adapt_lights(['light.bed_reading_up', 'light.philips_go', 'light.stairs_up', 'light.hall_2', 'light.ceiling_kitchen', 'light.toilet', 'light.hall_3', 'light.stairs_down', 'light.hall_1', 'light.ceiling_bathroom', 'light.bamboo', 'light.ceiling_bedroom', 'light.bed_reading_down', 'light.lampan'], None, False)' called 16:10:42 Brightness of 'light.ceiling_bedroom' significantly changed from 13 to 254 16:11:12 Adaptive Lighting: default: '_adapt_lights(['light.bed_reading_up', 'light.philips_go', 'light.stairs_up', 'light.hall_2', 'light.ceiling_kitchen', 'light.toilet', 'light.hall_3', 'light.stairs_down', 'light.hall_1', 'light.ceiling_bathroom', 'light.bamboo', 'light.ceiling_bedroom', 'light.bed_reading_down', 'light.lampan'], None, False)' called ``` --- .../components/adaptive_lighting/switch.py | 80 ++++++++++++++----- 1 file changed, 58 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 10dbbc91479681..53b014cfb59f7a 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -121,6 +121,18 @@ RGB_CHANGE = 30 # ≈12% of total range per component +def create_context(name: str, index: int) -> Context: + """Create a context that can identify this integration.""" + return Context(id=f"{DOMAIN}_{name}_{index}") + + +def is_our_context(context: Optional[Context]) -> bool: + """Check whether this integration created 'context'.""" + if context is None: + return False + return context.id.startswith(DOMAIN) + + async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall): """Handle the entity service apply.""" if not isinstance(switch, AdaptiveSwitch): @@ -285,9 +297,8 @@ def __init__( self._off_to_on_event: Dict[str, Event] = {} # Locks that prevent light adjusting when waiting for a light to 'turn_off' self._locks: Dict[str, asyncio.Lock] = {} - # To identify that this integration made a change - self.__context = Context() # self._context would be overwritten - self.turn_on_off_listener.contexts.add(self.__context) + # To count the number of `Context` instances + self._context_cnt: int = 0 # Set in self._update_attrs_and_maybe_adapt_lights self._settings: Dict[str, Any] = {} @@ -297,14 +308,12 @@ def __init__( _LOGGER.debug( "%s: Setting up with '%s'," " config_entry.data: '%s'," - " config_entry.options: '%s', converted to '%s'," - " with context '%s'.", + " config_entry.options: '%s', converted to '%s'.", self._name, self._lights, config_entry.data, config_entry.options, data, - self.__context, ) @property @@ -408,7 +417,9 @@ async def async_turn_on( # pylint: disable=arguments-differ await self._setup_listeners() if adapt_lights: await self._update_attrs_and_maybe_adapt_lights( - transition=self._initial_transition, force=True + transition=self._initial_transition, + force=True, + context=self.create_context(), ) async def async_turn_off(self, **kwargs) -> None: @@ -420,7 +431,15 @@ async def async_turn_off(self, **kwargs) -> None: self.turn_on_off_listener.reset(*self._lights) async def _async_update_at_interval(self, now=None) -> None: - await self._update_attrs_and_maybe_adapt_lights(force=False) + await self._update_attrs_and_maybe_adapt_lights( + force=False, context=self.create_context() + ) + + def create_context(self) -> Context: + """Create a context that identifies this Adaptive Lighting instance.""" + context = create_context(self._name, self._context_cnt) + self._context_cnt += 1 + return context async def _adapt_light( self, @@ -430,12 +449,12 @@ async def _adapt_light( adapt_color_temp: Optional[bool] = None, adapt_rgb_color: Optional[bool] = None, force: bool = False, + context: Optional[Context] = None, ) -> None: lock = self._locks.get(light) if lock is not None and lock.locked(): _LOGGER.debug("%s: '%s' is locked", self._name, light) return - service_data = {ATTR_ENTITY_ID: light} features = _supported_features(self.hass, light) @@ -466,7 +485,7 @@ async def _adapt_light( service_data[ATTR_COLOR_TEMP] = color_temp_mired elif "color" in features and adapt_rgb_color: service_data[ATTR_RGB_COLOR] = self._settings["rgb_color"] - + context = context or self.create_context() if ( self._take_over_control and self._detect_non_ha_changes @@ -476,7 +495,7 @@ async def _adapt_light( self._adapt_brightness, self._adapt_color_temp, self._adapt_rgb_color, - self.__context, + context, ) ): return @@ -489,7 +508,7 @@ async def _adapt_light( LIGHT_DOMAIN, SERVICE_TURN_ON, service_data, - context=self.__context, + context=context, ) async def _update_attrs_and_maybe_adapt_lights( @@ -497,6 +516,7 @@ async def _update_attrs_and_maybe_adapt_lights( lights: Optional[List[str]] = None, transition: Optional[int] = None, force: bool = False, + context: Optional[Context] = None, ): _LOGGER.debug("%s: '_update_attrs_and_maybe_adapt_lights' called", self._name) assert self.is_on @@ -508,17 +528,22 @@ async def _update_attrs_and_maybe_adapt_lights( lights = self._lights if (self._only_once and not force) or not lights: return - await self._adapt_lights(lights, transition, force) + await self._adapt_lights(lights, transition, force, context) async def _adapt_lights( - self, lights: List[str], transition: Optional[int], force: bool + self, + lights: List[str], + transition: Optional[int], + force: bool, + context: Optional[Context], ): _LOGGER.debug( - "%s: '_adapt_lights(%s, %s, %s)' called", + "%s: '_adapt_lights(%s, %s, force=%s, context=%s)' called", self.name, lights, transition, force, + context, ) for light in lights: if not is_on(self.hass, light): @@ -528,7 +553,6 @@ async def _adapt_lights( and self.turn_on_off_listener.is_manually_controlled( light, force, - adaptive_lighting_context=self.__context, ) ): _LOGGER.debug( @@ -537,7 +561,7 @@ async def _adapt_lights( light, ) continue - await self._adapt_light(light, transition, force=force) + await self._adapt_light(light, transition, force=force, context=context) async def _sleep_state_event(self, event: Event): if not match_state_event(event, (STATE_ON, STATE_OFF)): @@ -545,7 +569,9 @@ async def _sleep_state_event(self, event: Event): _LOGGER.debug("%s: _sleep_state_event, event: '%s'", self._name, event) self.turn_on_off_listener.reset(*self._lights) await self._update_attrs_and_maybe_adapt_lights( - transition=self._initial_transition, force=True + transition=self._initial_transition, + force=True, + context=self.create_context(), ) async def _light_event(self, event: Event): @@ -583,6 +609,7 @@ async def _light_event(self, event: Event): lights=[entity_id], transition=self._initial_transition, force=True, + context=self.create_context(), ) elif ( old_state is not None @@ -792,7 +819,6 @@ def __init__(self, hass): """Initialize the TurnOnOffListener that is shared among all switches.""" self.hass = hass self.lights = set() - self.contexts = set() # contexts of different AdaptiveSwitch instances # Tracks 'light.turn_off' service calls self.turn_off_event: Dict[str, Event] = {} @@ -860,20 +886,30 @@ async def state_changed_event_listener(self, event: Event): if ( new_state is not None and new_state.state == STATE_ON - and new_state.context in self.contexts + and is_our_context(new_state.context) ): _LOGGER.debug( "Detected a '%s' 'state_changed' event: '%s'", entity_id, new_state.attributes, ) + # If there is already a state change event from this event (with this + # context) then ignore. This is because a + # `turn_on.light(brightness_pct=100, transition=30)` event leads to an + # instant state change of `new_state=dict(brightness=100, ...)`. However, + # after polling the light could still only be + # `new_state=dict(brightness=50, ...)`. + old_state = self.last_state_change.get(entity_id) + if old_state is not None and old_state.context.id == new_state.context.id: + # state is already in + return + self.last_state_change[entity_id] = new_state def is_manually_controlled( self, light: str, force: bool, - adaptive_lighting_context: Context, ): """Check if the light has been 'on' and is now manually being adjusted.""" manually_controlled = self.manually_controlled.setdefault(light, False) @@ -884,7 +920,7 @@ def is_manually_controlled( turn_on_event = self.turn_on_event.get(light) if ( turn_on_event is not None - and adaptive_lighting_context.id != turn_on_event.context.id + and is_our_context(turn_on_event.context.id) and not force ): # Light was already on and 'light.turn_on' was not called by From d50abc413eeab3b3529c2e005be3407f0dd91a0f Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 6 Oct 2020 23:53:36 +0200 Subject: [PATCH 093/165] make the contexts more identifiable --- .../components/adaptive_lighting/switch.py | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 53b014cfb59f7a..0216b93cead6ea 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -121,9 +121,9 @@ RGB_CHANGE = 30 # ≈12% of total range per component -def create_context(name: str, index: int) -> Context: +def create_context(which: str, index: int) -> Context: """Create a context that can identify this integration.""" - return Context(id=f"{DOMAIN}_{name}_{index}") + return Context(id=f"{DOMAIN}_{which}_{index}") def is_our_context(context: Optional[Context]) -> bool: @@ -219,6 +219,7 @@ def match_state_event(event: Event, from_or_to_state: List[str]): def _expand_light_groups(hass: HomeAssistant, lights: List[str]) -> List[str]: all_lights = set() + turn_on_off_listener = hass.data[DOMAIN][ATTR_TURN_ON_OFF_LISTENER] for light in lights: state = hass.states.get(light) if state is None: @@ -226,6 +227,7 @@ def _expand_light_groups(hass: HomeAssistant, lights: List[str]) -> List[str]: all_lights.add(light) elif "entity_id" in state.attributes: # it's a light group group = state.attributes["entity_id"] + turn_on_off_listener.lights.discard(light) all_lights.update(group) _LOGGER.debug("Expanded %s to %s", light, group) else: @@ -419,7 +421,7 @@ async def async_turn_on( # pylint: disable=arguments-differ await self._update_attrs_and_maybe_adapt_lights( transition=self._initial_transition, force=True, - context=self.create_context(), + context=self.create_context("turn_on"), ) async def async_turn_off(self, **kwargs) -> None: @@ -432,12 +434,12 @@ async def async_turn_off(self, **kwargs) -> None: async def _async_update_at_interval(self, now=None) -> None: await self._update_attrs_and_maybe_adapt_lights( - force=False, context=self.create_context() + force=False, context=self.create_context("interval") ) - def create_context(self) -> Context: + def create_context(self, which="default") -> Context: """Create a context that identifies this Adaptive Lighting instance.""" - context = create_context(self._name, self._context_cnt) + context = create_context(which, self._context_cnt) self._context_cnt += 1 return context @@ -485,7 +487,7 @@ async def _adapt_light( service_data[ATTR_COLOR_TEMP] = color_temp_mired elif "color" in features and adapt_rgb_color: service_data[ATTR_RGB_COLOR] = self._settings["rgb_color"] - context = context or self.create_context() + context = context or self.create_context("adapt_lights") if ( self._take_over_control and self._detect_non_ha_changes @@ -571,7 +573,7 @@ async def _sleep_state_event(self, event: Event): await self._update_attrs_and_maybe_adapt_lights( transition=self._initial_transition, force=True, - context=self.create_context(), + context=self.create_context("sleep"), ) async def _light_event(self, event: Event): @@ -609,7 +611,7 @@ async def _light_event(self, event: Event): lights=[entity_id], transition=self._initial_transition, force=True, - context=self.create_context(), + context=self.create_context("light_event"), ) elif ( old_state is not None @@ -869,7 +871,11 @@ async def turn_on_off_event_listener(self, event: Event): self.reset(eid) elif service == SERVICE_TURN_ON: - _LOGGER.debug("Detected an 'light.turn_on('%s')' event", entity_ids) + _LOGGER.debug( + "Detected an 'light.turn_on('%s')' event with %s", + entity_ids, + event.context, + ) for eid in entity_ids: task = self.sleep_tasks.get(eid) if task is not None: @@ -889,9 +895,10 @@ async def state_changed_event_listener(self, event: Event): and is_our_context(new_state.context) ): _LOGGER.debug( - "Detected a '%s' 'state_changed' event: '%s'", + "Detected a '%s' 'state_changed' event: '%s' with '%s'", entity_id, new_state.attributes, + new_state.context, ) # If there is already a state change event from this event (with this # context) then ignore. This is because a @@ -901,7 +908,11 @@ async def state_changed_event_listener(self, event: Event): # `new_state=dict(brightness=50, ...)`. old_state = self.last_state_change.get(entity_id) if old_state is not None and old_state.context.id == new_state.context.id: - # state is already in + # state is already in 'self.last_state_change' + _LOGGER.debug( + "State change event ('%s') was already in 'self.last_state_change'", + new_state.context.id, + ) return self.last_state_change[entity_id] = new_state From 0e22b63711ccd67a1a43717586843a41ac5aa59f Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 7 Oct 2020 00:05:56 +0200 Subject: [PATCH 094/165] only log context.id --- homeassistant/components/adaptive_lighting/switch.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 0216b93cead6ea..3475a7cc8f414e 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -545,7 +545,7 @@ async def _adapt_lights( lights, transition, force, - context, + context.id, ) for light in lights: if not is_on(self.hass, light): @@ -874,7 +874,7 @@ async def turn_on_off_event_listener(self, event: Event): _LOGGER.debug( "Detected an 'light.turn_on('%s')' event with %s", entity_ids, - event.context, + event.context.id, ) for eid in entity_ids: task = self.sleep_tasks.get(eid) @@ -910,7 +910,8 @@ async def state_changed_event_listener(self, event: Event): if old_state is not None and old_state.context.id == new_state.context.id: # state is already in 'self.last_state_change' _LOGGER.debug( - "State change event ('%s') was already in 'self.last_state_change'", + "State change event of '%s' was already in 'self.last_state_change' (%s)", + entity_id, new_state.context.id, ) return From 4e1c584c27ee24a40aaae9a4b92a89d4f77f10ff Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 7 Oct 2020 09:52:41 +0200 Subject: [PATCH 095/165] log the context.id --- .../components/adaptive_lighting/switch.py | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 3475a7cc8f414e..332aa5e8cf5517 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -502,9 +502,11 @@ async def _adapt_light( ): return _LOGGER.debug( - "%s: Scheduling 'light.turn_on' with the following 'service_data': %s", + "%s: Scheduling 'light.turn_on' with the following 'service_data': %s" + " with context.id='%s'", self._name, service_data, + context.id, ) await self.hass.services.async_call( LIGHT_DOMAIN, @@ -587,7 +589,10 @@ async def _light_event(self, event: Event): and new_state.state == STATE_ON ): _LOGGER.debug( - "%s: Detected an 'off' → 'on' event for '%s'", self._name, entity_id + "%s: Detected an 'off' → 'on' event for '%s' with context.id='%s'", + self._name, + entity_id, + event.context.id, ) self.turn_on_off_listener.reset(entity_id) # Tracks 'off' → 'on' state changes @@ -872,7 +877,7 @@ async def turn_on_off_event_listener(self, event: Event): elif service == SERVICE_TURN_ON: _LOGGER.debug( - "Detected an 'light.turn_on('%s')' event with %s", + "Detected an 'light.turn_on('%s')' event with context.id='%s'", entity_ids, event.context.id, ) @@ -885,7 +890,7 @@ async def turn_on_off_event_listener(self, event: Event): async def state_changed_event_listener(self, event: Event): """Track 'state_changed' events.""" entity_id = event.data.get(ATTR_ENTITY_ID, "") - if entity_id not in self.lights and entity_id.split(".")[0] != LIGHT_DOMAIN: + if entity_id not in self.lights or entity_id.split(".")[0] != LIGHT_DOMAIN: return new_state = event.data.get("new_state") @@ -895,10 +900,10 @@ async def state_changed_event_listener(self, event: Event): and is_our_context(new_state.context) ): _LOGGER.debug( - "Detected a '%s' 'state_changed' event: '%s' with '%s'", + "Detected a '%s' 'state_changed' event: '%s' with context.id='%s'", entity_id, new_state.attributes, - new_state.context, + new_state.context.id, ) # If there is already a state change event from this event (with this # context) then ignore. This is because a @@ -983,10 +988,12 @@ async def significant_change( current_brightness = attributes[ATTR_BRIGHTNESS] if abs(current_brightness - last_brightness) > BRIGHTNESS_CHANGE: _LOGGER.debug( - "Brightness of '%s' significantly changed from %s to %s", + "Brightness of '%s' significantly changed from %s to %s with" + " context.id='%s'", light, last_brightness, current_brightness, + context.id, ) changed = True @@ -999,10 +1006,12 @@ async def significant_change( current_color_temp = attributes[ATTR_COLOR_TEMP] if abs(current_color_temp - last_color_temp) > COLOR_TEMP_CHANGE: _LOGGER.debug( - "Color temperature of '%s' significantly changed from %s to %s", + "Color temperature of '%s' significantly changed from %s to %s with" + " context.id='%s'", light, last_color_temp, current_color_temp, + context.id, ) changed = True @@ -1016,10 +1025,12 @@ async def significant_change( for last_col, current_col in zip(last_rgb_color, current_rgb_color): if abs(last_col - current_col) > RGB_CHANGE: _LOGGER.debug( - "color RGB of '%s' significantly changed from %s to %s", + "color RGB of '%s' significantly changed from %s to %s with" + " context.id='%s'", light, last_rgb_color, current_rgb_color, + context.id, ) changed = True break From ce4af77237e0a54b7aef389fead4a9030fb59aba Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 7 Oct 2020 10:47:35 +0200 Subject: [PATCH 096/165] improve readability --- .../components/adaptive_lighting/switch.py | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 332aa5e8cf5517..cb9ec5119aca3c 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -969,8 +969,9 @@ async def significant_change( """ if light not in self.last_state_change: return False + old_state = self.last_state_change[light] + old_attributes = old_state.attributes changed = False - old_attributes = self.last_state_change[light].attributes await self.hass.services.async_call( HA_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -978,14 +979,15 @@ async def significant_change( blocking=True, context=context, ) - attributes = self.hass.states.get(light).attributes + new_state = self.hass.states.get(light) + new_attributes = new_state.attributes if ( adapt_brightness and ATTR_BRIGHTNESS in old_attributes - and ATTR_BRIGHTNESS in attributes + and ATTR_BRIGHTNESS in new_attributes ): last_brightness = old_attributes[ATTR_BRIGHTNESS] - current_brightness = 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" @@ -1000,10 +1002,10 @@ async def significant_change( if ( adapt_color_temp and ATTR_COLOR_TEMP in old_attributes - and ATTR_COLOR_TEMP in attributes + and ATTR_COLOR_TEMP in new_attributes ): last_color_temp = old_attributes[ATTR_COLOR_TEMP] - current_color_temp = attributes[ATTR_COLOR_TEMP] + current_color_temp = new_attributes[ATTR_COLOR_TEMP] if abs(current_color_temp - last_color_temp) > COLOR_TEMP_CHANGE: _LOGGER.debug( "Color temperature of '%s' significantly changed from %s to %s with" @@ -1018,10 +1020,10 @@ async def significant_change( if ( adapt_rgb_color and ATTR_RGB_COLOR in old_attributes - and ATTR_RGB_COLOR in attributes + and ATTR_RGB_COLOR in new_attributes ): last_rgb_color = old_attributes[ATTR_RGB_COLOR] - current_rgb_color = attributes[ATTR_RGB_COLOR] + current_rgb_color = new_attributes[ATTR_RGB_COLOR] for last_col, current_col in zip(last_rgb_color, current_rgb_color): if abs(last_col - current_col) > RGB_CHANGE: _LOGGER.debug( @@ -1035,15 +1037,20 @@ async def significant_change( changed = True break - if (ATTR_RGB_COLOR in old_attributes and ATTR_RGB_COLOR not in attributes) or ( - ATTR_COLOR_TEMP in old_attributes and ATTR_COLOR_TEMP not in attributes - ): + switched_color_temp = ( + ATTR_RGB_COLOR in old_attributes and ATTR_RGB_COLOR not in new_attributes + ) + switched_to_rgb_color = ( + ATTR_COLOR_TEMP in old_attributes and ATTR_COLOR_TEMP not in new_attributes + ) + if switched_color_temp or switched_to_rgb_color: # Light switched from RGB mode to color_temp or visa versa _LOGGER.debug( "'%s' switched from RGB mode to color_temp or visa versa", light, ) changed = True + self.manually_controlled[light] = changed return changed From 1b981e110dc4a678f90310c34c6f58b18fe01778 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 7 Oct 2020 11:08:24 +0200 Subject: [PATCH 097/165] keep context_id shorter than 36 chars --- .../components/adaptive_lighting/switch.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index cb9ec5119aca3c..1b61a41fa13fe7 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -7,6 +7,7 @@ from dataclasses import dataclass import datetime from datetime import timedelta +import hashlib import logging from typing import Any, Dict, List, Optional, Tuple, Union @@ -120,17 +121,20 @@ COLOR_TEMP_CHANGE = 20 # ≈5% of total range RGB_CHANGE = 30 # ≈12% of total range per component +# Keep a short domain version for the context instances +_DOMAIN_SHORT = "adapt_lgt" -def create_context(which: str, index: int) -> Context: + +def create_context(name: str, which: str, index: int) -> Context: """Create a context that can identify this integration.""" - return Context(id=f"{DOMAIN}_{which}_{index}") + return Context(id=f"{_DOMAIN_SHORT}_{name}_{which}_{index}") def is_our_context(context: Optional[Context]) -> bool: """Check whether this integration created 'context'.""" if context is None: return False - return context.id.startswith(DOMAIN) + return context.id.startswith(_DOMAIN_SHORT) async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall): @@ -439,7 +443,10 @@ async def _async_update_at_interval(self, now=None) -> None: def create_context(self, which="default") -> Context: """Create a context that identifies this Adaptive Lighting instance.""" - context = create_context(which, self._context_cnt) + # Use a hash for the name because otherwise the context might become + # too long (max len == 36) to fit in the database. + name_hash = hashlib.sha1(self._name.encode("UTF-8")).hexdigest() + context = create_context(name_hash[:4], which, self._context_cnt) self._context_cnt += 1 return context @@ -542,7 +549,7 @@ async def _adapt_lights( context: Optional[Context], ): _LOGGER.debug( - "%s: '_adapt_lights(%s, %s, force=%s, context=%s)' called", + "%s: '_adapt_lights(%s, %s, force=%s, context.id=%s)' called", self.name, lights, transition, From d53442aca58af5f267827b58eeae64700fbe9c3f Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 7 Oct 2020 16:52:54 +0200 Subject: [PATCH 098/165] add an extra check --- .../components/adaptive_lighting/switch.py | 202 +++++++++++------- 1 file changed, 126 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 1b61a41fa13fe7..583c9a8e4d72b2 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -121,7 +121,7 @@ COLOR_TEMP_CHANGE = 20 # ≈5% of total range RGB_CHANGE = 30 # ≈12% of total range per component -# Keep a short domain version for the context instances +# Keep a short domain version for the context instances (which can only be 36 chars) _DOMAIN_SHORT = "adapt_lgt" @@ -245,6 +245,87 @@ def _supported_features(hass: HomeAssistant, light: str): return {key for key, value in _SUPPORT_OPTS.items() if supported_features & value} +def _attributes_have_changed( + light, + old_attributes, + new_attributes, + adapt_brightness, + adapt_color_temp, + adapt_rgb_color, + context, +): + changed = False + if ( + adapt_brightness + and ATTR_BRIGHTNESS in old_attributes + and ATTR_BRIGHTNESS in new_attributes + ): + 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, + ) + changed = True + + if ( + adapt_color_temp + and ATTR_COLOR_TEMP in old_attributes + and ATTR_COLOR_TEMP in new_attributes + ): + last_color_temp = old_attributes[ATTR_COLOR_TEMP] + current_color_temp = new_attributes[ATTR_COLOR_TEMP] + 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, + ) + changed = True + + if ( + adapt_rgb_color + and ATTR_RGB_COLOR in old_attributes + and ATTR_RGB_COLOR in new_attributes + ): + last_rgb_color = old_attributes[ATTR_RGB_COLOR] + current_rgb_color = new_attributes[ATTR_RGB_COLOR] + for last_col, current_col in zip(last_rgb_color, current_rgb_color): + if abs(last_col - current_col) > RGB_CHANGE: + _LOGGER.debug( + "color RGB of '%s' significantly changed from %s to %s with" + " context.id='%s'", + light, + last_rgb_color, + current_rgb_color, + context.id, + ) + changed = True + break + switched_color_temp = ( + ATTR_RGB_COLOR in old_attributes and ATTR_RGB_COLOR not in new_attributes + ) + switched_to_rgb_color = ( + ATTR_COLOR_TEMP in old_attributes and ATTR_COLOR_TEMP not in new_attributes + ) + if switched_color_temp or switched_to_rgb_color: + # Light switched from RGB mode to color_temp or visa versa + _LOGGER.debug( + "'%s' switched from RGB mode to color_temp or visa versa", + light, + ) + changed = True + return changed + + class AdaptiveSwitch(SwitchEntity, RestoreEntity): """Representation of a Adaptive Lighting switch.""" @@ -409,6 +490,15 @@ def device_state_attributes(self) -> Dict[str, Any]: ] return dict(self._settings, manually_controlled=manually_controlled) + def create_context(self, which="default") -> Context: + """Create a context that identifies this Adaptive Lighting instance.""" + # Use a hash for the name because otherwise the context might become + # too long (max len == 36) to fit in the database. + name_hash = hashlib.sha1(self._name.encode("UTF-8")).hexdigest() + context = create_context(name_hash[:4], which, self._context_cnt) + self._context_cnt += 1 + return context + async def async_turn_on( # pylint: disable=arguments-differ self, adapt_lights: bool = True ) -> None: @@ -441,15 +531,6 @@ async def _async_update_at_interval(self, now=None) -> None: force=False, context=self.create_context("interval") ) - def create_context(self, which="default") -> Context: - """Create a context that identifies this Adaptive Lighting instance.""" - # Use a hash for the name because otherwise the context might become - # too long (max len == 36) to fit in the database. - name_hash = hashlib.sha1(self._name.encode("UTF-8")).hexdigest() - context = create_context(name_hash[:4], which, self._context_cnt) - self._context_cnt += 1 - return context - async def _adapt_light( self, light: str, @@ -977,8 +1058,7 @@ async def significant_change( if light not in self.last_state_change: return False old_state = self.last_state_change[light] - old_attributes = old_state.attributes - changed = False + old_state_before_update = self.hass.states.get(light) await self.hass.services.async_call( HA_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -987,76 +1067,46 @@ async def significant_change( context=context, ) new_state = self.hass.states.get(light) - new_attributes = new_state.attributes - if ( - adapt_brightness - and ATTR_BRIGHTNESS in old_attributes - and ATTR_BRIGHTNESS in new_attributes - ): - 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, - ) - changed = True - - if ( - adapt_color_temp - and ATTR_COLOR_TEMP in old_attributes - and ATTR_COLOR_TEMP in new_attributes - ): - last_color_temp = old_attributes[ATTR_COLOR_TEMP] - current_color_temp = new_attributes[ATTR_COLOR_TEMP] - 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, - ) - changed = True + changed = _attributes_have_changed( + light, + old_state.attributes, + new_state.attributes, + adapt_brightness, + adapt_color_temp, + adapt_rgb_color, + context, + ) + # If changed, do a second check. if ( - adapt_rgb_color - and ATTR_RGB_COLOR in old_attributes - and ATTR_RGB_COLOR in new_attributes + changed + and is_our_context(old_state.context) + and is_our_context(old_state_before_update.context) + and old_state.context.id != old_state_before_update.context.id ): - last_rgb_color = old_attributes[ATTR_RGB_COLOR] - current_rgb_color = new_attributes[ATTR_RGB_COLOR] - for last_col, current_col in zip(last_rgb_color, current_rgb_color): - if abs(last_col - current_col) > RGB_CHANGE: - _LOGGER.debug( - "color RGB of '%s' significantly changed from %s to %s with" - " context.id='%s'", - light, - last_rgb_color, - current_rgb_color, - context.id, - ) - changed = True - break - - switched_color_temp = ( - ATTR_RGB_COLOR in old_attributes and ATTR_RGB_COLOR not in new_attributes - ) - switched_to_rgb_color = ( - ATTR_COLOR_TEMP in old_attributes and ATTR_COLOR_TEMP not in new_attributes - ) - if switched_color_temp or switched_to_rgb_color: - # Light switched from RGB mode to color_temp or visa versa + # This means there were two consecutive state change events with the + # same context after Adaptive Lighting called 'light.turn_on' last time. + # We are only saving that first one (for reasons explained in + # 'self.state_changed_event_listener'). It can happen that the second + # state change event was actually the final state. That happens for + # example when a light was called with a color_temp outside of its + # range (and HA reports the incorrect 'min_mireds' and 'max_mireds', which + # happens e.g., for Philips Hue White GU10 Bluetooth lights). _LOGGER.debug( - "'%s' switched from RGB mode to color_temp or visa versa", + "Last 'light.turn_on' of '%s' never actually changed the light" + " (or the rgb_color/color_temp was out of range)", light, ) - changed = True + if not _attributes_have_changed( + light, + old_state_before_update.attributes, + new_state.attributes, + adapt_brightness, + adapt_color_temp, + adapt_rgb_color, + context, + ): + changed = False self.manually_controlled[light] = changed return changed From 5cf5d413a57d7893471e47cf25a75c8444e4661f Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 7 Oct 2020 23:21:18 +0200 Subject: [PATCH 099/165] Simplify handling state changes with the same context ID --- .../components/adaptive_lighting/switch.py | 84 ++++++++----------- 1 file changed, 34 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 583c9a8e4d72b2..158d8de1aec2ff 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -924,7 +924,7 @@ def __init__(self, hass): # Tracks which lights are manually controlled self.manually_controlled: Dict[str, bool] = {} # Track 'state_changed' events of self.lights resulting from this integration - self.last_state_change: Dict[str, State] = {} + self.last_state_change: Dict[str, List[State]] = {} self.remove_listener = self.hass.bus.async_listen( EVENT_CALL_SERVICE, self.turn_on_off_event_listener @@ -993,23 +993,33 @@ async def state_changed_event_listener(self, event: Event): new_state.attributes, new_state.context.id, ) - # If there is already a state change event from this event (with this - # context) then ignore. This is because a - # `turn_on.light(brightness_pct=100, transition=30)` event leads to an - # instant state change of `new_state=dict(brightness=100, ...)`. However, - # after polling the light could still only be - # `new_state=dict(brightness=50, ...)`. - old_state = self.last_state_change.get(entity_id) - if old_state is not None and old_state.context.id == new_state.context.id: - # state is already in 'self.last_state_change' + # It is possible to have multiple state change events with the same context. + # This can happen because a `turn_on.light(brightness_pct=100, transition=30)` + # event leads to an instant state change of + # `new_state=dict(brightness=100, ...)`. However, after polling the light + # could still only be `new_state=dict(brightness=50, ...)`. + # We save both events because the first event change might indicate at what + # settings the light will be later *or* the second event might indicate a + # final state. The latter case happens for example when a light was + # called with a color_temp outside of its range (and HA reports the + # incorrect 'min_mireds' and 'max_mireds', which happens e.g., for + # Philips Hue White GU10 Bluetooth lights). + old_state: Optional[List[State]] = self.last_state_change.get(entity_id) + if ( + old_state is not None + and old_state[0].context.id == new_state.context.id + ): + # If there is already a state change event from this event (with this + # context) then append it to the already existing list. _LOGGER.debug( - "State change event of '%s' was already in 'self.last_state_change' (%s)", + "State change event of '%s' is already in 'self.last_state_change' (%s)" + " adding this state also", entity_id, new_state.context.id, ) - return - - self.last_state_change[entity_id] = new_state + self.last_state_change[entity_id].append(new_state) + else: + self.last_state_change[entity_id] = [new_state] def is_manually_controlled( self, @@ -1057,8 +1067,7 @@ async def significant_change( """ if light not in self.last_state_change: return False - old_state = self.last_state_change[light] - old_state_before_update = self.hass.states.get(light) + old_states: List[State] = self.last_state_change[light] await self.hass.services.async_call( HA_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -1067,46 +1076,21 @@ async def significant_change( context=context, ) new_state = self.hass.states.get(light) - changed = _attributes_have_changed( - light, - old_state.attributes, - new_state.attributes, - adapt_brightness, - adapt_color_temp, - adapt_rgb_color, - context, - ) - - # If changed, do a second check. - if ( - changed - and is_our_context(old_state.context) - and is_our_context(old_state_before_update.context) - and old_state.context.id != old_state_before_update.context.id - ): - # This means there were two consecutive state change events with the - # same context after Adaptive Lighting called 'light.turn_on' last time. - # We are only saving that first one (for reasons explained in - # 'self.state_changed_event_listener'). It can happen that the second - # state change event was actually the final state. That happens for - # example when a light was called with a color_temp outside of its - # range (and HA reports the incorrect 'min_mireds' and 'max_mireds', which - # happens e.g., for Philips Hue White GU10 Bluetooth lights). - _LOGGER.debug( - "Last 'light.turn_on' of '%s' never actually changed the light" - " (or the rgb_color/color_temp was out of range)", - light, - ) - if not _attributes_have_changed( + for index, old_state in enumerate(old_states): + changed = _attributes_have_changed( light, - old_state_before_update.attributes, + old_state.attributes, new_state.attributes, adapt_brightness, adapt_color_temp, adapt_rgb_color, context, - ): - changed = False + ) + if not changed: + _LOGGER.debug( + "States of '%s' didn't change wrt change event nr. %s", light, index + ) + break self.manually_controlled[light] = changed return changed From 5dc328607239fe76b061980bb7a8c7c5c40d61da Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 7 Oct 2020 23:30:52 +0200 Subject: [PATCH 100/165] Return early in the function --- .../components/adaptive_lighting/switch.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 158d8de1aec2ff..82394f9e463248 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -254,7 +254,6 @@ def _attributes_have_changed( adapt_rgb_color, context, ): - changed = False if ( adapt_brightness and ATTR_BRIGHTNESS in old_attributes @@ -271,7 +270,7 @@ def _attributes_have_changed( current_brightness, context.id, ) - changed = True + return True if ( adapt_color_temp @@ -289,7 +288,7 @@ def _attributes_have_changed( current_color_temp, context.id, ) - changed = True + return True if ( adapt_rgb_color @@ -308,8 +307,8 @@ def _attributes_have_changed( current_rgb_color, context.id, ) - changed = True - break + return True + switched_color_temp = ( ATTR_RGB_COLOR in old_attributes and ATTR_RGB_COLOR not in new_attributes ) @@ -322,8 +321,8 @@ def _attributes_have_changed( "'%s' switched from RGB mode to color_temp or visa versa", light, ) - changed = True - return changed + return True + return False class AdaptiveSwitch(SwitchEntity, RestoreEntity): From 4f0c96d3c0b7691019889d2711b82c28e6fc52e5 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Thu, 8 Oct 2020 08:13:25 +0200 Subject: [PATCH 101/165] fix: pass Context instead of only the ID --- .../components/adaptive_lighting/switch.py | 59 ++++++++++++------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 82394f9e463248..4b5bb4aa241aac 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -125,9 +125,17 @@ _DOMAIN_SHORT = "adapt_lgt" +def _short_hash(string: str, length: int = 4) -> str: + """Create a hash of 'string' with length 'length'.""" + return hashlib.sha1(string.encode("UTF-8")).hexdigest()[:length] + + def create_context(name: str, which: str, index: int) -> Context: """Create a context that can identify this integration.""" - return Context(id=f"{_DOMAIN_SHORT}_{name}_{which}_{index}") + # Use a hash for the name because otherwise the context might become + # too long (max len == 36) to fit in the database. + name_hash = _short_hash(name) + return Context(id=f"{_DOMAIN_SHORT}_{name_hash}_{which}_{index}") def is_our_context(context: Optional[Context]) -> bool: @@ -489,12 +497,16 @@ def device_state_attributes(self) -> Dict[str, Any]: ] return dict(self._settings, manually_controlled=manually_controlled) - def create_context(self, which="default") -> Context: + def create_context(self, which: str = "default") -> Context: """Create a context that identifies this Adaptive Lighting instance.""" - # Use a hash for the name because otherwise the context might become - # too long (max len == 36) to fit in the database. - name_hash = hashlib.sha1(self._name.encode("UTF-8")).hexdigest() - context = create_context(name_hash[:4], which, self._context_cnt) + # Right now the highest number of each context_id it can create is + # 'adapt_lgt_XXXX_turn_on_9999999999999' + # 'adapt_lgt_XXXX_interval_999999999999' + # 'adapt_lgt_XXXX_adapt_lights_99999999' + # 'adapt_lgt_XXXX_sleep_999999999999999' + # 'adapt_lgt_XXXX_light_event_999999999' + # So 100 million calls before we run into the 36 chars limit. + context = create_context(self._name, which, self._context_cnt) self._context_cnt += 1 return context @@ -608,8 +620,12 @@ async def _update_attrs_and_maybe_adapt_lights( transition: Optional[int] = None, force: bool = False, context: Optional[Context] = None, - ): - _LOGGER.debug("%s: '_update_attrs_and_maybe_adapt_lights' called", self._name) + ) -> None: + _LOGGER.debug( + "%s: '_update_attrs_and_maybe_adapt_lights' called with context.id='%s'", + self._name, + context, + ) assert self.is_on self._settings = self._sun_light_settings.get_settings( self.sleep_mode_switch.is_on @@ -627,7 +643,7 @@ async def _adapt_lights( transition: Optional[int], force: bool, context: Optional[Context], - ): + ) -> None: _LOGGER.debug( "%s: '_adapt_lights(%s, %s, force=%s, context.id=%s)' called", self.name, @@ -654,7 +670,7 @@ async def _adapt_lights( continue await self._adapt_light(light, transition, force=force, context=context) - async def _sleep_state_event(self, event: Event): + async def _sleep_state_event(self, event: Event) -> None: if not match_state_event(event, (STATE_ON, STATE_OFF)): return _LOGGER.debug("%s: _sleep_state_event, event: '%s'", self._name, event) @@ -665,7 +681,7 @@ async def _sleep_state_event(self, event: Event): context=self.create_context("sleep"), ) - async def _light_event(self, event: Event): + async def _light_event(self, event: Event) -> None: old_state = event.data.get("old_state") new_state = event.data.get("new_state") entity_id = event.data.get("entity_id") @@ -1024,7 +1040,7 @@ def is_manually_controlled( self, light: str, force: bool, - ): + ) -> bool: """Check if the light has been 'on' and is now manually being adjusted.""" manually_controlled = self.manually_controlled.setdefault(light, False) if manually_controlled: @@ -1034,7 +1050,7 @@ def is_manually_controlled( turn_on_event = self.turn_on_event.get(light) if ( turn_on_event is not None - and is_our_context(turn_on_event.context.id) + and not is_our_context(turn_on_event.context) and not force ): # Light was already on and 'light.turn_on' was not called by @@ -1051,12 +1067,12 @@ def is_manually_controlled( async def significant_change( self, - light, - adapt_brightness, - adapt_color_temp, - adapt_rgb_color, - context, - ): + light: str, + adapt_brightness: bool, + adapt_color_temp: bool, + adapt_rgb_color: bool, + context: Context, + ) -> bool: """Has the light made a significant change since last update. This method will detect changes that were made to the light without @@ -1087,7 +1103,10 @@ async def significant_change( ) if not changed: _LOGGER.debug( - "States of '%s' didn't change wrt change event nr. %s", light, index + "States of '%s' didn't change wrt change event nr. %s (context.id=%s)", + light, + index, + context.id, ) break From 5bcba1fea5f10ea240333a392c5f768825fa06cb Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Thu, 8 Oct 2020 08:13:25 +0200 Subject: [PATCH 102/165] fix: pass Context instead of only the ID --- homeassistant/components/adaptive_lighting/switch.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 4b5bb4aa241aac..da9df64acd65c4 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -663,9 +663,10 @@ async def _adapt_lights( ) ): _LOGGER.debug( - "%s: '%s' is being manually controlled, stop adapting.", + "%s: '%s' is being manually controlled, stop adapting, context.id=%s.", self._name, light, + context.id, ) continue await self._adapt_light(light, transition, force=force, context=context) @@ -1058,10 +1059,11 @@ def is_manually_controlled( manually_controlled = self.manually_controlled[light] = True _LOGGER.debug( "'%s' was already on and 'light.turn_on' was not called by the" - " adaptive_lighting integration, the Adaptive Lighting will stop" - " adapting the light until the switch or the light turns off and" - " then on again.", + " 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, + turn_on_event.context.id, ) return manually_controlled From be0cfe03cd048b1ce02ccd0d30702496fb33537d Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Thu, 8 Oct 2020 20:26:31 +0200 Subject: [PATCH 103/165] only log context.id --- homeassistant/components/adaptive_lighting/switch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index da9df64acd65c4..c5b38fcb9fd2e2 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -624,7 +624,7 @@ async def _update_attrs_and_maybe_adapt_lights( _LOGGER.debug( "%s: '_update_attrs_and_maybe_adapt_lights' called with context.id='%s'", self._name, - context, + context.id, ) assert self.is_on self._settings = self._sun_light_settings.get_settings( From a1da04f1e76cb7ac2b0c66707a2824079fe9ea8d Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 9 Oct 2020 09:42:38 +0200 Subject: [PATCH 104/165] log all state change events even the ones that aren't saved --- homeassistant/components/adaptive_lighting/switch.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index c5b38fcb9fd2e2..f36673e67b49fd 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -998,17 +998,19 @@ async def state_changed_event_listener(self, event: Event): return new_state = event.data.get("new_state") - if ( - new_state is not None - and new_state.state == STATE_ON - and is_our_context(new_state.context) - ): + if new_state is not None and new_state.state == STATE_ON: _LOGGER.debug( "Detected a '%s' 'state_changed' event: '%s' with context.id='%s'", entity_id, new_state.attributes, new_state.context.id, ) + + if ( + new_state is not None + and new_state.state == STATE_ON + and is_our_context(new_state.context) + ): # It is possible to have multiple state change events with the same context. # This can happen because a `turn_on.light(brightness_pct=100, transition=30)` # event leads to an instant state change of From 3999d4066aacf613f4cad85096500cf74e228a9e Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 9 Oct 2020 15:04:52 +0200 Subject: [PATCH 105/165] compare to latest target state --- .../components/adaptive_lighting/switch.py | 74 ++++++++++++------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index f36673e67b49fd..e7233004a4aae4 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -7,6 +7,7 @@ from dataclasses import dataclass import datetime from datetime import timedelta +import functools import hashlib import logging from typing import Any, Dict, List, Optional, Tuple, Union @@ -20,7 +21,6 @@ ) from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, ATTR_TRANSITION, @@ -203,7 +203,7 @@ async def async_setup_entry( ) -def validate(config_entry): +def validate(config_entry: ConfigEntry): """Get the options and data from the config_entry and add defaults.""" defaults = {key: default for key, default, _ in VALIDATION_TUPLES} data = deepcopy(defaults) @@ -254,14 +254,14 @@ def _supported_features(hass: HomeAssistant, light: str): def _attributes_have_changed( - light, - old_attributes, - new_attributes, - adapt_brightness, - adapt_color_temp, - adapt_rgb_color, - context, -): + light: str, + old_attributes: Dict[str, Any], + new_attributes: Dict[str, Any], + adapt_brightness: bool, + adapt_color_temp: bool, + adapt_rgb_color: bool, + context: Context, +) -> bool: if ( adapt_brightness and ATTR_BRIGHTNESS in old_attributes @@ -572,7 +572,8 @@ async def _adapt_light( service_data[ATTR_TRANSITION] = transition if "brightness" in features and adapt_brightness: - service_data[ATTR_BRIGHTNESS_PCT] = self._settings["brightness_pct"] + brightness = round(255 * self._settings["brightness_pct"] / 100) + service_data[ATTR_BRIGHTNESS] = brightness if ( "color_temp" in features @@ -607,6 +608,7 @@ async def _adapt_light( service_data, context.id, ) + self.turn_on_off_listener.last_service_data[light] = service_data await self.hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -926,7 +928,7 @@ def get_settings( class TurnOnOffListener: """Track 'light.turn_off' and 'light.turn_on' service calls.""" - def __init__(self, hass): + def __init__(self, hass: HomeAssistant): """Initialize the TurnOnOffListener that is shared among all switches.""" self.hass = hass self.lights = set() @@ -935,12 +937,14 @@ def __init__(self, hass): self.turn_off_event: Dict[str, Event] = {} # Tracks 'light.turn_on' service calls self.turn_on_event: Dict[str, Event] = {} - # Keeps 'asyncio.sleep` tasks that can be cancelled by 'light.turn_on' events + # Keep 'asyncio.sleep' tasks that can be cancelled by 'light.turn_on' events self.sleep_tasks: Dict[str, asyncio.Task] = {} # Tracks which lights are manually controlled self.manually_controlled: Dict[str, bool] = {} # Track 'state_changed' events of self.lights resulting from this integration self.last_state_change: Dict[str, List[State]] = {} + # Track last 'service_data' to 'light.turn_on' resulting from this integration + self.last_service_data: Dict[str, Dict[str, Any]] = {} self.remove_listener = self.hass.bus.async_listen( EVENT_CALL_SERVICE, self.turn_on_off_event_listener @@ -949,13 +953,14 @@ def __init__(self, hass): EVENT_STATE_CHANGED, self.state_changed_event_listener ) - def reset(self, *lights): + def reset(self, *lights) -> None: """Reset the 'manually_controlled' status of the lights.""" for light in lights: self.manually_controlled[light] = False self.last_state_change.pop(light, None) + self.last_service_data.pop(light, None) - async def turn_on_off_event_listener(self, event: Event): + async def turn_on_off_event_listener(self, event: Event) -> None: """Track 'light.turn_off' and 'light.turn_on' service calls.""" domain = event.data.get(ATTR_DOMAIN) if domain != LIGHT_DOMAIN: @@ -991,7 +996,7 @@ async def turn_on_off_event_listener(self, event: Event): task.cancel() self.turn_on_event[eid] = event - async def state_changed_event_listener(self, event: Event): + async def state_changed_event_listener(self, event: Event) -> None: """Track 'state_changed' events.""" entity_id = event.data.get(ATTR_ENTITY_ID, "") if entity_id not in self.lights or entity_id.split(".")[0] != LIGHT_DOMAIN: @@ -1016,7 +1021,7 @@ async def state_changed_event_listener(self, event: Event): # event leads to an instant state change of # `new_state=dict(brightness=100, ...)`. However, after polling the light # could still only be `new_state=dict(brightness=50, ...)`. - # We save both events because the first event change might indicate at what + # We save all events because the first event change might indicate at what # settings the light will be later *or* the second event might indicate a # final state. The latter case happens for example when a light was # called with a color_temp outside of its range (and HA reports the @@ -1095,25 +1100,40 @@ async def significant_change( context=context, ) new_state = self.hass.states.get(light) + compare_to = functools.partial( + _attributes_have_changed, + light=light, + new_attributes=new_state.attributes, + adapt_brightness=adapt_brightness, + adapt_color_temp=adapt_color_temp, + adapt_rgb_color=adapt_rgb_color, + context=context, + ) for index, old_state in enumerate(old_states): - changed = _attributes_have_changed( - light, - old_state.attributes, - new_state.attributes, - adapt_brightness, - adapt_color_temp, - adapt_rgb_color, - context, - ) + changed = compare_to(old_attributes=old_state.attributes) if not changed: _LOGGER.debug( - "States of '%s' didn't change wrt change event nr. %s (context.id=%s)", + "State of '%s' didn't change wrt change event nr. %s (context.id=%s)", light, index, context.id, ) break + last_service_data = self.last_service_data.get(light) + if changed and last_service_data is not None: + # It can happen that the state change events that are associated + # with the last 'light.turn_on' call by this integration were not + # final states. Possibly a later EVENT_STATE_CHANGED happened, where + # the correct target brightness/color was reached. + changed = compare_to(old_attributes=last_service_data) + if not changed: + _LOGGER.debug( + "State of '%s' didn't change wrt 'last_service_data' (context.id=%s)", + light, + context.id, + ) + self.manually_controlled[light] = changed return changed From 54097a347204cd16cf7cd1371d7e74ddb5ca748c Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 9 Oct 2020 15:07:23 +0200 Subject: [PATCH 106/165] simplify if-elif --- homeassistant/components/adaptive_lighting/switch.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index e7233004a4aae4..6f615902e51ad9 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -1028,10 +1028,9 @@ async def state_changed_event_listener(self, event: Event) -> None: # incorrect 'min_mireds' and 'max_mireds', which happens e.g., for # Philips Hue White GU10 Bluetooth lights). old_state: Optional[List[State]] = self.last_state_change.get(entity_id) - if ( - old_state is not None - and old_state[0].context.id == new_state.context.id - ): + if old_state is None: + self.last_state_change[entity_id] = [new_state] + elif old_state[0].context.id == new_state.context.id: # If there is already a state change event from this event (with this # context) then append it to the already existing list. _LOGGER.debug( @@ -1041,8 +1040,6 @@ async def state_changed_event_listener(self, event: Event) -> None: new_state.context.id, ) self.last_state_change[entity_id].append(new_state) - else: - self.last_state_change[entity_id] = [new_state] def is_manually_controlled( self, From 8c909d4f80d7f2584d877c32b2f96f79b5f18753 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 9 Oct 2020 15:12:59 +0200 Subject: [PATCH 107/165] change order of options --- homeassistant/components/adaptive_lighting/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index d3d7ebed7e3704..fd75713e924d5f 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -56,6 +56,7 @@ def int_between(min_int, max_int): (CONF_ADAPT_BRIGHTNESS, DEFAULT_ADAPT_BRIGHTNESS, bool), (CONF_ADAPT_COLOR_TEMP, DEFAULT_ADAPT_COLOR_TEMP, bool), (CONF_ADAPT_RGB_COLOR, DEFAULT_ADAPT_RGB_COLOR, bool), + (CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR, bool), (CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION), (CONF_TRANSITION, DEFAULT_TRANSITION, VALID_TRANSITION), (CONF_INTERVAL, DEFAULT_INTERVAL, cv.positive_int), @@ -63,7 +64,6 @@ def int_between(min_int, max_int): (CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS, int_between(1, 100)), (CONF_MIN_COLOR_TEMP, DEFAULT_MIN_COLOR_TEMP, int_between(1000, 10000)), (CONF_MAX_COLOR_TEMP, DEFAULT_MAX_COLOR_TEMP, int_between(1000, 10000)), - (CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR, bool), (CONF_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_BRIGHTNESS, int_between(1, 100)), (CONF_SLEEP_COLOR_TEMP, DEFAULT_SLEEP_COLOR_TEMP, int_between(1000, 10000)), (CONF_SUNRISE_TIME, NONE_STR, str), From 5b70f7d4e6fc8624e9e70b21d34dfd67a0d352b9 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 11 Oct 2020 11:21:00 +0200 Subject: [PATCH 108/165] Use a different color metric to measure the distance between the colors --- .../components/adaptive_lighting/switch.py | 44 ++++++++++++++----- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 6f615902e51ad9..7c07ac50b65764 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -10,6 +10,7 @@ import functools import hashlib import logging +import math from typing import Any, Dict, List, Optional, Tuple, Union import astral @@ -119,7 +120,7 @@ # Consider it a significant change when attribute changes more than BRIGHTNESS_CHANGE = 25 # ≈10% of total range COLOR_TEMP_CHANGE = 20 # ≈5% of total range -RGB_CHANGE = 30 # ≈12% of total range per component +RGB_REDMEAN_CHANGE = 80 # ≈10% of total range # Keep a short domain version for the context instances (which can only be 36 chars) _DOMAIN_SHORT = "adapt_lgt" @@ -253,6 +254,25 @@ def _supported_features(hass: HomeAssistant, light: str): return {key for key, value in _SUPPORT_OPTS.items() if supported_features & value} +def color_difference_redmean( + rgb1: Tuple[float, float, float], rgb2: Tuple[float, float, float] +) -> float: + """Distance between colors in RGB space (redmean metric). + + The maximal distance between (255, 255, 255) and (0, 0, 0) ≈ 765. + + Sources: + - https://en.wikipedia.org/wiki/Color_difference#Euclidean + - https://www.compuphase.com/cmetric.htm + """ + r_hat = (rgb1[0] + rgb2[0]) / 2 + delta_r, delta_g, delta_b = [(col1 - col2) for col1, col2 in zip(rgb1, rgb2)] + red_term = (2 + r_hat / 256) * delta_r ** 2 + green_term = 4 * delta_g ** 2 + blue_term = (2 + (255 - r_hat) / 256) * delta_b ** 2 + return math.sqrt(red_term + green_term + blue_term) + + def _attributes_have_changed( light: str, old_attributes: Dict[str, Any], @@ -305,17 +325,17 @@ def _attributes_have_changed( ): last_rgb_color = old_attributes[ATTR_RGB_COLOR] current_rgb_color = new_attributes[ATTR_RGB_COLOR] - for last_col, current_col in zip(last_rgb_color, current_rgb_color): - if abs(last_col - current_col) > RGB_CHANGE: - _LOGGER.debug( - "color RGB of '%s' significantly changed from %s to %s with" - " context.id='%s'", - light, - last_rgb_color, - current_rgb_color, - context.id, - ) - return True + redmean_change = color_difference_redmean(last_rgb_color, current_rgb_color) + if redmean_change > RGB_REDMEAN_CHANGE: + _LOGGER.debug( + "color RGB of '%s' significantly changed from %s to %s with" + " context.id='%s'", + light, + last_rgb_color, + current_rgb_color, + context.id, + ) + return True switched_color_temp = ( ATTR_RGB_COLOR in old_attributes and ATTR_RGB_COLOR not in new_attributes From 4de800c64fa710cc4d3972fce1031a3dfa931bed Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 11 Oct 2020 12:40:08 +0200 Subject: [PATCH 109/165] fix strings.json --- homeassistant/components/adaptive_lighting/strings.json | 2 +- .../components/adaptive_lighting/translations/en.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index 57949ea07e9c43..9d835bdc117748 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -6,7 +6,7 @@ "title": "Choose a name for the Adaptive Lighting", "description": "Every instance can contain multiple lights!", "data": { - "name": "[%key:common::config_flow::data::name%]" + "name": "Name" } } }, diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index 57949ea07e9c43..1ceb00df6b623c 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -6,12 +6,12 @@ "title": "Choose a name for the Adaptive Lighting", "description": "Every instance can contain multiple lights!", "data": { - "name": "[%key:common::config_flow::data::name%]" + "name": "Name" } } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "Device is already configured" } }, "options": { From 57bc1637aef345ecf3798a4262c93afad67afe8f Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 13 Oct 2020 21:57:19 +0200 Subject: [PATCH 110/165] do not directly mark a light as manually controlled --- .../components/adaptive_lighting/switch.py | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 7c07ac50b65764..dfd376076aa150 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -3,6 +3,7 @@ import asyncio import bisect +from collections import defaultdict from copy import deepcopy from dataclasses import dataclass import datetime @@ -961,11 +962,17 @@ def __init__(self, hass: HomeAssistant): self.sleep_tasks: Dict[str, asyncio.Task] = {} # Tracks which lights are manually controlled self.manually_controlled: Dict[str, bool] = {} + # Counts the number of times (in a row) a light had a changed state. + self.cnt_significant_changes: Dict[str, int] = defaultdict(int) # Track 'state_changed' events of self.lights resulting from this integration self.last_state_change: Dict[str, List[State]] = {} # Track last 'service_data' to 'light.turn_on' resulting from this integration self.last_service_data: Dict[str, Dict[str, Any]] = {} + # When a state is different `max_cnt_significant_changes` times in a row, + # mark it as manually_controlled. + self.max_cnt_significant_changes = 1 + self.remove_listener = self.hass.bus.async_listen( EVENT_CALL_SERVICE, self.turn_on_off_event_listener ) @@ -979,6 +986,7 @@ def reset(self, *lights) -> None: self.manually_controlled[light] = False self.last_state_change.pop(light, None) self.last_service_data.pop(light, None) + self.cnt_significant_changes[light] = 0 async def turn_on_off_event_listener(self, event: Event) -> None: """Track 'light.turn_off' and 'light.turn_on' service calls.""" @@ -1151,7 +1159,24 @@ async def significant_change( context.id, ) - self.manually_controlled[light] = changed + n_changes = self.cnt_significant_changes[light] + if changed: + self.cnt_significant_changes[light] += 1 + if n_changes >= self.max_cnt_significant_changes: + # Only mark a light as significantly changing, changed==True `x` + # times in a row. We do this because sometimes a state changes + # happens only *after* a new update interval has already started. + self.manually_controlled[light] = changed + else: + if n_changes > 1: + _LOGGER.debug( + "State of '%s' had 'cnt_significant_changes=%s' but the state" + " changed to the expected settings now", + light, + n_changes, + ) + self.cnt_significant_changes[light] = 0 + return changed async def maybe_cancel_adjusting( From 4f19cd6ffaaec42530bc7fa2452434af96634aab Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 13 Oct 2020 22:16:17 +0200 Subject: [PATCH 111/165] fire 'adaptive_lighting_manually_controlled' event --- .../components/adaptive_lighting/switch.py | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index dfd376076aa150..ea20d70216772e 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -52,7 +52,14 @@ SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, ) -from homeassistant.core import Context, Event, HomeAssistant, ServiceCall, State +from homeassistant.core import ( + Context, + Event, + HomeAssistant, + ServiceCall, + State, + callback, +) from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import ( @@ -168,6 +175,15 @@ async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall): ) +@callback +def _fire_manually_controlled_event( + hass: HomeAssistant, light: str, context: Context, is_async=True +): + """Fire an event that 'light' is marked as manually_controlled.""" + fire = hass.bus.async_fire if is_async else hass.bus.fire + fire(f"{DOMAIN}_manually_controlled", {ATTR_ENTITY_ID: light}, context=context) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: bool ): @@ -1089,6 +1105,7 @@ def is_manually_controlled( # Light was already on and 'light.turn_on' was not called by # the adaptive_lighting integration. manually_controlled = self.manually_controlled[light] = True + _fire_manually_controlled_event(self.hass, light, 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" @@ -1166,7 +1183,10 @@ async def significant_change( # Only mark a light as significantly changing, changed==True `x` # times in a row. We do this because sometimes a state changes # happens only *after* a new update interval has already started. - self.manually_controlled[light] = changed + self.manually_controlled[light] = True + _fire_manually_controlled_event( + self.hass, light, context, is_async=False + ) else: if n_changes > 1: _LOGGER.debug( From cb1754c8c9f52161e3188921eb505c441b9ed4df Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 13 Oct 2020 22:35:37 +0200 Subject: [PATCH 112/165] add service 'adaptive_lighting.not_manually_controlled' --- .../components/adaptive_lighting/const.py | 1 + .../components/adaptive_lighting/services.yaml | 9 +++++++++ .../components/adaptive_lighting/switch.py | 17 ++++++++++++++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index fd75713e924d5f..0ec106d95f19ca 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -40,6 +40,7 @@ UNDO_UPDATE_LISTENER = "undo_update_listener" NONE_STR = "None" +SERVICE_NOT_MANUALLY_CONTROLLED = "not_manually_controlled" SERVICE_APPLY = "apply" CONF_TURN_ON_LIGHTS = "turn_on_lights" diff --git a/homeassistant/components/adaptive_lighting/services.yaml b/homeassistant/components/adaptive_lighting/services.yaml index dcc5bbfd69227c..191192e5db3101 100644 --- a/homeassistant/components/adaptive_lighting/services.yaml +++ b/homeassistant/components/adaptive_lighting/services.yaml @@ -22,3 +22,12 @@ apply: turn_on_lights: description: "Turn on the lights that are off, default: false" example: false +not_manually_controlled: + description: Mark a light as not being 'manually_controlled'. + fields: + entity_id: + description: entity_id of the Adaptive Lighting switch. + example: switch.adaptive_lighting_default + lights: + description: entity_id(s) of lights. + example: light.bedroom_ceiling diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index ea20d70216772e..6202372463b4d4 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -104,6 +104,7 @@ EXTRA_VALIDATION, ICON, SERVICE_APPLY, + SERVICE_NOT_MANUALLY_CONTROLLED, SUN_EVENT_MIDNIGHT, SUN_EVENT_NOON, TURNING_OFF_DELAY, @@ -175,13 +176,21 @@ async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall): ) +async def handle_not_manually_controlled( + switch: AdaptiveSwitch, service_call: ServiceCall +): + """Remove lights from the 'manually_controlled' list.""" + all_lights = _expand_light_groups(switch.hass, service_call.data[CONF_LIGHTS]) + switch.turn_on_off_listener.reset(*all_lights) + + @callback def _fire_manually_controlled_event( hass: HomeAssistant, light: str, context: Context, is_async=True ): """Fire an event that 'light' is marked as manually_controlled.""" fire = hass.bus.async_fire if is_async else hass.bus.fire - fire(f"{DOMAIN}_manually_controlled", {ATTR_ENTITY_ID: light}, context=context) + fire(f"{DOMAIN}.manually_controlled", {ATTR_ENTITY_ID: light}, context=context) async def async_setup_entry( @@ -220,6 +229,12 @@ async def async_setup_entry( handle_apply, ) + platform.async_register_entity_service( + SERVICE_NOT_MANUALLY_CONTROLLED, + {vol.Required(CONF_LIGHTS): cv.entity_ids}, + handle_not_manually_controlled, + ) + def validate(config_entry: ConfigEntry): """Get the options and data from the config_entry and add defaults.""" From e7fb5b083a1a9d942c0fffa86a872f270269494e Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 14 Oct 2020 07:50:45 +0200 Subject: [PATCH 113/165] change 'not_manually_controlled' to 'set_manually_controlled' service --- .../components/adaptive_lighting/const.py | 3 +- .../adaptive_lighting/services.yaml | 7 +++-- .../components/adaptive_lighting/strings.json | 4 +-- .../components/adaptive_lighting/switch.py | 30 +++++++++++++++---- .../adaptive_lighting/translations/en.json | 4 +-- 5 files changed, 35 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index 0ec106d95f19ca..ab732e668921dc 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -40,7 +40,8 @@ UNDO_UPDATE_LISTENER = "undo_update_listener" NONE_STR = "None" -SERVICE_NOT_MANUALLY_CONTROLLED = "not_manually_controlled" +SERVICE_SET_MANUALLY_CONTROLLED = "set_manually_controlled" +CONF_MANUALLY_CONTROLLED = "manually_controlled" SERVICE_APPLY = "apply" CONF_TURN_ON_LIGHTS = "turn_on_lights" diff --git a/homeassistant/components/adaptive_lighting/services.yaml b/homeassistant/components/adaptive_lighting/services.yaml index 191192e5db3101..713e559a054b24 100644 --- a/homeassistant/components/adaptive_lighting/services.yaml +++ b/homeassistant/components/adaptive_lighting/services.yaml @@ -22,12 +22,15 @@ apply: turn_on_lights: description: "Turn on the lights that are off, default: false" example: false -not_manually_controlled: - description: Mark a light as not being 'manually_controlled'. +set_manually_controlled: + description: Mark a light as (not) being 'manually_controlled'. fields: entity_id: description: entity_id of the Adaptive Lighting switch. example: switch.adaptive_lighting_default + manually_controlled: + description: "Whether to add ('true') or remove ('false') the light from the 'manually_controlled' list, default: true" + example: true lights: description: entity_id(s) of lights. example: light.bedroom_ceiling diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index 9d835bdc117748..d45edc4bcd9f06 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -35,9 +35,9 @@ "sleep_brightness": "sleep_brightness, in %", "sleep_color_temp": "sleep_color_temp, in Kelvin", "sunrise_offset": "sunrise_offset, in +/- seconds", - "sunrise_time": "sunrise_time, in 'HH:MM:SS' format", + "sunrise_time": "sunrise_time, in 'HH:MM:SS' format (if 'None', it uses the actual sunrise time at your location)", "sunset_offset": "sunset_offset, in +/- seconds", - "sunset_time": "sunset_time, in 'HH:MM:SS' format", + "sunset_time": "sunset_time, in 'HH:MM:SS' format (if 'None', it uses the actual sunset time at your location)", "take_over_control": "take_over_control, if anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on.", "detect_non_ha_changes": "detect_non_ha_changes, detects all >5% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)", "transition": "transition, in seconds" diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 6202372463b4d4..d41db80fd234fb 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -85,6 +85,7 @@ CONF_INITIAL_TRANSITION, CONF_INTERVAL, CONF_LIGHTS, + CONF_MANUALLY_CONTROLLED, CONF_MAX_BRIGHTNESS, CONF_MAX_COLOR_TEMP, CONF_MIN_BRIGHTNESS, @@ -104,7 +105,7 @@ EXTRA_VALIDATION, ICON, SERVICE_APPLY, - SERVICE_NOT_MANUALLY_CONTROLLED, + SERVICE_SET_MANUALLY_CONTROLLED, SUN_EVENT_MIDNIGHT, SUN_EVENT_NOON, TURNING_OFF_DELAY, @@ -176,12 +177,25 @@ async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall): ) -async def handle_not_manually_controlled( +async def handle_set_manually_controlled( switch: AdaptiveSwitch, service_call: ServiceCall ): """Remove lights from the 'manually_controlled' list.""" all_lights = _expand_light_groups(switch.hass, service_call.data[CONF_LIGHTS]) - switch.turn_on_off_listener.reset(*all_lights) + _LOGGER.debug( + "Called 'adaptive_lighting.set_manually_controlled' service with '%s'", + service_call.data, + ) + if service_call.data[CONF_MANUALLY_CONTROLLED]: + for light in all_lights: + switch.turn_on_off_listener.manually_controlled[light] = True + _fire_manually_controlled_event(switch.hass, light, service_call.context) + else: + switch.turn_on_off_listener.reset(*all_lights) + # pylint: disable=protected-access + await switch._adapt_lights( + all_lights, switch._initial_transition, True, service_call.context + ) @callback @@ -230,9 +244,12 @@ async def async_setup_entry( ) platform.async_register_entity_service( - SERVICE_NOT_MANUALLY_CONTROLLED, - {vol.Required(CONF_LIGHTS): cv.entity_ids}, - handle_not_manually_controlled, + SERVICE_SET_MANUALLY_CONTROLLED, + { + vol.Required(CONF_LIGHTS): cv.entity_ids, + vol.Optional(CONF_MANUALLY_CONTROLLED, default=True): cv.boolean, + }, + handle_set_manually_controlled, ) @@ -639,6 +656,7 @@ async def _adapt_light( service_data[ATTR_COLOR_TEMP] = color_temp_mired elif "color" in features and adapt_rgb_color: service_data[ATTR_RGB_COLOR] = self._settings["rgb_color"] + context = context or self.create_context("adapt_lights") if ( self._take_over_control diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index 1ceb00df6b623c..f306cf6054d125 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -35,9 +35,9 @@ "sleep_brightness": "sleep_brightness, in %", "sleep_color_temp": "sleep_color_temp, in Kelvin", "sunrise_offset": "sunrise_offset, in +/- seconds", - "sunrise_time": "sunrise_time, in 'HH:MM:SS' format", + "sunrise_time": "sunrise_time, in 'HH:MM:SS' format (if 'None', it uses the actual sunrise time at your location)", "sunset_offset": "sunset_offset, in +/- seconds", - "sunset_time": "sunset_time, in 'HH:MM:SS' format", + "sunset_time": "sunset_time, in 'HH:MM:SS' format (if 'None', it uses the actual sunset time at your location)", "take_over_control": "take_over_control, if anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on.", "detect_non_ha_changes": "detect_non_ha_changes, detects all >5% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)", "transition": "transition, in seconds" From 6cfe818c5c1a4b306cd54e17117b5a9335fc3dd6 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 14 Oct 2020 07:55:49 +0200 Subject: [PATCH 114/165] rename event_type to 'adaptive_lighting.manual_control' --- .../components/adaptive_lighting/switch.py | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index d41db80fd234fb..681aeddbb10113 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -189,22 +189,27 @@ async def handle_set_manually_controlled( if service_call.data[CONF_MANUALLY_CONTROLLED]: for light in all_lights: switch.turn_on_off_listener.manually_controlled[light] = True - _fire_manually_controlled_event(switch.hass, light, service_call.context) + _fire_manual_control_event(switch.hass, light, service_call.context) else: switch.turn_on_off_listener.reset(*all_lights) # pylint: disable=protected-access await switch._adapt_lights( - all_lights, switch._initial_transition, True, service_call.context + all_lights, + transition=switch._initial_transition, + force=True, + context=switch.create_context("service"), ) @callback -def _fire_manually_controlled_event( +def _fire_manual_control_event( hass: HomeAssistant, light: str, context: Context, is_async=True ): """Fire an event that 'light' is marked as manually_controlled.""" fire = hass.bus.async_fire if is_async else hass.bus.fire - fire(f"{DOMAIN}.manually_controlled", {ATTR_ENTITY_ID: light}, context=context) + # Calling the event_type='manually_controlled' would be better, but + # event_type has a 32 character limit. + fire(f"{DOMAIN}.manual_control", {ATTR_ENTITY_ID: light}, context=context) async def async_setup_entry( @@ -574,6 +579,7 @@ def create_context(self, which: str = "default") -> Context: # 'adapt_lgt_XXXX_adapt_lights_99999999' # 'adapt_lgt_XXXX_sleep_999999999999999' # 'adapt_lgt_XXXX_light_event_999999999' + # 'adapt_lgt_XXXX_service_9999999999999' # So 100 million calls before we run into the 36 chars limit. context = create_context(self._name, which, self._context_cnt) self._context_cnt += 1 @@ -693,6 +699,7 @@ async def _update_attrs_and_maybe_adapt_lights( force: bool = False, context: Optional[Context] = None, ) -> None: + assert context is not None _LOGGER.debug( "%s: '_update_attrs_and_maybe_adapt_lights' called with context.id='%s'", self._name, @@ -716,6 +723,7 @@ async def _adapt_lights( force: bool, context: Optional[Context], ) -> None: + assert context is not None _LOGGER.debug( "%s: '_adapt_lights(%s, %s, force=%s, context.id=%s)' called", self.name, @@ -770,7 +778,7 @@ async def _light_event(self, event: Event) -> None: entity_id, event.context.id, ) - self.turn_on_off_listener.reset(entity_id) + self.turn_on_off_listener.reset(entity_id, reset_manually_controlled=False) # Tracks 'off' → 'on' state changes self._off_to_on_event[entity_id] = event lock = self._locks.get(entity_id) @@ -1029,10 +1037,11 @@ def __init__(self, hass: HomeAssistant): EVENT_STATE_CHANGED, self.state_changed_event_listener ) - def reset(self, *lights) -> None: + def reset(self, *lights, reset_manually_controlled=True) -> None: """Reset the 'manually_controlled' status of the lights.""" for light in lights: - self.manually_controlled[light] = False + if reset_manually_controlled: + self.manually_controlled[light] = False self.last_state_change.pop(light, None) self.last_service_data.pop(light, None) self.cnt_significant_changes[light] = 0 @@ -1138,7 +1147,7 @@ def is_manually_controlled( # Light was already on and 'light.turn_on' was not called by # the adaptive_lighting integration. manually_controlled = self.manually_controlled[light] = True - _fire_manually_controlled_event(self.hass, light, turn_on_event.context) + _fire_manual_control_event(self.hass, light, 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" @@ -1213,13 +1222,11 @@ async def significant_change( if changed: self.cnt_significant_changes[light] += 1 if n_changes >= self.max_cnt_significant_changes: - # Only mark a light as significantly changing, changed==True `x` - # times in a row. We do this because sometimes a state changes + # Only mark a light as significantly changing, if changed==True + # N times in a row. We do this because sometimes a state changes # happens only *after* a new update interval has already started. self.manually_controlled[light] = True - _fire_manually_controlled_event( - self.hass, light, context, is_async=False - ) + _fire_manual_control_event(self.hass, light, context, is_async=False) else: if n_changes > 1: _LOGGER.debug( From 312c6419512b39dbc8ae42cbe741e38c1b86efd8 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 14 Oct 2020 22:04:19 +0200 Subject: [PATCH 115/165] rename 'manually_controlled' to 'manual_control' everywhere (because it is shorter) --- .../components/adaptive_lighting/const.py | 4 +- .../adaptive_lighting/services.yaml | 8 +-- .../components/adaptive_lighting/switch.py | 60 +++++++++---------- 3 files changed, 34 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index ab732e668921dc..a96e301b276775 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -40,8 +40,8 @@ UNDO_UPDATE_LISTENER = "undo_update_listener" NONE_STR = "None" -SERVICE_SET_MANUALLY_CONTROLLED = "set_manually_controlled" -CONF_MANUALLY_CONTROLLED = "manually_controlled" +SERVICE_SET_MANUAL_CONTROL = "set_manual_control" +CONF_MANUAL_CONTROL = "manual_control" SERVICE_APPLY = "apply" CONF_TURN_ON_LIGHTS = "turn_on_lights" diff --git a/homeassistant/components/adaptive_lighting/services.yaml b/homeassistant/components/adaptive_lighting/services.yaml index 713e559a054b24..ba56b77b44f1a4 100644 --- a/homeassistant/components/adaptive_lighting/services.yaml +++ b/homeassistant/components/adaptive_lighting/services.yaml @@ -22,14 +22,14 @@ apply: turn_on_lights: description: "Turn on the lights that are off, default: false" example: false -set_manually_controlled: - description: Mark a light as (not) being 'manually_controlled'. +set_manual_control: + description: Mark whether a light is 'manually controlled'. fields: entity_id: description: entity_id of the Adaptive Lighting switch. example: switch.adaptive_lighting_default - manually_controlled: - description: "Whether to add ('true') or remove ('false') the light from the 'manually_controlled' list, default: true" + manual_control: + description: "Whether to add ('true') or remove ('false') the light from the 'manual_control' list, default: true" example: true lights: description: entity_id(s) of lights. diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 681aeddbb10113..4fdf4d394bbeac 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -85,7 +85,7 @@ CONF_INITIAL_TRANSITION, CONF_INTERVAL, CONF_LIGHTS, - CONF_MANUALLY_CONTROLLED, + CONF_MANUAL_CONTROL, CONF_MAX_BRIGHTNESS, CONF_MAX_COLOR_TEMP, CONF_MIN_BRIGHTNESS, @@ -105,7 +105,7 @@ EXTRA_VALIDATION, ICON, SERVICE_APPLY, - SERVICE_SET_MANUALLY_CONTROLLED, + SERVICE_SET_MANUAL_CONTROL, SUN_EVENT_MIDNIGHT, SUN_EVENT_NOON, TURNING_OFF_DELAY, @@ -177,18 +177,16 @@ async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall): ) -async def handle_set_manually_controlled( - switch: AdaptiveSwitch, service_call: ServiceCall -): - """Remove lights from the 'manually_controlled' list.""" +async def handle_set_manual_control(switch: AdaptiveSwitch, service_call: ServiceCall): + """Set or unset lights as 'manually controlled'.""" all_lights = _expand_light_groups(switch.hass, service_call.data[CONF_LIGHTS]) _LOGGER.debug( - "Called 'adaptive_lighting.set_manually_controlled' service with '%s'", + "Called 'adaptive_lighting.set_manual_control' service with '%s'", service_call.data, ) - if service_call.data[CONF_MANUALLY_CONTROLLED]: + if service_call.data[CONF_MANUAL_CONTROL]: for light in all_lights: - switch.turn_on_off_listener.manually_controlled[light] = True + switch.turn_on_off_listener.manual_control[light] = True _fire_manual_control_event(switch.hass, light, service_call.context) else: switch.turn_on_off_listener.reset(*all_lights) @@ -205,10 +203,8 @@ async def handle_set_manually_controlled( def _fire_manual_control_event( hass: HomeAssistant, light: str, context: Context, is_async=True ): - """Fire an event that 'light' is marked as manually_controlled.""" + """Fire an event that 'light' is marked as manual_control.""" fire = hass.bus.async_fire if is_async else hass.bus.fire - # Calling the event_type='manually_controlled' would be better, but - # event_type has a 32 character limit. fire(f"{DOMAIN}.manual_control", {ATTR_ENTITY_ID: light}, context=context) @@ -249,12 +245,12 @@ async def async_setup_entry( ) platform.async_register_entity_service( - SERVICE_SET_MANUALLY_CONTROLLED, + SERVICE_SET_MANUAL_CONTROL, { vol.Required(CONF_LIGHTS): cv.entity_ids, - vol.Optional(CONF_MANUALLY_CONTROLLED, default=True): cv.boolean, + vol.Optional(CONF_MANUAL_CONTROL, default=True): cv.boolean, }, - handle_set_manually_controlled, + handle_set_manual_control, ) @@ -564,12 +560,12 @@ def device_state_attributes(self) -> Dict[str, Any]: """Return the attributes of the switch.""" if not self.is_on: return {key: None for key in self._settings} - manually_controlled = [ + manual_control = [ light for light in self._lights - if self.turn_on_off_listener.manually_controlled.get(light) + if self.turn_on_off_listener.manual_control.get(light) ] - return dict(self._settings, manually_controlled=manually_controlled) + return dict(self._settings, manual_control=manual_control) def create_context(self, which: str = "default") -> Context: """Create a context that identifies this Adaptive Lighting instance.""" @@ -778,7 +774,7 @@ async def _light_event(self, event: Event) -> None: entity_id, event.context.id, ) - self.turn_on_off_listener.reset(entity_id, reset_manually_controlled=False) + self.turn_on_off_listener.reset(entity_id, reset_manual_control=False) # Tracks 'off' → 'on' state changes self._off_to_on_event[entity_id] = event lock = self._locks.get(entity_id) @@ -1018,7 +1014,7 @@ def __init__(self, hass: HomeAssistant): # Keep 'asyncio.sleep' tasks that can be cancelled by 'light.turn_on' events self.sleep_tasks: Dict[str, asyncio.Task] = {} # Tracks which lights are manually controlled - self.manually_controlled: Dict[str, bool] = {} + self.manual_control: Dict[str, bool] = {} # Counts the number of times (in a row) a light had a changed state. self.cnt_significant_changes: Dict[str, int] = defaultdict(int) # Track 'state_changed' events of self.lights resulting from this integration @@ -1028,7 +1024,7 @@ def __init__(self, hass: HomeAssistant): # When a state is different `max_cnt_significant_changes` times in a row, # mark it as manually_controlled. - self.max_cnt_significant_changes = 1 + self.max_cnt_significant_changes = 2 self.remove_listener = self.hass.bus.async_listen( EVENT_CALL_SERVICE, self.turn_on_off_event_listener @@ -1037,11 +1033,11 @@ def __init__(self, hass: HomeAssistant): EVENT_STATE_CHANGED, self.state_changed_event_listener ) - def reset(self, *lights, reset_manually_controlled=True) -> None: - """Reset the 'manually_controlled' status of the lights.""" + def reset(self, *lights, reset_manual_control=True) -> None: + """Reset the 'manual_control' status of the lights.""" for light in lights: - if reset_manually_controlled: - self.manually_controlled[light] = False + if reset_manual_control: + self.manual_control[light] = False self.last_state_change.pop(light, None) self.last_service_data.pop(light, None) self.cnt_significant_changes[light] = 0 @@ -1132,9 +1128,9 @@ def is_manually_controlled( light: str, force: bool, ) -> bool: - """Check if the light has been 'on' and is now manually being adjusted.""" - manually_controlled = self.manually_controlled.setdefault(light, False) - if manually_controlled: + """Check if the light has been 'on' and is now manually controlled.""" + manual_control = self.manual_control.setdefault(light, False) + if manual_control: # Manually controlled until light is turned on and off return True @@ -1146,7 +1142,7 @@ def is_manually_controlled( ): # Light was already on and 'light.turn_on' was not called by # the adaptive_lighting integration. - manually_controlled = self.manually_controlled[light] = True + manual_control = self.manual_control[light] = True _fire_manual_control_event(self.hass, light, turn_on_event.context) _LOGGER.debug( "'%s' was already on and 'light.turn_on' was not called by the" @@ -1156,7 +1152,7 @@ def is_manually_controlled( light, turn_on_event.context.id, ) - return manually_controlled + return manual_control async def significant_change( self, @@ -1170,7 +1166,7 @@ async def significant_change( This method will detect changes that were made to the light without calling 'light.turn_on', so outside of Home Assistant. If a change is - detected, we mark the light as 'manually_controlled' until the light + detected, we mark the light as 'manually controlled' until the light or switch is turned 'off' and 'on' again. """ if light not in self.last_state_change: @@ -1225,7 +1221,7 @@ async def significant_change( # Only mark a light as significantly changing, if changed==True # N times in a row. We do this because sometimes a state changes # happens only *after* a new update interval has already started. - self.manually_controlled[light] = True + self.manual_control[light] = True _fire_manual_control_event(self.hass, light, context, is_async=False) else: if n_changes > 1: From 29cd165f374ef08a3aed62aff65a86d0a3708539 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 16 Oct 2020 14:12:46 +0200 Subject: [PATCH 116/165] fix bug where last_state_change wasn't updated if prev state was not None --- homeassistant/components/adaptive_lighting/switch.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 4fdf4d394bbeac..23d460e83c6120 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -1058,9 +1058,10 @@ async def turn_on_off_event_listener(self, event: Event) -> None: if service == SERVICE_TURN_OFF: transition = service_data.get(ATTR_TRANSITION) _LOGGER.debug( - "Detected an 'light.turn_off('%s', transition=%s)' event", + "Detected an 'light.turn_off('%s', transition=%s)' event with context.id='%s'", entity_ids, transition, + event.context.id, ) for eid in entity_ids: self.turn_off_event[eid] = event @@ -1110,9 +1111,10 @@ async def state_changed_event_listener(self, event: Event) -> None: # incorrect 'min_mireds' and 'max_mireds', which happens e.g., for # Philips Hue White GU10 Bluetooth lights). old_state: Optional[List[State]] = self.last_state_change.get(entity_id) - if old_state is None: - self.last_state_change[entity_id] = [new_state] - elif old_state[0].context.id == new_state.context.id: + if ( + old_state is not None + and old_state[0].context.id == new_state.context.id + ): # If there is already a state change event from this event (with this # context) then append it to the already existing list. _LOGGER.debug( @@ -1122,6 +1124,8 @@ async def state_changed_event_listener(self, event: Event) -> None: new_state.context.id, ) self.last_state_change[entity_id].append(new_state) + else: + self.last_state_change[entity_id] = [new_state] def is_manually_controlled( self, From 59eb82015fb68de1c70b24c37d948b9f72b0623d Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 16 Oct 2020 14:43:49 +0200 Subject: [PATCH 117/165] fix hassfest error --- .coveragerc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.coveragerc b/.coveragerc index 3abbc9e9db8519..b85e82c0d89bd1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -18,7 +18,9 @@ omit = homeassistant/components/acmeda/helpers.py homeassistant/components/acmeda/hub.py homeassistant/components/acmeda/sensor.py - homeassistant/components/adaptive_lighting/* + homeassistant/components/adaptive_lighting/__init__.py + homeassistant/components/adaptive_lighting/const.py + homeassistant/components/adaptive_lighting/switch.py homeassistant/components/adguard/__init__.py homeassistant/components/adguard/const.py homeassistant/components/adguard/sensor.py From 851b117cf47e9c3a0b290f5349cafbe8dabb2046 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 16 Oct 2020 18:18:42 +0200 Subject: [PATCH 118/165] add tests/components/adaptive_lighting/test_config_flow.py --- .../adaptive_lighting/test_config_flow.py | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 tests/components/adaptive_lighting/test_config_flow.py diff --git a/tests/components/adaptive_lighting/test_config_flow.py b/tests/components/adaptive_lighting/test_config_flow.py new file mode 100644 index 00000000000000..b83250ee81ccd5 --- /dev/null +++ b/tests/components/adaptive_lighting/test_config_flow.py @@ -0,0 +1,135 @@ +"""Test Axis config flow.""" +from homeassistant import data_entry_flow +from homeassistant.components.adaptive_lighting.const import ( + CONF_ADAPT_BRIGHTNESS, + CONF_ADAPT_COLOR_TEMP, + CONF_ADAPT_RGB_COLOR, + CONF_DETECT_NON_HA_CHANGES, + CONF_INITIAL_TRANSITION, + CONF_INTERVAL, + CONF_LIGHTS, + CONF_MAX_BRIGHTNESS, + CONF_MAX_COLOR_TEMP, + CONF_MIN_BRIGHTNESS, + CONF_MIN_COLOR_TEMP, + CONF_ONLY_ONCE, + CONF_PREFER_RGB_COLOR, + CONF_SLEEP_BRIGHTNESS, + CONF_SLEEP_COLOR_TEMP, + CONF_SUNRISE_OFFSET, + CONF_SUNRISE_TIME, + CONF_SUNSET_OFFSET, + CONF_SUNSET_TIME, + CONF_TAKE_OVER_CONTROL, + CONF_TRANSITION, + DEFAULT_ADAPT_BRIGHTNESS, + DEFAULT_ADAPT_COLOR_TEMP, + DEFAULT_ADAPT_RGB_COLOR, + DEFAULT_DETECT_NON_HA_CHANGES, + DEFAULT_INITIAL_TRANSITION, + DEFAULT_INTERVAL, + DEFAULT_LIGHTS, + DEFAULT_MAX_BRIGHTNESS, + DEFAULT_MAX_COLOR_TEMP, + DEFAULT_MIN_BRIGHTNESS, + DEFAULT_MIN_COLOR_TEMP, + DEFAULT_NAME, + DEFAULT_ONLY_ONCE, + DEFAULT_PREFER_RGB_COLOR, + DEFAULT_SLEEP_BRIGHTNESS, + DEFAULT_SLEEP_COLOR_TEMP, + DEFAULT_SUNRISE_OFFSET, + DEFAULT_SUNSET_OFFSET, + DEFAULT_TAKE_OVER_CONTROL, + DEFAULT_TRANSITION, + DOMAIN, + NONE_STR, +) +from homeassistant.const import CONF_NAME + +from tests.common import MockConfigEntry + +DEFAULT_DATA = { + CONF_LIGHTS: DEFAULT_LIGHTS, + CONF_ADAPT_BRIGHTNESS: DEFAULT_ADAPT_BRIGHTNESS, + CONF_ADAPT_COLOR_TEMP: DEFAULT_ADAPT_COLOR_TEMP, + CONF_ADAPT_RGB_COLOR: DEFAULT_ADAPT_RGB_COLOR, + CONF_DETECT_NON_HA_CHANGES: DEFAULT_DETECT_NON_HA_CHANGES, + CONF_INITIAL_TRANSITION: DEFAULT_INITIAL_TRANSITION, + CONF_INTERVAL: DEFAULT_INTERVAL, + CONF_MAX_BRIGHTNESS: DEFAULT_MAX_BRIGHTNESS, + CONF_MAX_COLOR_TEMP: DEFAULT_MAX_COLOR_TEMP, + CONF_MIN_BRIGHTNESS: DEFAULT_MIN_BRIGHTNESS, + CONF_MIN_COLOR_TEMP: DEFAULT_MIN_COLOR_TEMP, + CONF_ONLY_ONCE: DEFAULT_ONLY_ONCE, + CONF_PREFER_RGB_COLOR: DEFAULT_PREFER_RGB_COLOR, + CONF_SLEEP_BRIGHTNESS: DEFAULT_SLEEP_BRIGHTNESS, + CONF_SLEEP_COLOR_TEMP: DEFAULT_SLEEP_COLOR_TEMP, + CONF_SUNRISE_OFFSET: DEFAULT_SUNRISE_OFFSET, + CONF_SUNRISE_TIME: None, + CONF_SUNSET_OFFSET: DEFAULT_SUNSET_OFFSET, + CONF_SUNSET_TIME: None, + CONF_TAKE_OVER_CONTROL: DEFAULT_TAKE_OVER_CONTROL, + CONF_TRANSITION: DEFAULT_TRANSITION, +} + + +async def test_flow_manual_configuration(hass): + """Test that config flow works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["handler"] == "adaptive_lighting" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NAME: "living room"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "living room" + + +async def test_import_success(hass): + """Test import step is successful.""" + data = DEFAULT_DATA.copy() + data[CONF_NAME] = DEFAULT_NAME + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "import"}, + data=data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + for key, value in data.items(): + assert result["data"][key] == value + + +async def test_options(hass): + """Test updating options.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_NAME, + data={CONF_NAME: DEFAULT_NAME}, + options={}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + data = DEFAULT_DATA.copy() + data[CONF_SUNRISE_TIME] = NONE_STR + data[CONF_SUNSET_TIME] = NONE_STR + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=data, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + for key, value in data.items(): + assert result["data"][key] == value From caa568f3d4baa9f878d8d8918c92daed4a4f5177 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 16 Oct 2020 18:20:43 +0200 Subject: [PATCH 119/165] simplify tests/components/adaptive_lighting/test_config_flow.py --- .../adaptive_lighting/test_config_flow.py | 65 +------------------ 1 file changed, 3 insertions(+), 62 deletions(-) diff --git a/tests/components/adaptive_lighting/test_config_flow.py b/tests/components/adaptive_lighting/test_config_flow.py index b83250ee81ccd5..ac989f4b151d3a 100644 --- a/tests/components/adaptive_lighting/test_config_flow.py +++ b/tests/components/adaptive_lighting/test_config_flow.py @@ -1,77 +1,18 @@ -"""Test Axis config flow.""" +"""Test Adaptive Lighting config flow.""" from homeassistant import data_entry_flow from homeassistant.components.adaptive_lighting.const import ( - CONF_ADAPT_BRIGHTNESS, - CONF_ADAPT_COLOR_TEMP, - CONF_ADAPT_RGB_COLOR, - CONF_DETECT_NON_HA_CHANGES, - CONF_INITIAL_TRANSITION, - CONF_INTERVAL, - CONF_LIGHTS, - CONF_MAX_BRIGHTNESS, - CONF_MAX_COLOR_TEMP, - CONF_MIN_BRIGHTNESS, - CONF_MIN_COLOR_TEMP, - CONF_ONLY_ONCE, - CONF_PREFER_RGB_COLOR, - CONF_SLEEP_BRIGHTNESS, - CONF_SLEEP_COLOR_TEMP, - CONF_SUNRISE_OFFSET, CONF_SUNRISE_TIME, - CONF_SUNSET_OFFSET, CONF_SUNSET_TIME, - CONF_TAKE_OVER_CONTROL, - CONF_TRANSITION, - DEFAULT_ADAPT_BRIGHTNESS, - DEFAULT_ADAPT_COLOR_TEMP, - DEFAULT_ADAPT_RGB_COLOR, - DEFAULT_DETECT_NON_HA_CHANGES, - DEFAULT_INITIAL_TRANSITION, - DEFAULT_INTERVAL, - DEFAULT_LIGHTS, - DEFAULT_MAX_BRIGHTNESS, - DEFAULT_MAX_COLOR_TEMP, - DEFAULT_MIN_BRIGHTNESS, - DEFAULT_MIN_COLOR_TEMP, DEFAULT_NAME, - DEFAULT_ONLY_ONCE, - DEFAULT_PREFER_RGB_COLOR, - DEFAULT_SLEEP_BRIGHTNESS, - DEFAULT_SLEEP_COLOR_TEMP, - DEFAULT_SUNRISE_OFFSET, - DEFAULT_SUNSET_OFFSET, - DEFAULT_TAKE_OVER_CONTROL, - DEFAULT_TRANSITION, DOMAIN, NONE_STR, + VALIDATION_TUPLES, ) from homeassistant.const import CONF_NAME from tests.common import MockConfigEntry -DEFAULT_DATA = { - CONF_LIGHTS: DEFAULT_LIGHTS, - CONF_ADAPT_BRIGHTNESS: DEFAULT_ADAPT_BRIGHTNESS, - CONF_ADAPT_COLOR_TEMP: DEFAULT_ADAPT_COLOR_TEMP, - CONF_ADAPT_RGB_COLOR: DEFAULT_ADAPT_RGB_COLOR, - CONF_DETECT_NON_HA_CHANGES: DEFAULT_DETECT_NON_HA_CHANGES, - CONF_INITIAL_TRANSITION: DEFAULT_INITIAL_TRANSITION, - CONF_INTERVAL: DEFAULT_INTERVAL, - CONF_MAX_BRIGHTNESS: DEFAULT_MAX_BRIGHTNESS, - CONF_MAX_COLOR_TEMP: DEFAULT_MAX_COLOR_TEMP, - CONF_MIN_BRIGHTNESS: DEFAULT_MIN_BRIGHTNESS, - CONF_MIN_COLOR_TEMP: DEFAULT_MIN_COLOR_TEMP, - CONF_ONLY_ONCE: DEFAULT_ONLY_ONCE, - CONF_PREFER_RGB_COLOR: DEFAULT_PREFER_RGB_COLOR, - CONF_SLEEP_BRIGHTNESS: DEFAULT_SLEEP_BRIGHTNESS, - CONF_SLEEP_COLOR_TEMP: DEFAULT_SLEEP_COLOR_TEMP, - CONF_SUNRISE_OFFSET: DEFAULT_SUNRISE_OFFSET, - CONF_SUNRISE_TIME: None, - CONF_SUNSET_OFFSET: DEFAULT_SUNSET_OFFSET, - CONF_SUNSET_TIME: None, - CONF_TAKE_OVER_CONTROL: DEFAULT_TAKE_OVER_CONTROL, - CONF_TRANSITION: DEFAULT_TRANSITION, -} +DEFAULT_DATA = {key: default for key, default, _ in VALIDATION_TUPLES} async def test_flow_manual_configuration(hass): From 71a56fbb5642f0ea05a3505e1717a1bec9087f19 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 17 Oct 2020 13:17:01 +0200 Subject: [PATCH 120/165] add tests/components/adaptive_lighting/test_init.py --- .../components/adaptive_lighting/__init__.py | 5 +- .../components/adaptive_lighting/switch.py | 1 + .../components/adaptive_lighting/test_init.py | 55 +++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 tests/components/adaptive_lighting/test_init.py diff --git a/homeassistant/components/adaptive_lighting/__init__.py b/homeassistant/components/adaptive_lighting/__init__.py index be8868f516c226..89f8173274bbcb 100644 --- a/homeassistant/components/adaptive_lighting/__init__.py +++ b/homeassistant/components/adaptive_lighting/__init__.py @@ -100,7 +100,7 @@ async def async_unload_entry(hass, config_entry: ConfigEntry) -> bool: ) data = hass.data[DOMAIN] data[config_entry.entry_id][UNDO_UPDATE_LISTENER]() - if len(data) == 1: # no more config_entries + if len(data) == 1 and ATTR_TURN_ON_OFF_LISTENER in data: # no more config_entries turn_on_off_listener = data.pop(ATTR_TURN_ON_OFF_LISTENER) turn_on_off_listener.remove_listener() turn_on_off_listener.remove_listener2() @@ -108,4 +108,7 @@ async def async_unload_entry(hass, config_entry: ConfigEntry) -> bool: if unload_ok: data.pop(config_entry.entry_id) + if not data: + hass.data.pop(DOMAIN) + return unload_ok diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 23d460e83c6120..8c12656c75c1e9 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -213,6 +213,7 @@ async def async_setup_entry( ): """Set up the AdaptiveLighting switch.""" data = hass.data[DOMAIN] + assert config_entry.entry_id in data if ATTR_TURN_ON_OFF_LISTENER not in data: data[ATTR_TURN_ON_OFF_LISTENER] = TurnOnOffListener(hass) diff --git a/tests/components/adaptive_lighting/test_init.py b/tests/components/adaptive_lighting/test_init.py new file mode 100644 index 00000000000000..53f05c612f4076 --- /dev/null +++ b/tests/components/adaptive_lighting/test_init.py @@ -0,0 +1,55 @@ +"""Tests for Adaptive Lighting integration.""" +from homeassistant import config_entries +from homeassistant.components import adaptive_lighting +from homeassistant.components.adaptive_lighting.const import ( + DEFAULT_NAME, + UNDO_UPDATE_LISTENER, +) +from homeassistant.const import CONF_NAME +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_setup_with_config(hass): + """Test that we import the config and setup the integration.""" + config = { + adaptive_lighting.DOMAIN: { + adaptive_lighting.CONF_NAME: DEFAULT_NAME, + } + } + assert await async_setup_component(hass, adaptive_lighting.DOMAIN, config) + assert adaptive_lighting.DOMAIN in hass.data + + +async def test_successful_config_entry(hass): + """Test that Adaptive Lighting is configured successfully.""" + + entry = MockConfigEntry( + domain=adaptive_lighting.DOMAIN, + data={CONF_NAME: DEFAULT_NAME}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + + assert entry.state == config_entries.ENTRY_STATE_LOADED + + assert UNDO_UPDATE_LISTENER in hass.data[adaptive_lighting.DOMAIN][entry.entry_id] + + +async def test_unload_entry(hass): + """Test removing Adaptive Lighting.""" + entry = MockConfigEntry( + domain=adaptive_lighting.DOMAIN, + data={CONF_NAME: DEFAULT_NAME}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED + assert adaptive_lighting.DOMAIN not in hass.data From e9b972c22ffd3a3266a28e899226e601697f6a53 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 17 Oct 2020 13:28:57 +0200 Subject: [PATCH 121/165] add tests/components/adaptive_lighting/test_switch.py --- .../adaptive_lighting/test_switch.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/components/adaptive_lighting/test_switch.py diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py new file mode 100644 index 00000000000000..b787b7d5ee6c85 --- /dev/null +++ b/tests/components/adaptive_lighting/test_switch.py @@ -0,0 +1,38 @@ +"""Tests for Adaptive Lighting switches.""" +from homeassistant.components import adaptive_lighting +from homeassistant.components.adaptive_lighting.const import ( + ATTR_TURN_ON_OFF_LISTENER, + DEFAULT_NAME, + DOMAIN, + UNDO_UPDATE_LISTENER, +) +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import CONF_NAME + +from tests.common import MockConfigEntry + + +async def test_adaptive_lighting_sensors(hass): + """Test sensors created for adaptive_lighting integration.""" + entry = MockConfigEntry( + domain=adaptive_lighting.DOMAIN, data={CONF_NAME: DEFAULT_NAME} + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 + assert hass.states.async_entity_ids(SWITCH_DOMAIN) == [ + f"{SWITCH_DOMAIN}.{DOMAIN}_{DEFAULT_NAME}", + f"{SWITCH_DOMAIN}.{DOMAIN}_sleep_mode_{DEFAULT_NAME}", + ] + assert ATTR_TURN_ON_OFF_LISTENER in hass.data[DOMAIN] + assert entry.entry_id in hass.data[DOMAIN] + assert len(hass.data[DOMAIN].keys()) == 2 + + data = hass.data[DOMAIN][entry.entry_id] + assert "sleep_mode_switch" in data + assert SWITCH_DOMAIN in data + assert UNDO_UPDATE_LISTENER in data + assert len(data.keys()) == 3 From c6b63933247b6b44f19f000cbc3caa0d4e4f06ab Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 17 Oct 2020 13:30:35 +0200 Subject: [PATCH 122/165] pop after unload --- homeassistant/components/adaptive_lighting/__init__.py | 9 +++++---- tests/components/adaptive_lighting/test_switch.py | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/__init__.py b/homeassistant/components/adaptive_lighting/__init__.py index 89f8173274bbcb..fb68b80fbf9e57 100644 --- a/homeassistant/components/adaptive_lighting/__init__.py +++ b/homeassistant/components/adaptive_lighting/__init__.py @@ -100,14 +100,15 @@ async def async_unload_entry(hass, config_entry: ConfigEntry) -> bool: ) data = hass.data[DOMAIN] data[config_entry.entry_id][UNDO_UPDATE_LISTENER]() - if len(data) == 1 and ATTR_TURN_ON_OFF_LISTENER in data: # no more config_entries + if unload_ok: + data.pop(config_entry.entry_id) + + if len(data) == 1 and ATTR_TURN_ON_OFF_LISTENER in data: + # no more config_entries turn_on_off_listener = data.pop(ATTR_TURN_ON_OFF_LISTENER) turn_on_off_listener.remove_listener() turn_on_off_listener.remove_listener2() - if unload_ok: - data.pop(config_entry.entry_id) - if not data: hass.data.pop(DOMAIN) diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index b787b7d5ee6c85..c17e1119052b7f 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -12,8 +12,8 @@ from tests.common import MockConfigEntry -async def test_adaptive_lighting_sensors(hass): - """Test sensors created for adaptive_lighting integration.""" +async def test_adaptive_lighting_switches(hass): + """Test switches created for adaptive_lighting integration.""" entry = MockConfigEntry( domain=adaptive_lighting.DOMAIN, data={CONF_NAME: DEFAULT_NAME} ) From d7b04c7c3282b1fe0fe86b1762710049903a29bd Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 17 Oct 2020 15:37:17 +0200 Subject: [PATCH 123/165] test for different timezones and fix the problem with sunevents order --- .../components/adaptive_lighting/switch.py | 10 +- .../adaptive_lighting/test_switch.py | 107 +++++++++++++++++- 2 files changed, 106 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 8c12656c75c1e9..5aa91f7d426fca 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -881,16 +881,12 @@ def get_sun_events(self, date: datetime.datetime) -> Dict[str, float]: def _replace_time(date: datetime.datetime, key: str) -> datetime.datetime: time = getattr(self, f"{key}_time") - date_time = datetime.datetime.combine(datetime.date.today(), time) + date_time = datetime.datetime.combine(date, time) utc_time = self.time_zone.localize(date_time).astimezone(dt_util.UTC) - return date.replace( - hour=utc_time.hour, - minute=utc_time.minute, - second=utc_time.second, - microsecond=utc_time.microsecond, - ) + return utc_time location = self.astral_location + sunrise = ( location.sunrise(date, local=False) if self.sunrise_time is None diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index c17e1119052b7f..d92fa917306d53 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -1,22 +1,53 @@ """Tests for Adaptive Lighting switches.""" -from homeassistant.components import adaptive_lighting +import datetime + +import pytest + from homeassistant.components.adaptive_lighting.const import ( ATTR_TURN_ON_OFF_LISTENER, + CONF_SUNRISE_TIME, + CONF_SUNSET_TIME, DEFAULT_NAME, + DEFAULT_SLEEP_BRIGHTNESS, + DEFAULT_SLEEP_COLOR_TEMP, DOMAIN, UNDO_UPDATE_LISTENER, ) +from homeassistant.components.light import ATTR_BRIGHTNESS_PCT from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +import homeassistant.config as config_util from homeassistant.const import CONF_NAME +from homeassistant.core import Context +import homeassistant.util.dt as dt_util +from tests.async_mock import patch from tests.common import MockConfigEntry +SUNRISE = datetime.datetime( + year=2020, + month=10, + day=17, + hour=6, +) +SUNSET = datetime.datetime( + year=2020, + month=10, + day=17, + hour=22, +) + +LAT_LONG_TZS = [ + (39, -1, "Europe/Madrid"), + (60, 50, "GMT"), + (55, 13, "Europe/Copenhagen"), + (52.379189, 4.899431, "Europe/Amsterdam"), + (32.87336, -117.22743, "US/Pacific"), +] + async def test_adaptive_lighting_switches(hass): """Test switches created for adaptive_lighting integration.""" - entry = MockConfigEntry( - domain=adaptive_lighting.DOMAIN, data={CONF_NAME: DEFAULT_NAME} - ) + entry = MockConfigEntry(domain=DOMAIN, data={CONF_NAME: DEFAULT_NAME}) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -36,3 +67,71 @@ async def test_adaptive_lighting_switches(hass): assert SWITCH_DOMAIN in data assert UNDO_UPDATE_LISTENER in data assert len(data.keys()) == 3 + + +@pytest.mark.parametrize("lat,long,tz", LAT_LONG_TZS) +async def test_adaptive_lighting_time_zones_and_sunsettings(hass, lat, long, tz): + """Test setting up the Adaptive Lighting switches with different timezones. + + Also test the (sleep) brightness and color temperature settings. + """ + await config_util.async_process_ha_core_config( + hass, + {"latitude": lat, "longitude": long, "time_zone": tz}, + ) + + sunset = hass.config.time_zone.localize(SUNSET).astimezone(dt_util.UTC) + before_sunset = sunset - datetime.timedelta(hours=1) + after_sunset = sunset + datetime.timedelta(hours=1) + + # Setup with time at sunset + with patch("homeassistant.util.dt.utcnow", return_value=sunset): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_NAME: DEFAULT_NAME, + CONF_SUNRISE_TIME: datetime.time(SUNRISE.hour), + CONF_SUNSET_TIME: datetime.time(SUNSET.hour), + }, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] + + # At sunset the brightness should be max and color_temp at the smallest value + assert switch._settings[ATTR_BRIGHTNESS_PCT] == 100 + assert ( + switch._settings["color_temp_kelvin"] + == switch._sun_light_settings.min_color_temp + ) + + # One hour before sunset the brightness should be max and color_temp + # not at the smallest value yet. + context = Context() + with patch("homeassistant.util.dt.utcnow", return_value=before_sunset): + await switch._update_attrs_and_maybe_adapt_lights(context=context) + assert switch._settings[ATTR_BRIGHTNESS_PCT] == 100 + assert ( + switch._settings["color_temp_kelvin"] + > switch._sun_light_settings.min_color_temp + ) + + # One hour after sunset the brightness should be down + with patch("homeassistant.util.dt.utcnow", return_value=after_sunset): + await switch._update_attrs_and_maybe_adapt_lights(context=context) + assert switch._settings[ATTR_BRIGHTNESS_PCT] < 100 + assert ( + switch._settings["color_temp_kelvin"] + == switch._sun_light_settings.min_color_temp + ) + + # Turn on sleep mode which make the brightness and color_temp + # deterministic regardless of the time + sleep_mode_switch = hass.data[DOMAIN][entry.entry_id]["sleep_mode_switch"] + await sleep_mode_switch.async_turn_on() + await switch._update_attrs_and_maybe_adapt_lights(context=context) + assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_SLEEP_BRIGHTNESS + assert switch._settings["color_temp_kelvin"] == DEFAULT_SLEEP_COLOR_TEMP From 287db89b2ab4ed97b508ab24194f00949a44633b Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 17 Oct 2020 15:41:45 +0200 Subject: [PATCH 124/165] remove adaptive_lighting from .coveragerc --- .coveragerc | 3 --- 1 file changed, 3 deletions(-) diff --git a/.coveragerc b/.coveragerc index b85e82c0d89bd1..a8459a2cd74762 100644 --- a/.coveragerc +++ b/.coveragerc @@ -18,9 +18,6 @@ omit = homeassistant/components/acmeda/helpers.py homeassistant/components/acmeda/hub.py homeassistant/components/acmeda/sensor.py - homeassistant/components/adaptive_lighting/__init__.py - homeassistant/components/adaptive_lighting/const.py - homeassistant/components/adaptive_lighting/switch.py homeassistant/components/adguard/__init__.py homeassistant/components/adguard/const.py homeassistant/components/adguard/sensor.py From f4948b11c6a9a6936734844f06dbc98c053fc000 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 17 Oct 2020 15:53:17 +0200 Subject: [PATCH 125/165] simplify tests --- .../adaptive_lighting/test_switch.py | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index d92fa917306d53..86809535177b46 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -7,6 +7,7 @@ ATTR_TURN_ON_OFF_LISTENER, CONF_SUNRISE_TIME, CONF_SUNSET_TIME, + DEFAULT_MAX_BRIGHTNESS, DEFAULT_NAME, DEFAULT_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_COLOR_TEMP, @@ -102,31 +103,23 @@ async def test_adaptive_lighting_time_zones_and_sunsettings(hass, lat, long, tz) switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] # At sunset the brightness should be max and color_temp at the smallest value - assert switch._settings[ATTR_BRIGHTNESS_PCT] == 100 - assert ( - switch._settings["color_temp_kelvin"] - == switch._sun_light_settings.min_color_temp - ) + min_color_temp = switch._sun_light_settings.min_color_temp + assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS + assert switch._settings["color_temp_kelvin"] == min_color_temp # One hour before sunset the brightness should be max and color_temp # not at the smallest value yet. context = Context() with patch("homeassistant.util.dt.utcnow", return_value=before_sunset): await switch._update_attrs_and_maybe_adapt_lights(context=context) - assert switch._settings[ATTR_BRIGHTNESS_PCT] == 100 - assert ( - switch._settings["color_temp_kelvin"] - > switch._sun_light_settings.min_color_temp - ) + assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS + assert switch._settings["color_temp_kelvin"] > min_color_temp # One hour after sunset the brightness should be down with patch("homeassistant.util.dt.utcnow", return_value=after_sunset): await switch._update_attrs_and_maybe_adapt_lights(context=context) - assert switch._settings[ATTR_BRIGHTNESS_PCT] < 100 - assert ( - switch._settings["color_temp_kelvin"] - == switch._sun_light_settings.min_color_temp - ) + assert switch._settings[ATTR_BRIGHTNESS_PCT] < DEFAULT_MAX_BRIGHTNESS + assert switch._settings["color_temp_kelvin"] == min_color_temp # Turn on sleep mode which make the brightness and color_temp # deterministic regardless of the time From 6d8347bf1d53fd7f2f269c00942f3c7f57cb3e7b Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 17 Oct 2020 15:58:39 +0200 Subject: [PATCH 126/165] test around sunrise too --- .../adaptive_lighting/test_switch.py | 64 +++++++++++++------ 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index 86809535177b46..593b7d3a455d5e 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -81,35 +81,38 @@ async def test_adaptive_lighting_time_zones_and_sunsettings(hass, lat, long, tz) {"latitude": lat, "longitude": long, "time_zone": tz}, ) - sunset = hass.config.time_zone.localize(SUNSET).astimezone(dt_util.UTC) - before_sunset = sunset - datetime.timedelta(hours=1) - after_sunset = sunset + datetime.timedelta(hours=1) + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_NAME: DEFAULT_NAME, + CONF_SUNRISE_TIME: datetime.time(SUNRISE.hour), + CONF_SUNSET_TIME: datetime.time(SUNSET.hour), + }, + ) + entry.add_to_hass(hass) - # Setup with time at sunset - with patch("homeassistant.util.dt.utcnow", return_value=sunset): - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_NAME: DEFAULT_NAME, - CONF_SUNRISE_TIME: datetime.time(SUNRISE.hour), - CONF_SUNSET_TIME: datetime.time(SUNSET.hour), - }, - ) - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + context = Context() # needs to be passed to update method switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] + min_color_temp = switch._sun_light_settings.min_color_temp + + sunset = hass.config.time_zone.localize(SUNSET).astimezone(dt_util.UTC) + before_sunset = sunset - datetime.timedelta(hours=1) + after_sunset = sunset + datetime.timedelta(hours=1) + sunrise = hass.config.time_zone.localize(SUNRISE).astimezone(dt_util.UTC) + before_sunrise = sunrise - datetime.timedelta(hours=1) + after_sunrise = sunrise + datetime.timedelta(hours=1) # At sunset the brightness should be max and color_temp at the smallest value - min_color_temp = switch._sun_light_settings.min_color_temp - assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS - assert switch._settings["color_temp_kelvin"] == min_color_temp + with patch("homeassistant.util.dt.utcnow", return_value=sunset): + await switch._update_attrs_and_maybe_adapt_lights(context=context) + assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS + assert switch._settings["color_temp_kelvin"] == min_color_temp # One hour before sunset the brightness should be max and color_temp # not at the smallest value yet. - context = Context() with patch("homeassistant.util.dt.utcnow", return_value=before_sunset): await switch._update_attrs_and_maybe_adapt_lights(context=context) assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS @@ -121,6 +124,25 @@ async def test_adaptive_lighting_time_zones_and_sunsettings(hass, lat, long, tz) assert switch._settings[ATTR_BRIGHTNESS_PCT] < DEFAULT_MAX_BRIGHTNESS assert switch._settings["color_temp_kelvin"] == min_color_temp + # At sunrise the brightness should be max and color_temp at the smallest value + with patch("homeassistant.util.dt.utcnow", return_value=sunrise): + await switch._update_attrs_and_maybe_adapt_lights(context=context) + assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS + assert switch._settings["color_temp_kelvin"] == min_color_temp + + # One hour before sunrise the brightness should smaller than max + # and color_temp at the min value. + with patch("homeassistant.util.dt.utcnow", return_value=before_sunrise): + await switch._update_attrs_and_maybe_adapt_lights(context=context) + assert switch._settings[ATTR_BRIGHTNESS_PCT] < DEFAULT_MAX_BRIGHTNESS + assert switch._settings["color_temp_kelvin"] == min_color_temp + + # One hour after sunrise the brightness should be up + with patch("homeassistant.util.dt.utcnow", return_value=after_sunrise): + await switch._update_attrs_and_maybe_adapt_lights(context=context) + assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS + assert switch._settings["color_temp_kelvin"] > min_color_temp + # Turn on sleep mode which make the brightness and color_temp # deterministic regardless of the time sleep_mode_switch = hass.data[DOMAIN][entry.entry_id]["sleep_mode_switch"] From 039b020cc9d70d7a9893f3a38183c3a157bd04c6 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 17 Oct 2020 16:15:37 +0200 Subject: [PATCH 127/165] use helper function setup_switch --- .../adaptive_lighting/test_switch.py | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index 593b7d3a455d5e..fa02d6caf587c5 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -46,13 +46,18 @@ ] -async def test_adaptive_lighting_switches(hass): - """Test switches created for adaptive_lighting integration.""" - entry = MockConfigEntry(domain=DOMAIN, data={CONF_NAME: DEFAULT_NAME}) +async def setup_switch(hass, extra_data): + """Create the switch entry.""" + entry = MockConfigEntry(domain=DOMAIN, data={CONF_NAME: DEFAULT_NAME, **extra_data}) entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + return entry + + +async def test_adaptive_lighting_switches(hass): + """Test switches created for adaptive_lighting integration.""" + entry = await setup_switch(hass, {}) assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 assert hass.states.async_entity_ids(SWITCH_DOMAIN) == [ @@ -70,6 +75,19 @@ async def test_adaptive_lighting_switches(hass): assert len(data.keys()) == 3 +@pytest.mark.parametrize("lat,long,tz", LAT_LONG_TZS) +async def test_adaptive_lighting_time_zones_with_default_settings(hass, lat, long, tz): + """Test setting up the Adaptive Lighting switches with different timezones.""" + await config_util.async_process_ha_core_config( + hass, + {"latitude": lat, "longitude": long, "time_zone": tz}, + ) + entry = await setup_switch(hass, {}) + switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] + # Shouldn't raise an exception ever + await switch._update_attrs_and_maybe_adapt_lights(context=Context()) + + @pytest.mark.parametrize("lat,long,tz", LAT_LONG_TZS) async def test_adaptive_lighting_time_zones_and_sunsettings(hass, lat, long, tz): """Test setting up the Adaptive Lighting switches with different timezones. @@ -80,19 +98,13 @@ async def test_adaptive_lighting_time_zones_and_sunsettings(hass, lat, long, tz) hass, {"latitude": lat, "longitude": long, "time_zone": tz}, ) - - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_NAME: DEFAULT_NAME, + entry = await setup_switch( + hass, + { CONF_SUNRISE_TIME: datetime.time(SUNRISE.hour), CONF_SUNSET_TIME: datetime.time(SUNSET.hour), }, ) - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() context = Context() # needs to be passed to update method switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] From 872bd1cb955ffac2ac020a404af3de7d64437fb4 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 17 Oct 2020 16:23:37 +0200 Subject: [PATCH 128/165] add tests/components/adaptive_lighting/__init__.py --- tests/components/adaptive_lighting/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/components/adaptive_lighting/__init__.py diff --git a/tests/components/adaptive_lighting/__init__.py b/tests/components/adaptive_lighting/__init__.py new file mode 100644 index 00000000000000..5ae9fe68e6c424 --- /dev/null +++ b/tests/components/adaptive_lighting/__init__.py @@ -0,0 +1 @@ +"""Tests for the Adaptive Lighting integration.""" From a35dce95042603825b65a7ee61a8b658f24850db Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 17 Oct 2020 16:25:33 +0200 Subject: [PATCH 129/165] Remove initial doc-string --- .../components/adaptive_lighting/__init__.py | 27 +------------------ 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/__init__.py b/homeassistant/components/adaptive_lighting/__init__.py index fb68b80fbf9e57..786daae245c633 100644 --- a/homeassistant/components/adaptive_lighting/__init__.py +++ b/homeassistant/components/adaptive_lighting/__init__.py @@ -1,29 +1,4 @@ -"""Adaptive Lighting integration in Home-Assistant. - -This integration calculates color temperature and brightness to synchronize -your color-changing lights with the perceived color temperature of the sky -throughout the day. This gives your environment a more natural feel, with -cooler whites during the midday and warmer tints near twilight and dawn. - -Additionally, the integration sets your lights to a nice warm white at 1% in -"Sleep mode", which is far brighter than starlight but won't reset your -circadian rhythm or break down too much rhodopsin in your eyes. - -Human circadian rhythms are heavily influenced by ambient light levels and -hues. Hormone production, brainwave activity, mood, and wakefulness are -just some of the cognitive functions tied to cyclical natural light. - -Resources: -- http://en.wikipedia.org/wiki/Zeitgeber -- http://www.cambridgeincolour.com/tutorials/sunrise-sunset-calculator.htm -- http://en.wikipedia.org/wiki/Color_temperature - -## Notes -* Only your location is taken into account to calculate the the sun's position. -* Weather is not considered. -* The integration does not calculate a true "Blue Hour" -- it just sets the - lights to 2700K (warm white) until your hub goes into "Sleep mode". -""" +"""Adaptive Lighting integration in Home-Assistant.""" import logging from typing import Any, Dict From 045ef33f7ff57019214090c6f75baae30920970e Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 18 Oct 2020 17:30:25 +0200 Subject: [PATCH 130/165] test light settings and manual control --- .../adaptive_lighting/test_switch.py | 260 ++++++++++++++++-- 1 file changed, 230 insertions(+), 30 deletions(-) diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index fa02d6caf587c5..1432c57696f9e6 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -5,24 +5,35 @@ from homeassistant.components.adaptive_lighting.const import ( ATTR_TURN_ON_OFF_LISTENER, + CONF_INITIAL_TRANSITION, + CONF_MANUAL_CONTROL, CONF_SUNRISE_TIME, CONF_SUNSET_TIME, + CONF_TRANSITION, DEFAULT_MAX_BRIGHTNESS, DEFAULT_NAME, DEFAULT_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_COLOR_TEMP, DOMAIN, + SERVICE_SET_MANUAL_CONTROL, UNDO_UPDATE_LISTENER, ) -from homeassistant.components.light import ATTR_BRIGHTNESS_PCT +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT, + ATTR_COLOR_TEMP, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, +) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN import homeassistant.config as config_util -from homeassistant.const import CONF_NAME -from homeassistant.core import Context +from homeassistant.const import ATTR_ENTITY_ID, CONF_LIGHTS, CONF_NAME, SERVICE_TURN_ON +from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.async_mock import patch from tests.common import MockConfigEntry +from tests.components.demo.test_light import ENTITY_LIGHT SUNRISE = datetime.datetime( year=2020, @@ -45,6 +56,8 @@ (32.87336, -117.22743, "US/Pacific"), ] +ENTITY_SLEEP_MODE_SWITCH = f"{SWITCH_DOMAIN}.{DOMAIN}_sleep_mode_{DEFAULT_NAME}" + async def setup_switch(hass, extra_data): """Create the switch entry.""" @@ -55,6 +68,42 @@ async def setup_switch(hass, extra_data): return entry +async def setup_switch_and_lights(hass): + """Create switch and demo lights.""" + # Setup demo lights and turn on + await async_setup_component( + hass, LIGHT_DOMAIN, {LIGHT_DOMAIN: [{"platform": "demo"}]} + ) + await hass.async_block_till_done() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT}, + blocking=True, + ) + + # Setup switch + lights = [ + "light.bed_light", + "light.ceiling_lights", + "light.kitchen_lights", + ] + assert all(hass.states.get(light) is not None for light in lights) + entry = await setup_switch( + hass, + { + CONF_LIGHTS: lights, + CONF_SUNRISE_TIME: datetime.time(SUNRISE.hour), + CONF_SUNSET_TIME: datetime.time(SUNSET.hour), + CONF_INITIAL_TRANSITION: 0, + CONF_TRANSITION: 0, + }, + ) + switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] + await hass.async_block_till_done() + return switch + + async def test_adaptive_lighting_switches(hass): """Test switches created for adaptive_lighting integration.""" entry = await setup_switch(hass, {}) @@ -85,11 +134,13 @@ async def test_adaptive_lighting_time_zones_with_default_settings(hass, lat, lon entry = await setup_switch(hass, {}) switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] # Shouldn't raise an exception ever - await switch._update_attrs_and_maybe_adapt_lights(context=Context()) + await switch._update_attrs_and_maybe_adapt_lights( + context=switch.create_context("test") + ) @pytest.mark.parametrize("lat,long,tz", LAT_LONG_TZS) -async def test_adaptive_lighting_time_zones_and_sunsettings(hass, lat, long, tz): +async def test_adaptive_lighting_time_zones_and_sun_settings(hass, lat, long, tz): """Test setting up the Adaptive Lighting switches with different timezones. Also test the (sleep) brightness and color temperature settings. @@ -106,8 +157,8 @@ async def test_adaptive_lighting_time_zones_and_sunsettings(hass, lat, long, tz) }, ) - context = Context() # needs to be passed to update method switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] + context = switch.create_context("test") # needs to be passed to update method min_color_temp = switch._sun_light_settings.min_color_temp sunset = hass.config.time_zone.localize(SUNSET).astimezone(dt_util.UTC) @@ -117,43 +168,42 @@ async def test_adaptive_lighting_time_zones_and_sunsettings(hass, lat, long, tz) before_sunrise = sunrise - datetime.timedelta(hours=1) after_sunrise = sunrise + datetime.timedelta(hours=1) + async def patch_time_and_update(time): + with patch("homeassistant.util.dt.utcnow", return_value=time): + await switch._update_attrs_and_maybe_adapt_lights(context=context) + await hass.async_block_till_done() + # At sunset the brightness should be max and color_temp at the smallest value - with patch("homeassistant.util.dt.utcnow", return_value=sunset): - await switch._update_attrs_and_maybe_adapt_lights(context=context) - assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS - assert switch._settings["color_temp_kelvin"] == min_color_temp + await patch_time_and_update(sunset) + assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS + assert switch._settings["color_temp_kelvin"] == min_color_temp # One hour before sunset the brightness should be max and color_temp # not at the smallest value yet. - with patch("homeassistant.util.dt.utcnow", return_value=before_sunset): - await switch._update_attrs_and_maybe_adapt_lights(context=context) - assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS - assert switch._settings["color_temp_kelvin"] > min_color_temp + await patch_time_and_update(before_sunset) + assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS + assert switch._settings["color_temp_kelvin"] > min_color_temp # One hour after sunset the brightness should be down - with patch("homeassistant.util.dt.utcnow", return_value=after_sunset): - await switch._update_attrs_and_maybe_adapt_lights(context=context) - assert switch._settings[ATTR_BRIGHTNESS_PCT] < DEFAULT_MAX_BRIGHTNESS - assert switch._settings["color_temp_kelvin"] == min_color_temp + await patch_time_and_update(after_sunset) + assert switch._settings[ATTR_BRIGHTNESS_PCT] < DEFAULT_MAX_BRIGHTNESS + assert switch._settings["color_temp_kelvin"] == min_color_temp # At sunrise the brightness should be max and color_temp at the smallest value - with patch("homeassistant.util.dt.utcnow", return_value=sunrise): - await switch._update_attrs_and_maybe_adapt_lights(context=context) - assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS - assert switch._settings["color_temp_kelvin"] == min_color_temp + await patch_time_and_update(sunrise) + assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS + assert switch._settings["color_temp_kelvin"] == min_color_temp # One hour before sunrise the brightness should smaller than max # and color_temp at the min value. - with patch("homeassistant.util.dt.utcnow", return_value=before_sunrise): - await switch._update_attrs_and_maybe_adapt_lights(context=context) - assert switch._settings[ATTR_BRIGHTNESS_PCT] < DEFAULT_MAX_BRIGHTNESS - assert switch._settings["color_temp_kelvin"] == min_color_temp + await patch_time_and_update(before_sunrise) + assert switch._settings[ATTR_BRIGHTNESS_PCT] < DEFAULT_MAX_BRIGHTNESS + assert switch._settings["color_temp_kelvin"] == min_color_temp # One hour after sunrise the brightness should be up - with patch("homeassistant.util.dt.utcnow", return_value=after_sunrise): - await switch._update_attrs_and_maybe_adapt_lights(context=context) - assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS - assert switch._settings["color_temp_kelvin"] > min_color_temp + await patch_time_and_update(after_sunrise) + assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS + assert switch._settings["color_temp_kelvin"] > min_color_temp # Turn on sleep mode which make the brightness and color_temp # deterministic regardless of the time @@ -162,3 +212,153 @@ async def test_adaptive_lighting_time_zones_and_sunsettings(hass, lat, long, tz) await switch._update_attrs_and_maybe_adapt_lights(context=context) assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_SLEEP_BRIGHTNESS assert switch._settings["color_temp_kelvin"] == DEFAULT_SLEEP_COLOR_TEMP + + +async def test_light_settings(hass): + """Test that light settings are correctly applied.""" + switch = await setup_switch_and_lights(hass) + 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] == last_service_data[ATTR_COLOR_TEMP] + + # 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 + sunset = hass.config.time_zone.localize(SUNSET).astimezone(dt_util.UTC) + before_sunset = sunset - datetime.timedelta(hours=1) + after_sunset = sunset + datetime.timedelta(hours=1) + sunrise = hass.config.time_zone.localize(SUNRISE).astimezone(dt_util.UTC) + before_sunrise = sunrise - datetime.timedelta(hours=1) + after_sunrise = sunrise + datetime.timedelta(hours=1) + + context = switch.create_context("test") # needs to be passed to update method + + 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] == last_service_data[ATTR_COLOR_TEMP] + + # At sunset the brightness should be max and color_temp at the smallest value + light_states = await patch_time_and_get_updated_states(sunset) + 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) + 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) + + +async def test_manual_control(hass): + """Test the 'manual control' tracking.""" + switch = await setup_switch_and_lights(hass) + context = switch.create_context("test") # needs to be passed to update method + + async def update(): + await switch._update_attrs_and_maybe_adapt_lights(transition=0, context=context) + await hass.async_block_till_done() + + async def turn_light(state): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON if state else SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT}, + blocking=True, + ) + await hass.async_block_till_done() + await update() + + async def change_manual_control(set_to): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MANUAL_CONTROL, + { + ATTR_ENTITY_ID: switch.entity_id, + CONF_MANUAL_CONTROL: set_to, + CONF_LIGHTS: [ENTITY_LIGHT], + }, + blocking=True, + ) + await hass.async_block_till_done() + await update() + + # Nothing is manually controlled + await update() + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + # Call light.turn_on for ENTITY_LIGHT + await turn_light(True) + # Check that ENTITY_LIGHT is manually controlled + assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + # Test adaptive_lighting.set_manual_control + await change_manual_control(False) + # Check that ENTITY_LIGHT is not manually controlled + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + + # Check that changing light on and off removes manual control + await change_manual_control(True) + await turn_light(True) # is already on + assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + await turn_light(False) + await turn_light(True) + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] From 31122a775f1be649ed09925af04e68fa81ae604f Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 18 Oct 2020 17:36:37 +0200 Subject: [PATCH 131/165] test that toggling switches resets manual control --- .../adaptive_lighting/test_switch.py | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index 1432c57696f9e6..65bfcce04c0240 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -56,6 +56,7 @@ (32.87336, -117.22743, "US/Pacific"), ] +ENTITY_SWITCH = f"{SWITCH_DOMAIN}.{DOMAIN}_{DEFAULT_NAME}" ENTITY_SLEEP_MODE_SWITCH = f"{SWITCH_DOMAIN}.{DOMAIN}_sleep_mode_{DEFAULT_NAME}" @@ -329,6 +330,15 @@ async def turn_light(state): await hass.async_block_till_done() await update() + async def turn_switch(state, entity_id): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON if state else SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + async def change_manual_control(set_to): await hass.services.async_call( DOMAIN, @@ -355,10 +365,17 @@ async def change_manual_control(set_to): # Check that ENTITY_LIGHT is not manually controlled assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] - # Check that changing light on and off removes manual control + # Check that toggling light off to on resets manual control await change_manual_control(True) - await turn_light(True) # is already on assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] await turn_light(False) await turn_light(True) assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + + # Check that toggling (sleep mode) switch resets manual control + for entity_id in [ENTITY_SWITCH, ENTITY_SLEEP_MODE_SWITCH]: + await change_manual_control(True) + assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + await turn_switch(False, entity_id) + await turn_switch(True, entity_id) + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] From 0c0eff38ffad5718d83b4fdbc58e8c309c32841b Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 18 Oct 2020 17:48:52 +0200 Subject: [PATCH 132/165] use legacy_patchable_time to fix failing tests --- tests/components/adaptive_lighting/test_switch.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index 65bfcce04c0240..33a320a9b6e5e7 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -141,7 +141,9 @@ async def test_adaptive_lighting_time_zones_with_default_settings(hass, lat, lon @pytest.mark.parametrize("lat,long,tz", LAT_LONG_TZS) -async def test_adaptive_lighting_time_zones_and_sun_settings(hass, lat, long, tz): +async def test_adaptive_lighting_time_zones_and_sun_settings( + hass, lat, long, tz, legacy_patchable_time +): """Test setting up the Adaptive Lighting switches with different timezones. Also test the (sleep) brightness and color temperature settings. @@ -215,7 +217,7 @@ async def patch_time_and_update(time): assert switch._settings["color_temp_kelvin"] == DEFAULT_SLEEP_COLOR_TEMP -async def test_light_settings(hass): +async def test_light_settings(hass, legacy_patchable_time): """Test that light settings are correctly applied.""" switch = await setup_switch_and_lights(hass) lights = switch._lights From 33524be3dc8f2ac1b4f01571cb793efadab0fa08 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 18 Oct 2020 18:00:35 +0200 Subject: [PATCH 133/165] fix pylint issues --- tests/components/adaptive_lighting/test_switch.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index 33a320a9b6e5e7..eb5ba61a832da9 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -1,4 +1,5 @@ """Tests for Adaptive Lighting switches.""" +# pylint: disable=protected-access import datetime import pytest @@ -125,12 +126,14 @@ async def test_adaptive_lighting_switches(hass): assert len(data.keys()) == 3 -@pytest.mark.parametrize("lat,long,tz", LAT_LONG_TZS) -async def test_adaptive_lighting_time_zones_with_default_settings(hass, lat, long, tz): +@pytest.mark.parametrize("lat,long,timezone", LAT_LONG_TZS) +async def test_adaptive_lighting_time_zones_with_default_settings( + hass, lat, long, timezone +): """Test setting up the Adaptive Lighting switches with different timezones.""" await config_util.async_process_ha_core_config( hass, - {"latitude": lat, "longitude": long, "time_zone": tz}, + {"latitude": lat, "longitude": long, "time_zone": timezone}, ) entry = await setup_switch(hass, {}) switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] @@ -140,9 +143,9 @@ async def test_adaptive_lighting_time_zones_with_default_settings(hass, lat, lon ) -@pytest.mark.parametrize("lat,long,tz", LAT_LONG_TZS) +@pytest.mark.parametrize("lat,long,timezone", LAT_LONG_TZS) async def test_adaptive_lighting_time_zones_and_sun_settings( - hass, lat, long, tz, legacy_patchable_time + hass, lat, long, timezone, legacy_patchable_time ): """Test setting up the Adaptive Lighting switches with different timezones. @@ -150,7 +153,7 @@ async def test_adaptive_lighting_time_zones_and_sun_settings( """ await config_util.async_process_ha_core_config( hass, - {"latitude": lat, "longitude": long, "time_zone": tz}, + {"latitude": lat, "longitude": long, "time_zone": timezone}, ) entry = await setup_switch( hass, From e35e0b600dcdd9e516d466f7274ae7367a4cff76 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 18 Oct 2020 19:00:18 +0200 Subject: [PATCH 134/165] restore dt_util.DEFAULT_TIME_ZONE --- tests/components/adaptive_lighting/test_switch.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index eb5ba61a832da9..63137bdb4a65f2 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -60,6 +60,8 @@ ENTITY_SWITCH = f"{SWITCH_DOMAIN}.{DOMAIN}_{DEFAULT_NAME}" ENTITY_SLEEP_MODE_SWITCH = f"{SWITCH_DOMAIN}.{DOMAIN}_sleep_mode_{DEFAULT_NAME}" +ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE + async def setup_switch(hass, extra_data): """Create the switch entry.""" @@ -141,6 +143,7 @@ async def test_adaptive_lighting_time_zones_with_default_settings( await switch._update_attrs_and_maybe_adapt_lights( context=switch.create_context("test") ) + dt_util.DEFAULT_TIME_ZONE = ORIG_TIMEZONE # Restore TZ @pytest.mark.parametrize("lat,long,timezone", LAT_LONG_TZS) @@ -219,6 +222,8 @@ async def patch_time_and_update(time): assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_SLEEP_BRIGHTNESS assert switch._settings["color_temp_kelvin"] == DEFAULT_SLEEP_COLOR_TEMP + dt_util.DEFAULT_TIME_ZONE = ORIG_TIMEZONE # Restore TZ + async def test_light_settings(hass, legacy_patchable_time): """Test that light settings are correctly applied.""" From 9fe4d2621e455b5826a823192a2d21cb4852830c Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 18 Oct 2020 19:08:56 +0200 Subject: [PATCH 135/165] use a fixture to restore the timezone --- .../components/adaptive_lighting/test_switch.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index 63137bdb4a65f2..09d790d9441576 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -63,6 +63,13 @@ ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE +@pytest.fixture +def reset_time_zone(): + """Reset time zone.""" + yield + dt_util.DEFAULT_TIME_ZONE = ORIG_TIMEZONE + + async def setup_switch(hass, extra_data): """Create the switch entry.""" entry = MockConfigEntry(domain=DOMAIN, data={CONF_NAME: DEFAULT_NAME, **extra_data}) @@ -130,7 +137,7 @@ async def test_adaptive_lighting_switches(hass): @pytest.mark.parametrize("lat,long,timezone", LAT_LONG_TZS) async def test_adaptive_lighting_time_zones_with_default_settings( - hass, lat, long, timezone + hass, lat, long, timezone, reset_time_zone ): """Test setting up the Adaptive Lighting switches with different timezones.""" await config_util.async_process_ha_core_config( @@ -143,12 +150,11 @@ async def test_adaptive_lighting_time_zones_with_default_settings( await switch._update_attrs_and_maybe_adapt_lights( context=switch.create_context("test") ) - dt_util.DEFAULT_TIME_ZONE = ORIG_TIMEZONE # Restore TZ @pytest.mark.parametrize("lat,long,timezone", LAT_LONG_TZS) async def test_adaptive_lighting_time_zones_and_sun_settings( - hass, lat, long, timezone, legacy_patchable_time + hass, lat, long, timezone, reset_time_zone ): """Test setting up the Adaptive Lighting switches with different timezones. @@ -222,10 +228,8 @@ async def patch_time_and_update(time): assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_SLEEP_BRIGHTNESS assert switch._settings["color_temp_kelvin"] == DEFAULT_SLEEP_COLOR_TEMP - dt_util.DEFAULT_TIME_ZONE = ORIG_TIMEZONE # Restore TZ - -async def test_light_settings(hass, legacy_patchable_time): +async def test_light_settings(hass): """Test that light settings are correctly applied.""" switch = await setup_switch_and_lights(hass) lights = switch._lights From 778d24f1398b6c51e04691d78f788c3473e512f6 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 18 Oct 2020 20:35:56 +0200 Subject: [PATCH 136/165] increase coverage to >90% --- .../components/adaptive_lighting/switch.py | 12 +-- .../adaptive_lighting/test_config_flow.py | 38 +++++++ .../adaptive_lighting/test_switch.py | 102 +++++++++++++++++- 3 files changed, 138 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 5aa91f7d426fca..7fb14182257c28 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -17,10 +17,6 @@ import astral import voluptuous as vol -from homeassistant.components.homeassistant import ( - DOMAIN as HA_DOMAIN, - SERVICE_UPDATE_ENTITY, -) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, @@ -1173,13 +1169,7 @@ async def significant_change( if light not in self.last_state_change: return False old_states: List[State] = self.last_state_change[light] - await self.hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: light}, - blocking=True, - context=context, - ) + await self.hass.helpers.entity_component.async_update_entity(light) new_state = self.hass.states.get(light) compare_to = functools.partial( _attributes_have_changed, diff --git a/tests/components/adaptive_lighting/test_config_flow.py b/tests/components/adaptive_lighting/test_config_flow.py index ac989f4b151d3a..e48a84f5809f94 100644 --- a/tests/components/adaptive_lighting/test_config_flow.py +++ b/tests/components/adaptive_lighting/test_config_flow.py @@ -1,4 +1,7 @@ """Test Adaptive Lighting config flow.""" +import pytest +from voluptuous.error import MultipleInvalid + from homeassistant import data_entry_flow from homeassistant.components.adaptive_lighting.const import ( CONF_SUNRISE_TIME, @@ -74,3 +77,38 @@ async def test_options(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY for key, value in data.items(): assert result["data"][key] == value + + +async def test_incorrect_options(hass): + """Test updating incorrect options.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_NAME, + data={CONF_NAME: DEFAULT_NAME}, + options={}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + + result = await hass.config_entries.options.async_init(entry.entry_id) + data = DEFAULT_DATA.copy() + data[CONF_SUNRISE_TIME] = None + data[CONF_SUNSET_TIME] = None + with pytest.raises(MultipleInvalid): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=data, + ) + + +async def test_import_twice(hass): + """Test importing twice.""" + data = DEFAULT_DATA.copy() + data[CONF_NAME] = DEFAULT_NAME + for _ in range(2): + _ = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "import"}, + data=data, + ) diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index 09d790d9441576..945ce74dac9c99 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -1,24 +1,35 @@ """Tests for Adaptive Lighting switches.""" # pylint: disable=protected-access +import asyncio import datetime +from random import randint import pytest from homeassistant.components.adaptive_lighting.const import ( ATTR_TURN_ON_OFF_LISTENER, + CONF_DETECT_NON_HA_CHANGES, CONF_INITIAL_TRANSITION, CONF_MANUAL_CONTROL, + CONF_PREFER_RGB_COLOR, CONF_SUNRISE_TIME, CONF_SUNSET_TIME, CONF_TRANSITION, + CONF_TURN_ON_LIGHTS, DEFAULT_MAX_BRIGHTNESS, DEFAULT_NAME, DEFAULT_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_COLOR_TEMP, DOMAIN, + SERVICE_APPLY, SERVICE_SET_MANUAL_CONTROL, UNDO_UPDATE_LISTENER, ) +from homeassistant.components.adaptive_lighting.switch import ( + color_difference_redmean, + create_context, + is_our_context, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, @@ -28,7 +39,15 @@ ) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN import homeassistant.config as config_util -from homeassistant.const import ATTR_ENTITY_ID, CONF_LIGHTS, CONF_NAME, SERVICE_TURN_ON +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_LIGHTS, + CONF_NAME, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import Context from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -108,6 +127,8 @@ async def setup_switch_and_lights(hass): CONF_SUNSET_TIME: datetime.time(SUNSET.hour), CONF_INITIAL_TRANSITION: 0, CONF_TRANSITION: 0, + CONF_DETECT_NON_HA_CHANGES: True, + CONF_PREFER_RGB_COLOR: False, }, ) switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] @@ -137,7 +158,7 @@ async def test_adaptive_lighting_switches(hass): @pytest.mark.parametrize("lat,long,timezone", LAT_LONG_TZS) async def test_adaptive_lighting_time_zones_with_default_settings( - hass, lat, long, timezone, reset_time_zone + hass, lat, long, timezone, reset_time_zone # pylint: disable=redefined-outer-name ): """Test setting up the Adaptive Lighting switches with different timezones.""" await config_util.async_process_ha_core_config( @@ -154,7 +175,7 @@ async def test_adaptive_lighting_time_zones_with_default_settings( @pytest.mark.parametrize("lat,long,timezone", LAT_LONG_TZS) async def test_adaptive_lighting_time_zones_and_sun_settings( - hass, lat, long, timezone, reset_time_zone + hass, lat, long, timezone, reset_time_zone # pylint: disable=redefined-outer-name ): """Test setting up the Adaptive Lighting switches with different timezones. @@ -393,3 +414,78 @@ async def change_manual_control(set_to): await turn_switch(False, entity_id) await turn_switch(True, entity_id) assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + + +async def test_apply_service(hass): + """Test adaptive_lighting.apply service.""" + await setup_switch_and_lights(hass) + await hass.services.async_call( + DOMAIN, + SERVICE_APPLY, + { + ATTR_ENTITY_ID: ENTITY_SWITCH, + CONF_LIGHTS: [ENTITY_LIGHT], + CONF_TURN_ON_LIGHTS: True, + }, + blocking=True, + ) + + +async def test_switch_off_on_off(hass): + """Test switch rapid off_on_off.""" + + async def turn_light(state, **kwargs): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON if state else SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT, **kwargs}, + blocking=True, + ) + await hass.async_block_till_done() + + async def update(): + await switch._update_attrs_and_maybe_adapt_lights( + transition=0, context=switch.create_context("test") + ) + await hass.async_block_till_done() + + switch = await setup_switch_and_lights(hass) + # Turn light on + await turn_light(True) + # Turn light off with transition + await turn_light(False, transition=30) + + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + # Set state to on after a second (like happens IRL) + await asyncio.sleep(1) + hass.states.async_set(ENTITY_LIGHT, STATE_ON) + # Set state to off after a second (like happens IRL) + await asyncio.sleep(1) + hass.states.async_set(ENTITY_LIGHT, STATE_OFF) + + # Now we test whether the sleep task is there + assert ENTITY_LIGHT in switch.turn_on_off_listener.sleep_tasks + sleep_task = switch.turn_on_off_listener.sleep_tasks[ENTITY_LIGHT] + assert not sleep_task.cancelled() + + # A 'light.turn_on' event should cancel that task + await turn_light(True) + await update() + assert sleep_task.cancelled() + + +def test_color_difference_redmean(): + """Test color_difference_redmean function.""" + for _ in range(10): + rgb_1 = (randint(0, 255), randint(0, 255), randint(0, 255)) + rgb_2 = (randint(0, 255), randint(0, 255), randint(0, 255)) + color_difference_redmean(rgb_1, rgb_2) + color_difference_redmean((0, 0, 0), (255, 255, 255)) + + +def test_is_our_context(): + """Test is_our_context function.""" + context = create_context(DOMAIN, "test", 0) + assert is_our_context(context) + assert not is_our_context(None) + assert not is_our_context(Context()) From da30a8b4a29184280a11f60d7d75239099c9c977 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 19 Oct 2020 10:28:02 +0200 Subject: [PATCH 137/165] add more tests --- .../adaptive_lighting/test_switch.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index 945ce74dac9c99..10e4c1e8da61c9 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -26,14 +26,18 @@ UNDO_UPDATE_LISTENER, ) from homeassistant.components.adaptive_lighting.switch import ( + _attributes_have_changed, + _expand_light_groups, color_difference_redmean, create_context, is_our_context, ) +from homeassistant.components.group import DOMAIN as GROUP_DOMAIN from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP, + ATTR_RGB_COLOR, DOMAIN as LIGHT_DOMAIN, SERVICE_TURN_OFF, ) @@ -450,6 +454,8 @@ async def update(): await hass.async_block_till_done() switch = await setup_switch_and_lights(hass) + + # - SCENARIO 1 # Turn light on await turn_light(True) # Turn light off with transition @@ -489,3 +495,60 @@ def test_is_our_context(): assert is_our_context(context) assert not is_our_context(None) assert not is_our_context(Context()) + + +def test_attributes_have_changed(): + """Test _attributes_have_changed function.""" + attributes_1 = {ATTR_BRIGHTNESS: 1, ATTR_RGB_COLOR: (0, 0, 0), ATTR_COLOR_TEMP: 100} + attributes_2 = { + ATTR_BRIGHTNESS: 100, + ATTR_RGB_COLOR: (255, 0, 0), + ATTR_COLOR_TEMP: 300, + } + kwargs = dict( + light="light.test", + adapt_brightness=True, + adapt_color_temp=True, + adapt_rgb_color=True, + context=Context(), + ) + assert not _attributes_have_changed( + old_attributes=attributes_1, new_attributes=attributes_1, **kwargs + ) + for key, value in attributes_2.items(): + attrs = dict(attributes_1) + attrs[key] = value + assert _attributes_have_changed( + old_attributes=attributes_1, new_attributes=attrs, **kwargs + ) + + +@pytest.mark.parametrize("wait", [True, False]) +async def test_expand_light_groups(hass, wait): + """Test expanding light groups.""" + await setup_switch(hass, {}) + lights = ["light.ceiling_lights", "light.kitchen_lights"] + await async_setup_component( + hass, + LIGHT_DOMAIN, + { + LIGHT_DOMAIN: [ + {"platform": "demo"}, + { + "platform": GROUP_DOMAIN, + "entities": lights, + }, + ] + }, + ) + if wait: + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + expanded = set(_expand_light_groups(hass, ["light.light_group"])) + if wait: + assert expanded == set(lights) + else: + # Cannot expand yet because state is None + assert expanded == {"light.light_group"} From f053860a091d6566daa1886a3ac9949614c57833 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 19 Oct 2020 11:32:18 +0200 Subject: [PATCH 138/165] increase config_flow.py test coverage to 100% --- .../adaptive_lighting/config_flow.py | 2 +- .../adaptive_lighting/test_config_flow.py | 37 +++++++++++---- .../adaptive_lighting/test_switch.py | 47 ++++++++++--------- 3 files changed, 53 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/config_flow.py b/homeassistant/components/adaptive_lighting/config_flow.py index 6bf5e4302ea52b..8fa74f5c69cc7e 100644 --- a/homeassistant/components/adaptive_lighting/config_flow.py +++ b/homeassistant/components/adaptive_lighting/config_flow.py @@ -84,7 +84,7 @@ async def async_step_init(self, user_input=None): """Handle options flow.""" conf = self.config_entry if conf.source == config_entries.SOURCE_IMPORT: - return self.async_show_form(step_id="init", data_schema={}) + return self.async_show_form(step_id="init", data_schema=None) errors = {} if user_input is not None: validate_options(user_input, errors) diff --git a/tests/components/adaptive_lighting/test_config_flow.py b/tests/components/adaptive_lighting/test_config_flow.py index e48a84f5809f94..53901cf98aee1f 100644 --- a/tests/components/adaptive_lighting/test_config_flow.py +++ b/tests/components/adaptive_lighting/test_config_flow.py @@ -1,7 +1,4 @@ """Test Adaptive Lighting config flow.""" -import pytest -from voluptuous.error import MultipleInvalid - from homeassistant import data_entry_flow from homeassistant.components.adaptive_lighting.const import ( CONF_SUNRISE_TIME, @@ -11,6 +8,7 @@ NONE_STR, VALIDATION_TUPLES, ) +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_NAME from tests.common import MockConfigEntry @@ -93,13 +91,12 @@ async def test_incorrect_options(hass): result = await hass.config_entries.options.async_init(entry.entry_id) data = DEFAULT_DATA.copy() - data[CONF_SUNRISE_TIME] = None - data[CONF_SUNSET_TIME] = None - with pytest.raises(MultipleInvalid): - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input=data, - ) + data[CONF_SUNRISE_TIME] = "yolo" + data[CONF_SUNSET_TIME] = "yolo" + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=data, + ) async def test_import_twice(hass): @@ -112,3 +109,23 @@ async def test_import_twice(hass): context={"source": "import"}, data=data, ) + + +async def test_changing_options_when_using_yaml(hass): + """Test changing options when using YAML.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_NAME, + data={CONF_NAME: DEFAULT_NAME}, + source=SOURCE_IMPORT, + options={}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={}, + ) diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index 10e4c1e8da61c9..c08bc40b5f4576 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -455,29 +455,32 @@ async def update(): switch = await setup_switch_and_lights(hass) - # - SCENARIO 1 - # Turn light on - await turn_light(True) - # Turn light off with transition - await turn_light(False, transition=30) + for turn_light_state_at_end in [True, False]: + # Turn light on + await turn_light(True) + # Turn light off with transition + await turn_light(False, transition=30) - assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] - # Set state to on after a second (like happens IRL) - await asyncio.sleep(1) - hass.states.async_set(ENTITY_LIGHT, STATE_ON) - # Set state to off after a second (like happens IRL) - await asyncio.sleep(1) - hass.states.async_set(ENTITY_LIGHT, STATE_OFF) - - # Now we test whether the sleep task is there - assert ENTITY_LIGHT in switch.turn_on_off_listener.sleep_tasks - sleep_task = switch.turn_on_off_listener.sleep_tasks[ENTITY_LIGHT] - assert not sleep_task.cancelled() - - # A 'light.turn_on' event should cancel that task - await turn_light(True) - await update() - assert sleep_task.cancelled() + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + # Set state to on after a second (like happens IRL) + await asyncio.sleep(1) + hass.states.async_set(ENTITY_LIGHT, STATE_ON) + # Set state to off after a second (like happens IRL) + await asyncio.sleep(1) + hass.states.async_set(ENTITY_LIGHT, STATE_OFF) + + # Now we test whether the sleep task is there + assert ENTITY_LIGHT in switch.turn_on_off_listener.sleep_tasks + sleep_task = switch.turn_on_off_listener.sleep_tasks[ENTITY_LIGHT] + assert not sleep_task.cancelled() + + # A 'light.turn_on' event should cancel that task + await turn_light(turn_light_state_at_end) + await update() + if turn_light_state_at_end: + assert sleep_task.cancelled() + expected_state = {False: STATE_OFF, True: STATE_ON}[turn_light_state_at_end] + assert hass.states.get(ENTITY_LIGHT).state == expected_state def test_color_difference_redmean(): From 3ebf0e4f128cc13029eab13cb7fe15486265cea4 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 19 Oct 2020 13:09:13 +0200 Subject: [PATCH 139/165] increase overal test coverage to >97% --- .../components/adaptive_lighting/const.py | 5 - .../components/adaptive_lighting/switch.py | 2 - .../adaptive_lighting/test_switch.py | 143 +++++++++++++++++- 3 files changed, 138 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index a96e301b276775..2bd9db96933ce9 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -86,11 +86,6 @@ def timedelta_as_int(value): return value.total_seconds() -def join_strings(lst): - """Join a list to comma-separated values string.""" - return ",".join(lst) - - # conf_option: (validator, coerce) tuples # these validators cannot be serialized but can be serialized when coerced by coerce. EXTRA_VALIDATION = { diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 7fb14182257c28..0736f7de25a7cd 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -154,8 +154,6 @@ def is_our_context(context: Optional[Context]) -> bool: async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall): """Handle the entity service apply.""" - if not isinstance(switch, AdaptiveSwitch): - raise ValueError("Apply can only be called for a AdaptiveSwitch.") hass = switch.hass data = service_call.data all_lights = _expand_light_groups(hass, data[CONF_LIGHTS]) diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index c08bc40b5f4576..9c25221d9eb0b6 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -12,6 +12,7 @@ CONF_INITIAL_TRANSITION, CONF_MANUAL_CONTROL, CONF_PREFER_RGB_COLOR, + CONF_SUNRISE_OFFSET, CONF_SUNRISE_TIME, CONF_SUNSET_TIME, CONF_TRANSITION, @@ -51,7 +52,7 @@ STATE_OFF, STATE_ON, ) -from homeassistant.core import Context +from homeassistant.core import Context, State from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -120,7 +121,6 @@ async def setup_switch_and_lights(hass): lights = [ "light.bed_light", "light.ceiling_lights", - "light.kitchen_lights", ] assert all(hass.states.get(light) is not None for light in lights) entry = await setup_switch( @@ -350,6 +350,25 @@ def assert_expected_color_temp(state): assert_expected_color_temp(state) +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.""" + switch = await setup_switch_and_lights(hass) + light = "light.kitchen_lights" + assert light not in switch._lights + for state in [True, False]: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON if state else SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: light}, + blocking=True, + ) + await switch._update_attrs_and_maybe_adapt_lights( + context=switch.create_context("test") + ) + await hass.async_block_till_done() + assert light not in switch.turn_on_off_listener.lights + + async def test_manual_control(hass): """Test the 'manual control' tracking.""" switch = await setup_switch_and_lights(hass) @@ -459,14 +478,14 @@ async def update(): # Turn light on await turn_light(True) # Turn light off with transition - await turn_light(False, transition=30) + await turn_light(False, transition=1) assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] # Set state to on after a second (like happens IRL) - await asyncio.sleep(1) + await asyncio.sleep(1e-3) hass.states.async_set(ENTITY_LIGHT, STATE_ON) # Set state to off after a second (like happens IRL) - await asyncio.sleep(1) + await asyncio.sleep(1e-3) hass.states.async_set(ENTITY_LIGHT, STATE_OFF) # Now we test whether the sleep task is there @@ -483,6 +502,55 @@ async def update(): assert hass.states.get(ENTITY_LIGHT).state == expected_state +async def test_significant_change(hass): + """Test significant change.""" + + async def turn_light(state, **kwargs): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON if state else SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT, **kwargs}, + blocking=True, + ) + await hass.async_block_till_done() + + async def update(force): + await switch._update_attrs_and_maybe_adapt_lights( + transition=0, + context=switch.create_context("test"), + force=force, + ) + await hass.async_block_till_done() + + def get_demo_light(entity_id): + return next( + demo_light + for demo_light in hass.data["demo"]["light"] + if demo_light.entity_id == entity_id + ) + + switch = await setup_switch_and_lights(hass) + await turn_light(True) + await update(force=True) # removes manual control + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + + # Change brightness by setting state (not using 'light.turn_on') + attributes = hass.states.get(ENTITY_LIGHT).attributes + new_attributes = attributes.copy() + new_brightness = (attributes[ATTR_BRIGHTNESS] + 100) % 255 + new_attributes[ATTR_BRIGHTNESS] = new_brightness + get_demo_light(ENTITY_LIGHT)._brightness = new_brightness + hass.states.async_set(ENTITY_LIGHT, STATE_ON, new_attributes, True) + await hass.async_block_till_done() + assert switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None + for _ in range(switch.turn_on_off_listener.max_cnt_significant_changes): + await update(force=False) + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + # On next update the light should be marked as manually controlled + await update(force=False) + # assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + + def test_color_difference_redmean(): """Test color_difference_redmean function.""" for _ in range(10): @@ -524,6 +592,12 @@ def test_attributes_have_changed(): assert _attributes_have_changed( old_attributes=attributes_1, new_attributes=attrs, **kwargs ) + # Switch from rgb_color to color_temp + assert _attributes_have_changed( + old_attributes={ATTR_BRIGHTNESS: 1, ATTR_COLOR_TEMP: 100}, + new_attributes={ATTR_BRIGHTNESS: 1, ATTR_RGB_COLOR: (0, 0, 0)}, + **kwargs, + ) @pytest.mark.parametrize("wait", [True, False]) @@ -555,3 +629,62 @@ async def test_expand_light_groups(hass, wait): else: # Cannot expand yet because state is None assert expanded == {"light.light_group"} + + +async def test_unload_switch(hass): + """Test removing Adaptive Lighting.""" + entry = await setup_switch(hass, {}) + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert DOMAIN not in hass.data + + +@pytest.mark.parametrize("state", [STATE_ON, STATE_OFF]) +async def test_restore_off_state(hass, state): + """Test that the 'off' and 'on' states are propoperly restored.""" + with patch( + "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state", + return_value=State(ENTITY_SWITCH, state), + ): + await hass.async_start() + await hass.async_block_till_done() + entry = await setup_switch(hass, {}) + switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] + if state == STATE_ON: + assert switch.is_on + else: + assert not switch.is_on + + +@pytest.mark.xfail(reason="Offset is larger than half a day") +async def test_offset_too_large(hass): + """Test that update fails when the offset is too large.""" + entry = await setup_switch(hass, {CONF_SUNRISE_OFFSET: 3600 * 12}) + switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] + await switch._update_attrs_and_maybe_adapt_lights( + context=switch.create_context("test") + ) + await hass.async_block_till_done() + + +async def test_turn_on_and_off_when_already_at_that_state(hass): + """Test 'switch.turn_on/off' when switch is on/off.""" + entry = await setup_switch(hass, {}) + switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] + + await switch.async_turn_on() + await hass.async_block_till_done() + await switch.async_turn_on() + await hass.async_block_till_done() + + await switch.async_turn_off() + await hass.async_block_till_done() + await switch.async_turn_off() + await hass.async_block_till_done() + + +async def test_async_update_at_interval(hass): + """Test '_async_update_at_interval' method.""" + entry = await setup_switch(hass, {}) + switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] + await switch._async_update_at_interval() From fa54aeb3b254f2260f30026e6130bfd271488f94 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 19 Oct 2020 20:39:47 +0200 Subject: [PATCH 140/165] use FakeLight implementation based on demo.light --- .../adaptive_lighting/fake_light.py | 107 ++++++++++++++++++ .../adaptive_lighting/test_switch.py | 64 ++++++++--- 2 files changed, 153 insertions(+), 18 deletions(-) create mode 100644 tests/components/adaptive_lighting/fake_light.py diff --git a/tests/components/adaptive_lighting/fake_light.py b/tests/components/adaptive_lighting/fake_light.py new file mode 100644 index 00000000000000..3e2fef512a6596 --- /dev/null +++ b/tests/components/adaptive_lighting/fake_light.py @@ -0,0 +1,107 @@ +"""Fake light entity bases on 'demo.light.DemoLight'.""" +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, + LightEntity, +) + +SUPPORT_DEMO = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_COLOR + + +class FakeLight(LightEntity): + """Representation of a fake light.""" + + def __init__( + self, + unique_id, + name, + state, + hs_color=None, + ct=None, + brightness=180, + ): + """Initialize the light.""" + self._unique_id = unique_id + self._name = name + self._state = state + self._hs_color = hs_color + self._ct = ct or 380 + self._brightness = brightness + self._features = SUPPORT_DEMO + self._available = True + self._color_mode = "ct" if ct is not None and hs_color is None else "hs" + + @property + def name(self) -> str: + """Return the name of the light if any.""" + return self._name + + @property + def unique_id(self): + """Return unique ID for light.""" + return self._unique_id + + @property + def should_poll(self) -> bool: + """No polling needed for a demo light.""" + return False + + @property + def available(self) -> bool: + """Return availability.""" + return self._available + + @property + def brightness(self) -> int: + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def hs_color(self) -> tuple: + """Return the hs color value.""" + if self._color_mode == "hs": + return self._hs_color + return None + + @property + def color_temp(self) -> int: + """Return the CT color temperature.""" + if self._color_mode == "ct": + return self._ct + return None + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self._state + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return self._features + + async def async_turn_on(self, **kwargs) -> None: + """Turn the light on.""" + self._state = True + + if ATTR_HS_COLOR in kwargs: + self._color_mode = "hs" + self._hs_color = kwargs[ATTR_HS_COLOR] + + if ATTR_COLOR_TEMP in kwargs: + self._color_mode = "ct" + self._ct = kwargs[ATTR_COLOR_TEMP] + + if ATTR_BRIGHTNESS in kwargs: + self._brightness = kwargs[ATTR_BRIGHTNESS] + + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + """Turn the light off.""" + self._state = False + self.async_write_ha_state() diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index 9c25221d9eb0b6..9ec04f7fe6fbcd 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -48,6 +48,7 @@ ATTR_ENTITY_ID, CONF_LIGHTS, CONF_NAME, + CONF_PLATFORM, SERVICE_TURN_ON, STATE_OFF, STATE_ON, @@ -56,6 +57,8 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from .fake_light import FakeLight + from tests.async_mock import patch from tests.common import MockConfigEntry from tests.components.demo.test_light import ENTITY_LIGHT @@ -103,13 +106,45 @@ async def setup_switch(hass, extra_data): return entry +async def setup_lights(hass): + """Set up 3 light entities using the 'test' platform.""" + platform = getattr(hass.components, "test.light") + while platform.ENTITIES: + # Make sure it is empty + platform.ENTITIES.pop() + lights = [ + FakeLight( + unique_id="light_1", + name="Bed Light", + state=True, + ct=200, + ), + FakeLight( + unique_id="light_2", + name="Ceiling Lights", + state=True, + ct=380, + ), + FakeLight( + unique_id="light_3", + name="Kitchen Lights", + state=True, + hs_color=(345, 75), + ct=240, + ), + ] + platform.ENTITIES.extend(lights) + assert await async_setup_component( + hass, LIGHT_DOMAIN, {LIGHT_DOMAIN: {CONF_PLATFORM: "test"}} + ) + await hass.async_block_till_done() + return lights + + async def setup_switch_and_lights(hass): """Create switch and demo lights.""" # Setup demo lights and turn on - await async_setup_component( - hass, LIGHT_DOMAIN, {LIGHT_DOMAIN: [{"platform": "demo"}]} - ) - await hass.async_block_till_done() + lights_instances = await setup_lights(hass) await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -137,7 +172,7 @@ async def setup_switch_and_lights(hass): ) switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] await hass.async_block_till_done() - return switch + return switch, lights_instances async def test_adaptive_lighting_switches(hass): @@ -256,7 +291,7 @@ async def patch_time_and_update(time): async def test_light_settings(hass): """Test that light settings are correctly applied.""" - switch = await setup_switch_and_lights(hass) + switch, _ = await setup_switch_and_lights(hass) lights = switch._lights # Turn on "sleep mode" @@ -352,7 +387,7 @@ def assert_expected_color_temp(state): 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.""" - switch = await setup_switch_and_lights(hass) + switch, _ = await setup_switch_and_lights(hass) light = "light.kitchen_lights" assert light not in switch._lights for state in [True, False]: @@ -371,7 +406,7 @@ async def test_turn_on_off_listener_not_tracking_untracked_lights(hass): async def test_manual_control(hass): """Test the 'manual control' tracking.""" - switch = await setup_switch_and_lights(hass) + switch, _ = await setup_switch_and_lights(hass) context = switch.create_context("test") # needs to be passed to update method async def update(): @@ -472,7 +507,7 @@ async def update(): ) await hass.async_block_till_done() - switch = await setup_switch_and_lights(hass) + switch, _ = await setup_switch_and_lights(hass) for turn_light_state_at_end in [True, False]: # Turn light on @@ -522,14 +557,7 @@ async def update(force): ) await hass.async_block_till_done() - def get_demo_light(entity_id): - return next( - demo_light - for demo_light in hass.data["demo"]["light"] - if demo_light.entity_id == entity_id - ) - - switch = await setup_switch_and_lights(hass) + switch, (bed_light, *_) = await setup_switch_and_lights(hass) await turn_light(True) await update(force=True) # removes manual control assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] @@ -539,7 +567,7 @@ def get_demo_light(entity_id): new_attributes = attributes.copy() new_brightness = (attributes[ATTR_BRIGHTNESS] + 100) % 255 new_attributes[ATTR_BRIGHTNESS] = new_brightness - get_demo_light(ENTITY_LIGHT)._brightness = new_brightness + bed_light._brightness = new_brightness hass.states.async_set(ENTITY_LIGHT, STATE_ON, new_attributes, True) await hass.async_block_till_done() assert switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None From 7290e19d73f4ba9217608c77dba6028ac47ccc47 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 19 Oct 2020 23:38:57 +0200 Subject: [PATCH 141/165] add SLEEP_MODE_SWITCH constant and clean up tests --- .../components/adaptive_lighting/const.py | 1 + .../components/adaptive_lighting/switch.py | 3 +- .../adaptive_lighting/test_switch.py | 64 +++++++++---------- 3 files changed, 32 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index 2bd9db96933ce9..c9dfd3a520a3fb 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -36,6 +36,7 @@ CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", True CONF_TRANSITION, DEFAULT_TRANSITION = "transition", 45 +SLEEP_MODE_SWITCH = "sleep_mode_switch" ATTR_TURN_ON_OFF_LISTENER = "turn_on_off_listener" UNDO_UPDATE_LISTENER = "undo_update_listener" NONE_STR = "None" diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 0736f7de25a7cd..41f30a919b9d5b 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -102,6 +102,7 @@ ICON, SERVICE_APPLY, SERVICE_SET_MANUAL_CONTROL, + SLEEP_MODE_SWITCH, SUN_EVENT_MIDNIGHT, SUN_EVENT_NOON, TURNING_OFF_DELAY, @@ -216,7 +217,7 @@ async def async_setup_entry( sleep_mode_switch = AdaptiveSleepModeSwitch(hass, config_entry) switch = AdaptiveSwitch(hass, config_entry, turn_on_off_listener, sleep_mode_switch) - data[config_entry.entry_id]["sleep_mode_switch"] = sleep_mode_switch + data[config_entry.entry_id][SLEEP_MODE_SWITCH] = sleep_mode_switch data[config_entry.entry_id][SWITCH_DOMAIN] = switch async_add_entities([switch, sleep_mode_switch], update_before_add=True) diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index 9ec04f7fe6fbcd..a1d795ff583811 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -24,6 +24,7 @@ DOMAIN, SERVICE_APPLY, SERVICE_SET_MANUAL_CONTROL, + SLEEP_MODE_SWITCH, UNDO_UPDATE_LISTENER, ) from homeassistant.components.adaptive_lighting.switch import ( @@ -103,7 +104,8 @@ async def setup_switch(hass, extra_data): entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - return entry + switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] + return entry, switch async def setup_lights(hass): @@ -141,7 +143,7 @@ async def setup_lights(hass): return lights -async def setup_switch_and_lights(hass): +async def setup_lights_and_switch(hass): """Create switch and demo lights.""" # Setup demo lights and turn on lights_instances = await setup_lights(hass) @@ -158,7 +160,7 @@ async def setup_switch_and_lights(hass): "light.ceiling_lights", ] assert all(hass.states.get(light) is not None for light in lights) - entry = await setup_switch( + _, switch = await setup_switch( hass, { CONF_LIGHTS: lights, @@ -170,26 +172,25 @@ async def setup_switch_and_lights(hass): CONF_PREFER_RGB_COLOR: False, }, ) - switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] await hass.async_block_till_done() return switch, lights_instances async def test_adaptive_lighting_switches(hass): """Test switches created for adaptive_lighting integration.""" - entry = await setup_switch(hass, {}) + entry, _ = await setup_switch(hass, {}) assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 assert hass.states.async_entity_ids(SWITCH_DOMAIN) == [ - f"{SWITCH_DOMAIN}.{DOMAIN}_{DEFAULT_NAME}", - f"{SWITCH_DOMAIN}.{DOMAIN}_sleep_mode_{DEFAULT_NAME}", + ENTITY_SWITCH, + ENTITY_SLEEP_MODE_SWITCH, ] assert ATTR_TURN_ON_OFF_LISTENER in hass.data[DOMAIN] assert entry.entry_id in hass.data[DOMAIN] assert len(hass.data[DOMAIN].keys()) == 2 data = hass.data[DOMAIN][entry.entry_id] - assert "sleep_mode_switch" in data + assert SLEEP_MODE_SWITCH in data assert SWITCH_DOMAIN in data assert UNDO_UPDATE_LISTENER in data assert len(data.keys()) == 3 @@ -204,8 +205,7 @@ async def test_adaptive_lighting_time_zones_with_default_settings( hass, {"latitude": lat, "longitude": long, "time_zone": timezone}, ) - entry = await setup_switch(hass, {}) - switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] + _, switch = await setup_switch(hass, {}) # Shouldn't raise an exception ever await switch._update_attrs_and_maybe_adapt_lights( context=switch.create_context("test") @@ -224,7 +224,7 @@ async def test_adaptive_lighting_time_zones_and_sun_settings( hass, {"latitude": lat, "longitude": long, "time_zone": timezone}, ) - entry = await setup_switch( + _, switch = await setup_switch( hass, { CONF_SUNRISE_TIME: datetime.time(SUNRISE.hour), @@ -232,7 +232,6 @@ async def test_adaptive_lighting_time_zones_and_sun_settings( }, ) - switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] context = switch.create_context("test") # needs to be passed to update method min_color_temp = switch._sun_light_settings.min_color_temp @@ -282,8 +281,7 @@ async def patch_time_and_update(time): # Turn on sleep mode which make the brightness and color_temp # deterministic regardless of the time - sleep_mode_switch = hass.data[DOMAIN][entry.entry_id]["sleep_mode_switch"] - await sleep_mode_switch.async_turn_on() + await switch.sleep_mode_switch.async_turn_on() await switch._update_attrs_and_maybe_adapt_lights(context=context) assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_SLEEP_BRIGHTNESS assert switch._settings["color_temp_kelvin"] == DEFAULT_SLEEP_COLOR_TEMP @@ -291,7 +289,7 @@ async def patch_time_and_update(time): async def test_light_settings(hass): """Test that light settings are correctly applied.""" - switch, _ = await setup_switch_and_lights(hass) + switch, _ = await setup_lights_and_switch(hass) lights = switch._lights # Turn on "sleep mode" @@ -387,7 +385,7 @@ def assert_expected_color_temp(state): 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.""" - switch, _ = await setup_switch_and_lights(hass) + switch, _ = await setup_lights_and_switch(hass) light = "light.kitchen_lights" assert light not in switch._lights for state in [True, False]: @@ -406,7 +404,7 @@ async def test_turn_on_off_listener_not_tracking_untracked_lights(hass): async def test_manual_control(hass): """Test the 'manual control' tracking.""" - switch, _ = await setup_switch_and_lights(hass) + switch, _ = await setup_lights_and_switch(hass) context = switch.create_context("test") # needs to be passed to update method async def update(): @@ -476,7 +474,7 @@ async def change_manual_control(set_to): async def test_apply_service(hass): """Test adaptive_lighting.apply service.""" - await setup_switch_and_lights(hass) + await setup_lights_and_switch(hass) await hass.services.async_call( DOMAIN, SERVICE_APPLY, @@ -507,7 +505,7 @@ async def update(): ) await hass.async_block_till_done() - switch, _ = await setup_switch_and_lights(hass) + switch, _ = await setup_lights_and_switch(hass) for turn_light_state_at_end in [True, False]: # Turn light on @@ -531,10 +529,12 @@ async def update(): # A 'light.turn_on' event should cancel that task await turn_light(turn_light_state_at_end) await update() + state = hass.states.get(ENTITY_LIGHT).state if turn_light_state_at_end: assert sleep_task.cancelled() - expected_state = {False: STATE_OFF, True: STATE_ON}[turn_light_state_at_end] - assert hass.states.get(ENTITY_LIGHT).state == expected_state + assert state == STATE_ON + else: + assert state == STATE_OFF async def test_significant_change(hass): @@ -557,7 +557,7 @@ async def update(force): ) await hass.async_block_till_done() - switch, (bed_light, *_) = await setup_switch_and_lights(hass) + switch, (bed_light_instance, *_) = await setup_lights_and_switch(hass) await turn_light(True) await update(force=True) # removes manual control assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] @@ -567,16 +567,14 @@ async def update(force): new_attributes = attributes.copy() new_brightness = (attributes[ATTR_BRIGHTNESS] + 100) % 255 new_attributes[ATTR_BRIGHTNESS] = new_brightness - bed_light._brightness = new_brightness - hass.states.async_set(ENTITY_LIGHT, STATE_ON, new_attributes, True) - await hass.async_block_till_done() + bed_light_instance._brightness = new_brightness assert switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None for _ in range(switch.turn_on_off_listener.max_cnt_significant_changes): await update(force=False) assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] # On next update the light should be marked as manually controlled await update(force=False) - # assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] def test_color_difference_redmean(): @@ -661,7 +659,7 @@ async def test_expand_light_groups(hass, wait): async def test_unload_switch(hass): """Test removing Adaptive Lighting.""" - entry = await setup_switch(hass, {}) + entry, _ = await setup_switch(hass, {}) assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert DOMAIN not in hass.data @@ -676,8 +674,7 @@ async def test_restore_off_state(hass, state): ): await hass.async_start() await hass.async_block_till_done() - entry = await setup_switch(hass, {}) - switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] + _, switch = await setup_switch(hass, {}) if state == STATE_ON: assert switch.is_on else: @@ -687,8 +684,7 @@ async def test_restore_off_state(hass, state): @pytest.mark.xfail(reason="Offset is larger than half a day") async def test_offset_too_large(hass): """Test that update fails when the offset is too large.""" - entry = await setup_switch(hass, {CONF_SUNRISE_OFFSET: 3600 * 12}) - switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] + _, switch = await setup_switch(hass, {CONF_SUNRISE_OFFSET: 3600 * 12}) await switch._update_attrs_and_maybe_adapt_lights( context=switch.create_context("test") ) @@ -697,8 +693,7 @@ async def test_offset_too_large(hass): async def test_turn_on_and_off_when_already_at_that_state(hass): """Test 'switch.turn_on/off' when switch is on/off.""" - entry = await setup_switch(hass, {}) - switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] + _, switch = await setup_switch(hass, {}) await switch.async_turn_on() await hass.async_block_till_done() @@ -713,6 +708,5 @@ async def test_turn_on_and_off_when_already_at_that_state(hass): async def test_async_update_at_interval(hass): """Test '_async_update_at_interval' method.""" - entry = await setup_switch(hass, {}) - switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] + _, switch = await setup_switch(hass, {}) await switch._async_update_at_interval() From 13997601fbcf0c5f01210c2dbde7b1d4a08d041e Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 20 Oct 2020 00:10:24 +0200 Subject: [PATCH 142/165] add 'sun_position' attribute --- homeassistant/components/adaptive_lighting/switch.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 41f30a919b9d5b..8c662cf6f32971 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -988,6 +988,7 @@ def get_settings( "rgb_color": rgb_color, "xy_color": xy_color, "hs_color": hs_color, + "sun_position": percent, } From 2203995bd39367b0c4462fea409b0df8b2b8f895 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 20 Oct 2020 21:59:15 +0200 Subject: [PATCH 143/165] use DemoLight for as long as it works --- .../adaptive_lighting/fake_light.py | 107 ------------------ .../adaptive_lighting/test_switch.py | 9 +- 2 files changed, 4 insertions(+), 112 deletions(-) delete mode 100644 tests/components/adaptive_lighting/fake_light.py diff --git a/tests/components/adaptive_lighting/fake_light.py b/tests/components/adaptive_lighting/fake_light.py deleted file mode 100644 index 3e2fef512a6596..00000000000000 --- a/tests/components/adaptive_lighting/fake_light.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Fake light entity bases on 'demo.light.DemoLight'.""" -from homeassistant.components.light import ( - ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, - ATTR_HS_COLOR, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, - LightEntity, -) - -SUPPORT_DEMO = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_COLOR - - -class FakeLight(LightEntity): - """Representation of a fake light.""" - - def __init__( - self, - unique_id, - name, - state, - hs_color=None, - ct=None, - brightness=180, - ): - """Initialize the light.""" - self._unique_id = unique_id - self._name = name - self._state = state - self._hs_color = hs_color - self._ct = ct or 380 - self._brightness = brightness - self._features = SUPPORT_DEMO - self._available = True - self._color_mode = "ct" if ct is not None and hs_color is None else "hs" - - @property - def name(self) -> str: - """Return the name of the light if any.""" - return self._name - - @property - def unique_id(self): - """Return unique ID for light.""" - return self._unique_id - - @property - def should_poll(self) -> bool: - """No polling needed for a demo light.""" - return False - - @property - def available(self) -> bool: - """Return availability.""" - return self._available - - @property - def brightness(self) -> int: - """Return the brightness of this light between 0..255.""" - return self._brightness - - @property - def hs_color(self) -> tuple: - """Return the hs color value.""" - if self._color_mode == "hs": - return self._hs_color - return None - - @property - def color_temp(self) -> int: - """Return the CT color temperature.""" - if self._color_mode == "ct": - return self._ct - return None - - @property - def is_on(self) -> bool: - """Return true if light is on.""" - return self._state - - @property - def supported_features(self) -> int: - """Flag supported features.""" - return self._features - - async def async_turn_on(self, **kwargs) -> None: - """Turn the light on.""" - self._state = True - - if ATTR_HS_COLOR in kwargs: - self._color_mode = "hs" - self._hs_color = kwargs[ATTR_HS_COLOR] - - if ATTR_COLOR_TEMP in kwargs: - self._color_mode = "ct" - self._ct = kwargs[ATTR_COLOR_TEMP] - - if ATTR_BRIGHTNESS in kwargs: - self._brightness = kwargs[ATTR_BRIGHTNESS] - - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs) -> None: - """Turn the light off.""" - self._state = False - self.async_write_ha_state() diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index a1d795ff583811..c331c13c85e7af 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -34,6 +34,7 @@ create_context, is_our_context, ) +from homeassistant.components.demo.light import DemoLight from homeassistant.components.group import DOMAIN as GROUP_DOMAIN from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -58,8 +59,6 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .fake_light import FakeLight - from tests.async_mock import patch from tests.common import MockConfigEntry from tests.components.demo.test_light import ENTITY_LIGHT @@ -115,19 +114,19 @@ async def setup_lights(hass): # Make sure it is empty platform.ENTITIES.pop() lights = [ - FakeLight( + DemoLight( unique_id="light_1", name="Bed Light", state=True, ct=200, ), - FakeLight( + DemoLight( unique_id="light_2", name="Ceiling Lights", state=True, ct=380, ), - FakeLight( + DemoLight( unique_id="light_3", name="Kitchen Lights", state=True, From 4732f4eaff112c6deb2d67a09cc41b1b4ba1c5e0 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 20 Oct 2020 22:06:23 +0200 Subject: [PATCH 144/165] generalize AdaptiveSleepModeSwitch to SimpleSwitch --- .../components/adaptive_lighting/switch.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 8c662cf6f32971..1b2d01420399bf 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -64,6 +64,7 @@ ) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.sun import get_astral_location +from homeassistant.util import slugify from homeassistant.util.color import ( color_RGB_to_xy, color_temperature_kelvin_to_mired, @@ -214,7 +215,7 @@ async def async_setup_entry( data[ATTR_TURN_ON_OFF_LISTENER] = TurnOnOffListener(hass) turn_on_off_listener = data[ATTR_TURN_ON_OFF_LISTENER] - sleep_mode_switch = AdaptiveSleepModeSwitch(hass, config_entry) + sleep_mode_switch = SimpleSwitch("sleep_mode", hass, config_entry) switch = AdaptiveSwitch(hass, config_entry, turn_on_off_listener, sleep_mode_switch) data[config_entry.entry_id][SLEEP_MODE_SWITCH] = sleep_mode_switch @@ -407,7 +408,7 @@ def __init__( hass, config_entry: ConfigEntry, turn_on_off_listener: TurnOnOffListener, - sleep_mode_switch: AdaptiveSleepModeSwitch, + sleep_mode_switch: SimpleSwitch, ): """Initialize the Adaptive Lighting switch.""" self.hass = hass @@ -805,26 +806,29 @@ async def _light_event(self, event: Event) -> None: self.turn_on_off_listener.reset(entity_id) -class AdaptiveSleepModeSwitch(SwitchEntity, RestoreEntity): +class SimpleSwitch(SwitchEntity, RestoreEntity): """Representation of a Adaptive Lighting switch.""" - def __init__(self, hass: HomeAssistant, config_entry): + def __init__(self, which: str, hass: HomeAssistant, config_entry): """Initialize the Adaptive Lighting switch.""" self.hass = hass data = validate(config_entry) self._name = data[CONF_NAME] self._icon = ICON self._state = None + self._which = which + self._unique_id = f"{self._name}_{slugify(self._which)}" + self._name = f"Adaptive Lighting {which}: {self._name}" @property def name(self): """Return the name of the device if any.""" - return f"Adaptive Lighting Sleep Mode: {self._name}" + return self._name @property def unique_id(self): """Return the unique ID of entity.""" - return f"{self._name}_sleep_mode" + return self._unique_id @property def icon(self) -> str: From 1b9451b41228193d1666551b1c38fbd1933b6629 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 21 Oct 2020 15:43:17 +0200 Subject: [PATCH 145/165] define a adapt_brightness and adapt_color switch --- .../components/adaptive_lighting/const.py | 10 +- .../components/adaptive_lighting/switch.py | 132 +++++++++++------- .../adaptive_lighting/test_switch.py | 24 ++-- 3 files changed, 101 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index c9dfd3a520a3fb..ccfeffa8c92bd0 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -12,9 +12,6 @@ CONF_NAME, DEFAULT_NAME = "name", "default" CONF_LIGHTS, DEFAULT_LIGHTS = "lights", [] -CONF_ADAPT_BRIGHTNESS, DEFAULT_ADAPT_BRIGHTNESS = "adapt_brightness", True -CONF_ADAPT_COLOR_TEMP, DEFAULT_ADAPT_COLOR_TEMP = "adapt_color_temp", True -CONF_ADAPT_RGB_COLOR, DEFAULT_ADAPT_RGB_COLOR = "adapt_rgb_color", True CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES = ( "detect_non_ha_changes", False, @@ -37,9 +34,13 @@ CONF_TRANSITION, DEFAULT_TRANSITION = "transition", 45 SLEEP_MODE_SWITCH = "sleep_mode_switch" +ADAPT_COLOR_SWITCH = "adapt_color_switch" +ADAPT_BRIGHTNESS_SWITCH = "adapt_brightness_switch" ATTR_TURN_ON_OFF_LISTENER = "turn_on_off_listener" UNDO_UPDATE_LISTENER = "undo_update_listener" NONE_STR = "None" +ATTR_ADAPT_COLOR = "adapt_color" +ATTR_ADAPT_BRIGHTNESS = "adapt_brightness" SERVICE_SET_MANUAL_CONTROL = "set_manual_control" CONF_MANUAL_CONTROL = "manual_control" @@ -56,9 +57,6 @@ def int_between(min_int, max_int): VALIDATION_TUPLES = [ (CONF_LIGHTS, DEFAULT_LIGHTS, cv.entity_ids), - (CONF_ADAPT_BRIGHTNESS, DEFAULT_ADAPT_BRIGHTNESS, bool), - (CONF_ADAPT_COLOR_TEMP, DEFAULT_ADAPT_COLOR_TEMP, bool), - (CONF_ADAPT_RGB_COLOR, DEFAULT_ADAPT_RGB_COLOR, bool), (CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR, bool), (CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION), (CONF_TRANSITION, DEFAULT_TRANSITION, VALID_TRANSITION), diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 1b2d01420399bf..8cdda287ffe0ca 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -74,10 +74,11 @@ import homeassistant.util.dt as dt_util from .const import ( + ADAPT_BRIGHTNESS_SWITCH, + ADAPT_COLOR_SWITCH, + ATTR_ADAPT_BRIGHTNESS, + ATTR_ADAPT_COLOR, ATTR_TURN_ON_OFF_LISTENER, - CONF_ADAPT_BRIGHTNESS, - CONF_ADAPT_COLOR_TEMP, - CONF_ADAPT_RGB_COLOR, CONF_DETECT_NON_HA_CHANGES, CONF_INITIAL_TRANSITION, CONF_INTERVAL, @@ -166,9 +167,8 @@ async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall): await switch._adapt_light( # pylint: disable=protected-access light, data[CONF_TRANSITION], - data[CONF_ADAPT_BRIGHTNESS], - data[CONF_ADAPT_COLOR_TEMP], - data[CONF_ADAPT_RGB_COLOR], + data[ATTR_ADAPT_BRIGHTNESS], + data[ATTR_ADAPT_COLOR], force=True, ) @@ -215,13 +215,27 @@ async def async_setup_entry( data[ATTR_TURN_ON_OFF_LISTENER] = TurnOnOffListener(hass) turn_on_off_listener = data[ATTR_TURN_ON_OFF_LISTENER] - sleep_mode_switch = SimpleSwitch("sleep_mode", hass, config_entry) - switch = AdaptiveSwitch(hass, config_entry, turn_on_off_listener, sleep_mode_switch) + sleep_mode_switch = SimpleSwitch("Sleep Mode", False, hass, config_entry) + adapt_color_switch = SimpleSwitch("Adapt Color", True, hass, config_entry) + adapt_brightness_switch = SimpleSwitch("Adapt Brightness", True, hass, config_entry) + switch = AdaptiveSwitch( + hass, + config_entry, + turn_on_off_listener, + sleep_mode_switch, + adapt_color_switch, + adapt_brightness_switch, + ) data[config_entry.entry_id][SLEEP_MODE_SWITCH] = sleep_mode_switch + data[config_entry.entry_id][ADAPT_COLOR_SWITCH] = adapt_color_switch + data[config_entry.entry_id][ADAPT_BRIGHTNESS_SWITCH] = adapt_brightness_switch data[config_entry.entry_id][SWITCH_DOMAIN] = switch - async_add_entities([switch, sleep_mode_switch], update_before_add=True) + async_add_entities( + [switch, sleep_mode_switch, adapt_color_switch, adapt_brightness_switch], + update_before_add=True, + ) # Register `apply` service platform = entity_platform.current_platform.get() @@ -233,9 +247,8 @@ async def async_setup_entry( CONF_TRANSITION, default=switch._initial_transition, # pylint: disable=protected-access ): VALID_TRANSITION, - vol.Optional(CONF_ADAPT_BRIGHTNESS, default=True): cv.boolean, - vol.Optional(CONF_ADAPT_COLOR_TEMP, default=True): cv.boolean, - vol.Optional(CONF_ADAPT_RGB_COLOR, default=True): cv.boolean, + vol.Optional(ATTR_ADAPT_BRIGHTNESS, default=True): cv.boolean, + vol.Optional(ATTR_ADAPT_COLOR, default=True): cv.boolean, vol.Optional(CONF_TURN_ON_LIGHTS, default=False): cv.boolean, }, handle_apply, @@ -265,7 +278,7 @@ def validate(config_entry: ConfigEntry): return data -def match_state_event(event: Event, from_or_to_state: List[str]): +def match_switch_state_event(event: Event, from_or_to_state: List[str]): """Match state event when either 'from_state' or 'to_state' matches.""" old_state = event.data.get("old_state") from_state_match = old_state is not None and old_state.state in from_or_to_state @@ -325,8 +338,7 @@ def _attributes_have_changed( old_attributes: Dict[str, Any], new_attributes: Dict[str, Any], adapt_brightness: bool, - adapt_color_temp: bool, - adapt_rgb_color: bool, + adapt_color: bool, context: Context, ) -> bool: if ( @@ -348,7 +360,7 @@ def _attributes_have_changed( return True if ( - adapt_color_temp + adapt_color and ATTR_COLOR_TEMP in old_attributes and ATTR_COLOR_TEMP in new_attributes ): @@ -366,7 +378,7 @@ def _attributes_have_changed( return True if ( - adapt_rgb_color + adapt_color and ATTR_RGB_COLOR in old_attributes and ATTR_RGB_COLOR in new_attributes ): @@ -409,19 +421,20 @@ def __init__( config_entry: ConfigEntry, turn_on_off_listener: TurnOnOffListener, sleep_mode_switch: SimpleSwitch, + adapt_color_switch: SimpleSwitch, + adapt_brightness_switch: SimpleSwitch, ): """Initialize the Adaptive Lighting switch.""" self.hass = hass self.turn_on_off_listener = turn_on_off_listener self.sleep_mode_switch = sleep_mode_switch + self.adapt_color_switch = adapt_color_switch + self.adapt_brightness_switch = adapt_brightness_switch data = validate(config_entry) self._name = data[CONF_NAME] self._lights = data[CONF_LIGHTS] - self._adapt_brightness = data[CONF_ADAPT_BRIGHTNESS] - self._adapt_color_temp = data[CONF_ADAPT_COLOR_TEMP] - self._adapt_rgb_color = data[CONF_ADAPT_RGB_COLOR] self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES] self._initial_transition = data[CONF_INITIAL_TRANSITION] self._interval = data[CONF_INTERVAL] @@ -528,12 +541,20 @@ async def _setup_listeners(self, _=None) -> None: remove_interval = async_track_time_interval( self.hass, self._async_update_at_interval, self._interval ) - remove_sleep = async_track_state_change_event( - self.hass, - self.sleep_mode_switch.entity_id, - self._sleep_state_event, - ) - self.remove_listeners.extend([remove_interval, remove_sleep]) + remove_simple_switches = [ + async_track_state_change_event( + self.hass, + switch.entity_id, + self._simple_switch_state_event, + ) + for switch in ( + self.sleep_mode_switch, + self.adapt_brightness_switch, + self.adapt_color_switch, + ) + ] + + self.remove_listeners.extend([remove_interval, *remove_simple_switches]) if self._lights: self._expand_light_groups() @@ -615,8 +636,7 @@ async def _adapt_light( light: str, transition: Optional[int] = None, adapt_brightness: Optional[bool] = None, - adapt_color_temp: Optional[bool] = None, - adapt_rgb_color: Optional[bool] = None, + adapt_color: Optional[bool] = None, force: bool = False, context: Optional[Context] = None, ) -> None: @@ -630,11 +650,9 @@ async def _adapt_light( if transition is None: transition = self._transition if adapt_brightness is None: - adapt_brightness = self._adapt_brightness - if adapt_color_temp is None: - adapt_color_temp = self._adapt_color_temp - if adapt_rgb_color is None: - adapt_rgb_color = self._adapt_rgb_color + adapt_brightness = self.adapt_brightness_switch.is_on + if adapt_color is None: + adapt_color = self.adapt_color_switch.is_on if "transition" in features: service_data[ATTR_TRANSITION] = transition @@ -645,7 +663,7 @@ async def _adapt_light( if ( "color_temp" in features - and adapt_color_temp + and adapt_color and not (self._prefer_rgb_color and "color" in features) ): attributes = self.hass.states.get(light).attributes @@ -653,7 +671,7 @@ async def _adapt_light( color_temp_mired = self._settings["color_temp_mired"] color_temp_mired = max(min(color_temp_mired, max_mireds), min_mireds) service_data[ATTR_COLOR_TEMP] = color_temp_mired - elif "color" in features and adapt_rgb_color: + elif "color" in features and adapt_color: service_data[ATTR_RGB_COLOR] = self._settings["rgb_color"] context = context or self.create_context("adapt_lights") @@ -663,9 +681,8 @@ async def _adapt_light( and not force and await self.turn_on_off_listener.significant_change( light, - self._adapt_brightness, - self._adapt_color_temp, - self._adapt_rgb_color, + adapt_brightness, + adapt_color, context, ) ): @@ -744,15 +761,22 @@ async def _adapt_lights( continue await self._adapt_light(light, transition, force=force, context=context) - async def _sleep_state_event(self, event: Event) -> None: - if not match_state_event(event, (STATE_ON, STATE_OFF)): + async def _simple_switch_state_event(self, event: Event) -> None: + if not match_switch_state_event(event, (STATE_ON, STATE_OFF)): return - _LOGGER.debug("%s: _sleep_state_event, event: '%s'", self._name, event) - self.turn_on_off_listener.reset(*self._lights) + which = { + self.sleep_mode_switch.entity_id: "sleep_sw", + self.adapt_color_switch.entity_id: "color_sw", + self.adapt_brightness_switch.entity_id: "brigt_sw", + }[event.data[ATTR_ENTITY_ID]] + _LOGGER.debug("%s: _simple_switch_state_event, event: '%s'", self._name, event) + if which == "sleep_sw": + # Reset the manually controlled status when the "sleep mode" changes + self.turn_on_off_listener.reset(*self._lights) await self._update_attrs_and_maybe_adapt_lights( transition=self._initial_transition, force=True, - context=self.create_context("sleep"), + context=self.create_context(which), ) async def _light_event(self, event: Event) -> None: @@ -809,7 +833,9 @@ async def _light_event(self, event: Event) -> None: class SimpleSwitch(SwitchEntity, RestoreEntity): """Representation of a Adaptive Lighting switch.""" - def __init__(self, which: str, hass: HomeAssistant, config_entry): + def __init__( + self, which: str, initial_state: bool, hass: HomeAssistant, config_entry + ): """Initialize the Adaptive Lighting switch.""" self.hass = hass data = validate(config_entry) @@ -819,6 +845,7 @@ def __init__(self, which: str, hass: HomeAssistant, config_entry): self._which = which self._unique_id = f"{self._name}_{slugify(self._which)}" self._name = f"Adaptive Lighting {which}: {self._name}" + self._initial_state = initial_state @property def name(self): @@ -843,10 +870,15 @@ def is_on(self) -> Optional[bool]: async def async_added_to_hass(self) -> None: """Call when entity about to be added to hass.""" last_state = await self.async_get_last_state() - if last_state is None or STATE_OFF: # newly added to HA - await self.async_turn_off() - else: + if last_state is None: # newly added to HA + if self._initial_state: + await self.async_turn_on() + else: + await self.async_turn_off() + elif STATE_ON: await self.async_turn_on() + elif STATE_OFF: + await self.async_turn_off() async def async_turn_on(self, **kwargs) -> None: """Turn on adaptive lighting sleep mode.""" @@ -1159,8 +1191,7 @@ async def significant_change( self, light: str, adapt_brightness: bool, - adapt_color_temp: bool, - adapt_rgb_color: bool, + adapt_color: bool, context: Context, ) -> bool: """Has the light made a significant change since last update. @@ -1180,8 +1211,7 @@ async def significant_change( light=light, new_attributes=new_state.attributes, adapt_brightness=adapt_brightness, - adapt_color_temp=adapt_color_temp, - adapt_rgb_color=adapt_rgb_color, + adapt_color=adapt_color, context=context, ) for index, old_state in enumerate(old_states): diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index c331c13c85e7af..2117581ae644ed 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -7,6 +7,8 @@ import pytest from homeassistant.components.adaptive_lighting.const import ( + ADAPT_BRIGHTNESS_SWITCH, + ADAPT_COLOR_SWITCH, ATTR_TURN_ON_OFF_LISTENER, CONF_DETECT_NON_HA_CHANGES, CONF_INITIAL_TRANSITION, @@ -84,8 +86,11 @@ (32.87336, -117.22743, "US/Pacific"), ] -ENTITY_SWITCH = f"{SWITCH_DOMAIN}.{DOMAIN}_{DEFAULT_NAME}" -ENTITY_SLEEP_MODE_SWITCH = f"{SWITCH_DOMAIN}.{DOMAIN}_sleep_mode_{DEFAULT_NAME}" +_SWITCH_FMT = f"{SWITCH_DOMAIN}.{DOMAIN}" +ENTITY_SWITCH = f"{_SWITCH_FMT}_{DEFAULT_NAME}" +ENTITY_SLEEP_MODE_SWITCH = f"{_SWITCH_FMT}_sleep_mode_{DEFAULT_NAME}" +ENTITY_ADAPT_BRIGHTNESS_SWITCH = f"{_SWITCH_FMT}_adapt_brightness_{DEFAULT_NAME}" +ENTITY_ADAPT_COLOR_SWITCH = f"{_SWITCH_FMT}_adapt_color_{DEFAULT_NAME}" ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE @@ -179,11 +184,13 @@ async def test_adaptive_lighting_switches(hass): """Test switches created for adaptive_lighting integration.""" entry, _ = await setup_switch(hass, {}) - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 - assert hass.states.async_entity_ids(SWITCH_DOMAIN) == [ + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 4 + assert set(hass.states.async_entity_ids(SWITCH_DOMAIN)) == { ENTITY_SWITCH, ENTITY_SLEEP_MODE_SWITCH, - ] + ENTITY_ADAPT_COLOR_SWITCH, + ENTITY_ADAPT_BRIGHTNESS_SWITCH, + } assert ATTR_TURN_ON_OFF_LISTENER in hass.data[DOMAIN] assert entry.entry_id in hass.data[DOMAIN] assert len(hass.data[DOMAIN].keys()) == 2 @@ -191,8 +198,10 @@ async def test_adaptive_lighting_switches(hass): data = hass.data[DOMAIN][entry.entry_id] assert SLEEP_MODE_SWITCH in data assert SWITCH_DOMAIN in data + assert ADAPT_COLOR_SWITCH in data + assert ADAPT_BRIGHTNESS_SWITCH in data assert UNDO_UPDATE_LISTENER in data - assert len(data.keys()) == 3 + assert len(data.keys()) == 5 @pytest.mark.parametrize("lat,long,timezone", LAT_LONG_TZS) @@ -604,8 +613,7 @@ def test_attributes_have_changed(): kwargs = dict( light="light.test", adapt_brightness=True, - adapt_color_temp=True, - adapt_rgb_color=True, + adapt_color=True, context=Context(), ) assert not _attributes_have_changed( From bb1813468fe7a55df658793f7da306d966f7a8a5 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 21 Oct 2020 17:40:40 +0200 Subject: [PATCH 146/165] watch for color or brightness related data in light.turn_on --- .../components/adaptive_lighting/switch.py | 57 +++++++++++++++---- .../adaptive_lighting/test_switch.py | 55 ++++++++++++++++-- 2 files changed, 95 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 8cdda287ffe0ca..1f16680f632b6e 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -19,9 +19,17 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, + ATTR_COLOR_NAME, ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + ATTR_KELVIN, ATTR_RGB_COLOR, ATTR_TRANSITION, + ATTR_WHITE_VALUE, + ATTR_XY_COLOR, DOMAIN as LIGHT_DOMAIN, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, @@ -131,6 +139,23 @@ COLOR_TEMP_CHANGE = 20 # ≈5% of total range RGB_REDMEAN_CHANGE = 80 # ≈10% of total range +COLOR_ATTRS = { # Should ATTR_PROFILE be in here? + ATTR_COLOR_NAME, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + ATTR_KELVIN, + ATTR_RGB_COLOR, + ATTR_WHITE_VALUE, # Should this be here? + ATTR_XY_COLOR, +} + +BRIGHTNESS_ATTRS = { + ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, +} + # Keep a short domain version for the context instances (which can only be 36 chars) _DOMAIN_SHORT = "adapt_lgt" @@ -750,6 +775,8 @@ async def _adapt_lights( and self.turn_on_off_listener.is_manually_controlled( light, force, + self.adapt_brightness_switch.is_on, + self.adapt_color_switch.is_on, ) ): _LOGGER.debug( @@ -1160,6 +1187,8 @@ def is_manually_controlled( self, light: 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) @@ -1173,18 +1202,22 @@ def is_manually_controlled( and not is_our_context(turn_on_event.context) and not force ): - # Light was already on and 'light.turn_on' was not called by - # the adaptive_lighting integration. - manual_control = self.manual_control[light] = True - _fire_manual_control_event(self.hass, light, 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, - turn_on_event.context.id, - ) + keys = turn_on_event.data[ATTR_SERVICE_DATA].keys() + if (adapt_color and COLOR_ATTRS.intersection(keys)) or ( + adapt_brightness and BRIGHTNESS_ATTRS.intersection(keys) + ): + # Light was already on and 'light.turn_on' was not called by + # the adaptive_lighting integration. + manual_control = self.manual_control[light] = True + _fire_manual_control_event(self.hass, light, 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, + turn_on_event.context.id, + ) return manual_control async def significant_change( diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index 2117581ae644ed..fe2db4c80a7ae1 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -412,18 +412,18 @@ async def test_turn_on_off_listener_not_tracking_untracked_lights(hass): async def test_manual_control(hass): """Test the 'manual control' tracking.""" - switch, _ = await setup_lights_and_switch(hass) + switch, (light, *_) = await setup_lights_and_switch(hass) context = switch.create_context("test") # needs to be passed to update method async def update(): await switch._update_attrs_and_maybe_adapt_lights(transition=0, context=context) await hass.async_block_till_done() - async def turn_light(state): + async def turn_light(state, **kwargs): await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON if state else SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: ENTITY_LIGHT}, + {ATTR_ENTITY_ID: ENTITY_LIGHT, **kwargs}, blocking=True, ) await hass.async_block_till_done() @@ -452,11 +452,17 @@ async def change_manual_control(set_to): await hass.async_block_till_done() await update() + def increased_brightness(): + return (light._brightness + 100) % 255 + + def increased_color_temp(): + return max((light._ct + 100) % light.max_mireds, light.min_mireds) + # Nothing is manually controlled await update() assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] # Call light.turn_on for ENTITY_LIGHT - await turn_light(True) + await turn_light(True, brightness=increased_brightness()) # Check that ENTITY_LIGHT is manually controlled assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] # Test adaptive_lighting.set_manual_control @@ -468,7 +474,7 @@ async def change_manual_control(set_to): await change_manual_control(True) assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] await turn_light(False) - await turn_light(True) + await turn_light(True, brightness=increased_brightness()) assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] # Check that toggling (sleep mode) switch resets manual control @@ -479,6 +485,45 @@ async def change_manual_control(set_to): await turn_switch(True, entity_id) assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + # Check that when 'adapt_brightness' is off, changing the brightness + # doesn't mark it as manually controlled but changing color_temp + # does + await turn_light(False) # reset manually controlled status + await turn_light(True) + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + await switch.adapt_brightness_switch.async_turn_off() + await turn_light(True, brightness=increased_brightness()) + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + await turn_light(True, color_temp=(light._ct + 100) % 500) + assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + await switch.adapt_brightness_switch.async_turn_on() # turn on again + + # Check that when 'adapt_color' is off, changing the color + # doesn't mark it as manually controlled but changing brightness + # does + await turn_light(False) # reset manually controlled status + await turn_light(True) + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + await switch.adapt_color_switch.async_turn_off() + await turn_light(True, color_temp=increased_color_temp()) + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + await turn_light(True, brightness=increased_brightness()) + assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + + # Check that when 'adapt_color' adapt_brightness are both off + # nothing marks it as manually controlled + await turn_light(False) # reset manually controlled status + await turn_light(True) + await switch.adapt_color_switch.async_turn_off() + await switch.adapt_brightness_switch.async_turn_off() + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + await turn_light(True, color_temp=increased_color_temp()) + await turn_light(True, brightness=increased_brightness()) + await turn_light( + True, color_temp=increased_color_temp(), brightness=increased_brightness() + ) + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + async def test_apply_service(hass): """Test adaptive_lighting.apply service.""" From e74eac21b17ad73eb1e6f48e3d8c93edd359684f Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 21 Oct 2020 23:13:08 +0200 Subject: [PATCH 147/165] no need to track the state of adapt_brightness and color switches --- .../components/adaptive_lighting/switch.py | 37 +++++++------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 1f16680f632b6e..cf7bbdb92f6c52 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -566,20 +566,13 @@ async def _setup_listeners(self, _=None) -> None: remove_interval = async_track_time_interval( self.hass, self._async_update_at_interval, self._interval ) - remove_simple_switches = [ - async_track_state_change_event( - self.hass, - switch.entity_id, - self._simple_switch_state_event, - ) - for switch in ( - self.sleep_mode_switch, - self.adapt_brightness_switch, - self.adapt_color_switch, - ) - ] + remove_sleep = async_track_state_change_event( + self.hass, + self.sleep_mode_switch.entity_id, + self._sleep_mode_switch_state_event, + ) - self.remove_listeners.extend([remove_interval, *remove_simple_switches]) + self.remove_listeners.extend([remove_interval, remove_sleep]) if self._lights: self._expand_light_groups() @@ -788,22 +781,18 @@ async def _adapt_lights( continue await self._adapt_light(light, transition, force=force, context=context) - async def _simple_switch_state_event(self, event: Event) -> None: + async def _sleep_mode_switch_state_event(self, event: Event) -> None: if not match_switch_state_event(event, (STATE_ON, STATE_OFF)): return - which = { - self.sleep_mode_switch.entity_id: "sleep_sw", - self.adapt_color_switch.entity_id: "color_sw", - self.adapt_brightness_switch.entity_id: "brigt_sw", - }[event.data[ATTR_ENTITY_ID]] - _LOGGER.debug("%s: _simple_switch_state_event, event: '%s'", self._name, event) - if which == "sleep_sw": - # Reset the manually controlled status when the "sleep mode" changes - self.turn_on_off_listener.reset(*self._lights) + _LOGGER.debug( + "%s: _sleep_mode_switch_state_event, event: '%s'", self._name, event + ) + # Reset the manually controlled status when the "sleep mode" changes + self.turn_on_off_listener.reset(*self._lights) await self._update_attrs_and_maybe_adapt_lights( transition=self._initial_transition, force=True, - context=self.create_context(which), + context=self.create_context("sleep"), ) async def _light_event(self, event: Event) -> None: From ab796e793ecbe3b26a765f6c42594cea6bcbe022 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 21 Oct 2020 23:48:45 +0200 Subject: [PATCH 148/165] add German translations and update strings --- .../components/adaptive_lighting/strings.json | 3 -- .../adaptive_lighting/translations/de.json | 48 +++++++++++++++++++ .../adaptive_lighting/translations/en.json | 5 +- 3 files changed, 49 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/adaptive_lighting/translations/de.json diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index d45edc4bcd9f06..fe6d0dd8da06ff 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -21,9 +21,6 @@ "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", - "adapt_brightness": "adapt_brightness", - "adapt_color_temp": "adapt_color_temp, adapt color temperature using 'color_temp' if supported", - "adapt_rgb_color": "adapt_rgb_color, adapt color temperature using RGB/XY if supported", "initial_transition": "initial_transition, when lights go 'off' to 'on' or when 'sleep_state' changes", "interval": "interval, time between switch updates in seconds", "max_brightness": "max_brightness, in %", diff --git a/homeassistant/components/adaptive_lighting/translations/de.json b/homeassistant/components/adaptive_lighting/translations/de.json new file mode 100644 index 00000000000000..b8d06afd216cb5 --- /dev/null +++ b/homeassistant/components/adaptive_lighting/translations/de.json @@ -0,0 +1,48 @@ +{ + "title": "Adaptive Lighting", + "config": { + "step": { + "user": { + "title": "Benenne das Adaptive Lighting", + "description": "Jede Instanz kann mehrere Licht Entitäten beinhalten", + "data": { + "name": "Name" + } + } + }, + "abort": { + "already_configured": "Gerät ist bereits konfiguriert!" + } + }, + "options": { + "step": { + "init": { + "title": "Adaptive Lighting Optionen", + "description": "Alle Einstellungen für eine Adaptive Lighting Komponente. Die Optionsnamen entsprechen den YAML-Einstellungen. Es werden keine Optionen angezeigt, wenn dieser Eintrag in YAML konfiguriert wurde.", + "data": { + "lights": "Lichter", + "initial_transition": "initial_transition, wenn Lichter von 'off' zu 'on' wechseln oder wenn 'sleep_state' wechselt", + "interval": "interval, Zeit zwischen Updates des Switches", + "max_brightness": "max_brightness, maximale Helligkeit in %", + "max_color_temp": "max_color_temp, maximale Farbtemperatur in Kelvin", + "min_brightness": "min_brightness, minimale Helligkeit in %", + "min_color_temp": "min_color_temp, minimale Farbtemperatur in Kelvin", + "only_once": "only_once, passe die Lichter nur beim Einschalten an", + "prefer_rgb_color": "prefer_rgb_color, nutze 'rgb_color' vor 'color_temp', wenn möglich", + "sleep_brightness": "sleep_brightness, Schlafhelligkeit in %", + "sleep_color_temp": "sleep_color_temp, Schlaffarbtemperaturin Kelvin", + "sunrise_offset": "sunrise_offset, Sonnenaufgang Verschiebung in +/- seconds", + "sunrise_time": "sunrise_time, Sonnenaufgangszeit in 'HH:MM:SS' Format (wenn 'None' wird die aktuelle Zeit des Sonnenaufgangs an deiner Position verwendet)", + "sunset_offset": "sunset_offset, Sonnenuntergang Verschiebung in +/- seconds", + "sunset_time": "sunset_time, Sonnenuntergangszeit in 'HH:MM:SS' Format (wenn 'None' wird die aktuelle Zeit des Sonnenuntergangs an deiner Position verwendet)", + "take_over_control": "take_over_control, wenn irgendetwas während ein Licht an ist außer Adaptive Lighting den Service 'light.turn_on' aufruft, stoppe die Anpassung des Lichtes (oder des Schalters) bis dieser wieder von off -> on geschaltet wird.", + "detect_non_ha_changes": "detect_non_ha_changes, entdeckt alle Änderungen über 10% am Licht (auch außerhalb von HA gemacht), 'take_over_control' muss aktiviert sein (ruft 'homeassistant.update_entity' jede 'interval' auf!)", + "transition": "transition, Wechselzeit in Sekunden" + } + } + }, + "error": { + "option_error": "Fehlerhafte Option" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index f306cf6054d125..c2c31dbba8f3c2 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -21,9 +21,6 @@ "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", - "adapt_brightness": "adapt_brightness", - "adapt_color_temp": "adapt_color_temp, adapt color temperature using 'color_temp' if supported", - "adapt_rgb_color": "adapt_rgb_color, adapt color temperature using RGB/XY if supported", "initial_transition": "initial_transition, when lights go 'off' to 'on' or when 'sleep_state' changes", "interval": "interval, time between switch updates in seconds", "max_brightness": "max_brightness, in %", @@ -39,7 +36,7 @@ "sunset_offset": "sunset_offset, in +/- seconds", "sunset_time": "sunset_time, in 'HH:MM:SS' format (if 'None', it uses the actual sunset time at your location)", "take_over_control": "take_over_control, if anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on.", - "detect_non_ha_changes": "detect_non_ha_changes, detects all >5% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)", + "detect_non_ha_changes": "detect_non_ha_changes, detects all >10% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)", "transition": "transition, in seconds" } } From d728ec03a4b5cac828a8d0b2c416abc173f8fb7c Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Thu, 22 Oct 2020 11:13:54 +0200 Subject: [PATCH 149/165] fix initial state of SimpleSwitch --- .../components/adaptive_lighting/switch.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index cf7bbdb92f6c52..611bd21a9968df 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -855,12 +855,11 @@ def __init__( """Initialize the Adaptive Lighting switch.""" self.hass = hass data = validate(config_entry) - self._name = data[CONF_NAME] self._icon = ICON self._state = None self._which = which self._unique_id = f"{self._name}_{slugify(self._which)}" - self._name = f"Adaptive Lighting {which}: {self._name}" + self._name = f"Adaptive Lighting {which}: {data[CONF_NAME]}" self._initial_state = initial_state @property @@ -886,14 +885,10 @@ def is_on(self) -> Optional[bool]: async def async_added_to_hass(self) -> None: """Call when entity about to be added to hass.""" last_state = await self.async_get_last_state() - if last_state is None: # newly added to HA - if self._initial_state: - await self.async_turn_on() - else: - await self.async_turn_off() - elif STATE_ON: + _LOGGER.debug("%s: last state is %s", self._name, last_state) + if (last_state is None and self._initial_state) or last_state.state == STATE_ON: await self.async_turn_on() - elif STATE_OFF: + else: await self.async_turn_off() async def async_turn_on(self, **kwargs) -> None: From ee6ba255a7d81afb3497c6401dfb8f476d5339a8 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Thu, 22 Oct 2020 23:03:02 +0200 Subject: [PATCH 150/165] make sure that 'last_state is not None' --- homeassistant/components/adaptive_lighting/switch.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 611bd21a9968df..2d09eaafd5d96e 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -858,8 +858,9 @@ def __init__( self._icon = ICON self._state = None self._which = which - self._unique_id = f"{self._name}_{slugify(self._which)}" - self._name = f"Adaptive Lighting {which}: {data[CONF_NAME]}" + name = data[CONF_NAME] + self._unique_id = f"{name}_{slugify(self._which)}" + self._name = f"Adaptive Lighting {which}: {name}" self._initial_state = initial_state @property @@ -886,7 +887,9 @@ async def async_added_to_hass(self) -> None: """Call when entity about to be added to hass.""" last_state = await self.async_get_last_state() _LOGGER.debug("%s: last state is %s", self._name, last_state) - if (last_state is None and self._initial_state) or last_state.state == STATE_ON: + if (last_state is None and self._initial_state) or ( + last_state is not None and last_state.state == STATE_ON + ): await self.async_turn_on() else: await self.async_turn_off() From 19b76512cbb4a9716275d1e9934f70ff4cec82a2 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Thu, 22 Oct 2020 23:19:51 +0200 Subject: [PATCH 151/165] add test-case to prevent the bug solved in prev commit --- .../adaptive_lighting/test_switch.py | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index fe2db4c80a7ae1..1cedf22ff68e95 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -717,20 +717,37 @@ async def test_unload_switch(hass): assert DOMAIN not in hass.data -@pytest.mark.parametrize("state", [STATE_ON, STATE_OFF]) +@pytest.mark.parametrize("state", [STATE_ON, STATE_OFF, None]) async def test_restore_off_state(hass, state): """Test that the 'off' and 'on' states are propoperly restored.""" with patch( "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state", - return_value=State(ENTITY_SWITCH, state), + return_value=State(ENTITY_SWITCH, state) if state is not None else None, ): await hass.async_start() await hass.async_block_till_done() _, switch = await setup_switch(hass, {}) if state == STATE_ON: assert switch.is_on - else: + elif state == STATE_OFF: assert not switch.is_on + elif state is None: + assert switch.is_on + + for sw, initial_state in [ + (switch.sleep_mode_switch, False), + (switch.adapt_brightness_switch, True), + (switch.adapt_color_switch, True), + ]: + if state == STATE_ON: + assert sw.is_on + elif state == STATE_OFF: + assert not sw.is_on + elif state is None: + if initial_state: + assert sw.is_on + else: + assert not sw.is_on @pytest.mark.xfail(reason="Offset is larger than half a day") From d8d3c28e6f8fa81cda6966ff8e6bf1a8f913bc39 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 25 Oct 2020 09:34:40 +0100 Subject: [PATCH 152/165] fix and test adaptive_lighting.apply --- .../adaptive_lighting/services.yaml | 10 +-- .../components/adaptive_lighting/switch.py | 7 +- .../adaptive_lighting/test_switch.py | 69 +++++++++++++++---- 3 files changed, 68 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/services.yaml b/homeassistant/components/adaptive_lighting/services.yaml index ba56b77b44f1a4..71d723e0de0871 100644 --- a/homeassistant/components/adaptive_lighting/services.yaml +++ b/homeassistant/components/adaptive_lighting/services.yaml @@ -13,12 +13,12 @@ apply: adapt_brightness: description: "Adapt the 'brightness', default: true" example: true - adapt_color_temp: - description: "Adapt the 'color_temp', default: true" - example: true - adapt_rgb_color: - description: "Adapt the 'rgb_color', default: true" + adapt_color: + description: "Adapt the color_temp/color_rgb, default: true" example: true + prefer_rgb_color: + description: "Prefer to use color_rgb over color_temp if possible, default: false" + example: false turn_on_lights: description: "Turn on the lights that are off, default: false" example: false diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 2d09eaafd5d96e..6982494c8dc572 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -194,6 +194,7 @@ async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall): data[CONF_TRANSITION], data[ATTR_ADAPT_BRIGHTNESS], data[ATTR_ADAPT_COLOR], + data[CONF_PREFER_RGB_COLOR], force=True, ) @@ -274,6 +275,7 @@ async def async_setup_entry( ): 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, }, handle_apply, @@ -655,6 +657,7 @@ async def _adapt_light( transition: Optional[int] = None, adapt_brightness: Optional[bool] = None, adapt_color: Optional[bool] = None, + prefer_rgb_color: Optional[bool] = None, force: bool = False, context: Optional[Context] = None, ) -> None: @@ -671,6 +674,8 @@ async def _adapt_light( adapt_brightness = self.adapt_brightness_switch.is_on if adapt_color is None: adapt_color = self.adapt_color_switch.is_on + if prefer_rgb_color is None: + prefer_rgb_color = self._prefer_rgb_color if "transition" in features: service_data[ATTR_TRANSITION] = transition @@ -682,7 +687,7 @@ async def _adapt_light( if ( "color_temp" in features and adapt_color - and not (self._prefer_rgb_color and "color" in features) + and not (prefer_rgb_color and "color" in features) ): attributes = self.hass.states.get(light).attributes min_mireds, max_mireds = attributes["min_mireds"], attributes["max_mireds"] diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index 1cedf22ff68e95..c3b2aebf726b49 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -134,7 +134,7 @@ async def setup_lights(hass): DemoLight( unique_id="light_3", name="Kitchen Lights", - state=True, + state=False, hs_color=(345, 75), ct=240, ), @@ -527,17 +527,62 @@ def increased_color_temp(): async def test_apply_service(hass): """Test adaptive_lighting.apply service.""" - await setup_lights_and_switch(hass) - await hass.services.async_call( - DOMAIN, - SERVICE_APPLY, - { - ATTR_ENTITY_ID: ENTITY_SWITCH, - CONF_LIGHTS: [ENTITY_LIGHT], - CONF_TURN_ON_LIGHTS: True, - }, - blocking=True, - ) + switch, (_, _, light) = await setup_lights_and_switch(hass) + entity_id = light.entity_id + assert entity_id not in switch._lights + + def increased_brightness(): + return (light._brightness + 100) % 255 + + def increased_color_temp(): + return max((light._ct + 100) % light.max_mireds, light.min_mireds) + + async def change_light(): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: entity_id, + ATTR_BRIGHTNESS: increased_brightness(), + ATTR_COLOR_TEMP: increased_color_temp(), + }, + blocking=True, + ) + await hass.async_block_till_done() + + async def apply(**kwargs): + await hass.services.async_call( + DOMAIN, + SERVICE_APPLY, + { + ATTR_ENTITY_ID: ENTITY_SWITCH, + CONF_LIGHTS: [entity_id], + CONF_TURN_ON_LIGHTS: True, + **kwargs, + }, + blocking=True, + ) + + # Test turn on with defaults + assert hass.states.get(entity_id).state == STATE_OFF + await apply() + assert hass.states.get(entity_id).state == STATE_ON + await change_light() + + # Test only changing color + old_state = hass.states.get(entity_id).attributes + await apply(adapt_color=True, adapt_brightness=False) + new_state = hass.states.get(entity_id).attributes + assert old_state[ATTR_BRIGHTNESS] == new_state[ATTR_BRIGHTNESS] + assert old_state[ATTR_COLOR_TEMP] != new_state[ATTR_COLOR_TEMP] + + # Test only changing brightness + await change_light() + old_state = hass.states.get(entity_id).attributes + await apply(adapt_color=False, adapt_brightness=True) + new_state = hass.states.get(entity_id).attributes + assert old_state[ATTR_BRIGHTNESS] != new_state[ATTR_BRIGHTNESS] + assert old_state[ATTR_COLOR_TEMP] == new_state[ATTR_COLOR_TEMP] async def test_switch_off_on_off(hass): From 2a362ea05ec062011bc1562ef17d07346a63fa5e Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 25 Oct 2020 12:22:19 +0100 Subject: [PATCH 153/165] add Swedish translation --- .../adaptive_lighting/translations/sv.json | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 homeassistant/components/adaptive_lighting/translations/sv.json diff --git a/homeassistant/components/adaptive_lighting/translations/sv.json b/homeassistant/components/adaptive_lighting/translations/sv.json new file mode 100644 index 00000000000000..ed28840248a30b --- /dev/null +++ b/homeassistant/components/adaptive_lighting/translations/sv.json @@ -0,0 +1,51 @@ +{ + "title": "Adaptiv Ljussättning", + "config": { + "step": { + "user": { + "title": "Välj ett namn för Adaptiv Ljussättning", + "description": "Varje konfiguration kan innehålla flera ljuskällor!", + "data": { + "name": "Namn" + } + } + }, + "abort": { + "already_configured": "Enheten är redan konfiguerad" + } + }, + "options": { + "step": { + "init": { + "title": "Adaptiv Ljussättning Inställningar", + "description": "Alla inställningar för en Adaptiv Ljussättning komponent. Titeln på inställningarna är desamma som i YAML konfigurationen. Inga inställningar visas om enheten redan är konfigurerad i YAML.", + "data": { + "lights": "lights, ljuskällor", + "adapt_brightness": "adapt_brightness, Adaptiv ljusstyrka", + "adapt_color_temp": "adapt_color_temp, Justera färgtemperatur genom att använda 'color_temp' om möjligt", + "adapt_rgb_color": "adapt_rgb_color, Justera färgtemperatur genom att använda RGB/XY om möjligt", + "initial_transition": "initial_transition, när ljuskällorna går från 'av' till 'på' eller när 'sleep_state' ändras", + "interval": "interval, Tid mellan uppdateringar i sekunder", + "max_brightness": "max_brightness, i procent %", + "max_color_temp": "max_color_temp, i Kelvin", + "min_brightness": "min_brightness, i %", + "min_color_temp": "min_color_temp, i Kelvin", + "only_once": "only_once, Adaptivt justera endast ljuskällorna när de sätts från 'av' till 'på'", + "prefer_rgb_color": "prefer_rgb_color, Använd 'rgb_color' över 'color_temp' om möjligt", + "sleep_brightness": "sleep_brightness, i %", + "sleep_color_temp": "sleep_color_temp, i Kelvin", + "sunrise_offset": "sunrise_offset, i +/- sekunder", + "sunrise_time": "sunrise_time, i 'HH:MM:SS' format (om 'None', används den faktiskta soluppgången för din position)", + "sunset_offset": "sunset_offset, i +/- sekunder", + "sunset_time": "sunset_time, i 'HH:MM:SS' format (om 'None', används den faktiskta solnedgången för din position)", + "take_over_control": "take_over_control, om något utöver 'Adaptiv Ljussättning' komponenten kallar på 'light.turn_on' när en ljuskälla redan är på, stängs den adaptiva justeringen av tills ljuskällan stängs av -> på igen, alternativt switchen för konfigurationen", + "detect_non_ha_changes": "detect_non_ha_changes, Upptäcker alla ändringar större än 5% gjorda på ljuskällorna som inte kommer från HA. Kräver att 'take_over_control' är påslaget.(Kallar på 'homeassistant.update_entity' vid varje 'interval'!)", + "transition": "transition, i sekunder" + } + } + }, + "error": { + "option_error": "Ogiltlig inställning" + } + } +} From 92bd89151eb1ba4163833fc33ade7d1cb8792476 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 27 Oct 2020 16:53:25 +0100 Subject: [PATCH 154/165] support white_value and sync back https://github.com/basnijholt/adaptive-lighting/pull/27 --- .../components/adaptive_lighting/switch.py | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 6982494c8dc572..00af66d17bc11d 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -35,6 +35,7 @@ SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_TRANSITION, + SUPPORT_WHITE_VALUE, VALID_TRANSITION, is_on, ) @@ -122,6 +123,7 @@ _SUPPORT_OPTS = { "brightness": SUPPORT_BRIGHTNESS, + "white_value": SUPPORT_WHITE_VALUE, "color_temp": SUPPORT_COLOR_TEMP, "color": SUPPORT_COLOR, "transition": SUPPORT_TRANSITION, @@ -145,12 +147,12 @@ ATTR_HS_COLOR, ATTR_KELVIN, ATTR_RGB_COLOR, - ATTR_WHITE_VALUE, # Should this be here? ATTR_XY_COLOR, } BRIGHTNESS_ATTRS = { ATTR_BRIGHTNESS, + ATTR_WHITE_VALUE, ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS_STEP, ATTR_BRIGHTNESS_STEP_PCT, @@ -386,6 +388,24 @@ def _attributes_have_changed( ) return True + if ( + adapt_brightness + and ATTR_WHITE_VALUE in old_attributes + and ATTR_WHITE_VALUE in new_attributes + ): + last_white_value = old_attributes[ATTR_WHITE_VALUE] + current_white_value = new_attributes[ATTR_WHITE_VALUE] + if abs(current_white_value - last_white_value) > BRIGHTNESS_CHANGE: + _LOGGER.debug( + "White Value of '%s' significantly changed from %s to %s with" + " context.id='%s'", + light, + last_white_value, + current_white_value, + context.id, + ) + return True + if ( adapt_color and ATTR_COLOR_TEMP in old_attributes @@ -684,6 +704,10 @@ async def _adapt_light( brightness = round(255 * self._settings["brightness_pct"] / 100) service_data[ATTR_BRIGHTNESS] = brightness + if "white_value" in features and adapt_brightness: + white_value = round(255 * self._settings["brightness_pct"] / 100) + service_data[ATTR_WHITE_VALUE] = white_value + if ( "color_temp" in features and adapt_color From f984c5e5773987b14dd2cea34e4deb6eea96e3fe Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 27 Oct 2020 16:56:14 +0100 Subject: [PATCH 155/165] use ATTR_SUPPORTED_FEATURES --- homeassistant/components/adaptive_lighting/switch.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 00af66d17bc11d..406dc648d7cd07 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -46,6 +46,7 @@ ATTR_ENTITY_ID, ATTR_SERVICE, ATTR_SERVICE_DATA, + ATTR_SUPPORTED_FEATURES, CONF_NAME, EVENT_CALL_SERVICE, EVENT_HOMEASSISTANT_STARTED, @@ -339,7 +340,7 @@ def _expand_light_groups(hass: HomeAssistant, lights: List[str]) -> List[str]: def _supported_features(hass: HomeAssistant, light: str): state = hass.states.get(light) - supported_features = state.attributes["supported_features"] + supported_features = state.attributes[ATTR_SUPPORTED_FEATURES] return {key for key, value in _SUPPORT_OPTS.items() if supported_features & value} From eebe687be3980ae392072348b7461b49c1d9f9ef Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 27 Oct 2020 17:50:46 +0100 Subject: [PATCH 156/165] if lights is not specified in set_manual_control, select all lights --- .../adaptive_lighting/services.yaml | 2 +- .../components/adaptive_lighting/switch.py | 8 ++- .../adaptive_lighting/test_switch.py | 51 ++++++++++++------- 3 files changed, 40 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/services.yaml b/homeassistant/components/adaptive_lighting/services.yaml index 71d723e0de0871..10bfd2a87b0803 100644 --- a/homeassistant/components/adaptive_lighting/services.yaml +++ b/homeassistant/components/adaptive_lighting/services.yaml @@ -32,5 +32,5 @@ set_manual_control: description: "Whether to add ('true') or remove ('false') the light from the 'manual_control' list, default: true" example: true lights: - description: entity_id(s) of lights. + description: entity_id(s) of lights, if not specified, all lights in the switch are selected. example: light.bedroom_ceiling diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 406dc648d7cd07..497316bdb6acb2 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -204,7 +204,11 @@ async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall): async def handle_set_manual_control(switch: AdaptiveSwitch, service_call: ServiceCall): """Set or unset lights as 'manually controlled'.""" - all_lights = _expand_light_groups(switch.hass, service_call.data[CONF_LIGHTS]) + lights = service_call.data[CONF_LIGHTS] + if not lights: + all_lights = switch._lights + else: + all_lights = _expand_light_groups(switch.hass, lights) _LOGGER.debug( "Called 'adaptive_lighting.set_manual_control' service with '%s'", service_call.data, @@ -287,7 +291,7 @@ async def async_setup_entry( platform.async_register_entity_service( SERVICE_SET_MANUAL_CONTROL, { - vol.Required(CONF_LIGHTS): cv.entity_ids, + vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, vol.Optional(CONF_MANUAL_CONTROL, default=True): cv.boolean, }, handle_set_manual_control, diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index c3b2aebf726b49..8e1bdd4a25e2c1 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -414,6 +414,7 @@ async def test_manual_control(hass): """Test the 'manual control' tracking.""" switch, (light, *_) = await setup_lights_and_switch(hass) context = switch.create_context("test") # needs to be passed to update method + manual_control = switch.turn_on_off_listener.manual_control async def update(): await switch._update_attrs_and_maybe_adapt_lights(transition=0, context=context) @@ -438,14 +439,16 @@ async def turn_switch(state, entity_id): ) await hass.async_block_till_done() - async def change_manual_control(set_to): + async def change_manual_control( + set_to, extra_service_data={CONF_LIGHTS: [ENTITY_LIGHT]} + ): await hass.services.async_call( DOMAIN, SERVICE_SET_MANUAL_CONTROL, { ATTR_ENTITY_ID: switch.entity_id, CONF_MANUAL_CONTROL: set_to, - CONF_LIGHTS: [ENTITY_LIGHT], + **extra_service_data, }, blocking=True, ) @@ -460,42 +463,42 @@ def increased_color_temp(): # Nothing is manually controlled await update() - assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + assert not manual_control[ENTITY_LIGHT] # Call light.turn_on for ENTITY_LIGHT await turn_light(True, brightness=increased_brightness()) # Check that ENTITY_LIGHT is manually controlled - assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + assert manual_control[ENTITY_LIGHT] # Test adaptive_lighting.set_manual_control await change_manual_control(False) # Check that ENTITY_LIGHT is not manually controlled - assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + assert not manual_control[ENTITY_LIGHT] # Check that toggling light off to on resets manual control await change_manual_control(True) - assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + assert manual_control[ENTITY_LIGHT] await turn_light(False) await turn_light(True, brightness=increased_brightness()) - assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + assert not manual_control[ENTITY_LIGHT] # Check that toggling (sleep mode) switch resets manual control for entity_id in [ENTITY_SWITCH, ENTITY_SLEEP_MODE_SWITCH]: await change_manual_control(True) - assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + assert manual_control[ENTITY_LIGHT] await turn_switch(False, entity_id) await turn_switch(True, entity_id) - assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + assert not manual_control[ENTITY_LIGHT] # Check that when 'adapt_brightness' is off, changing the brightness # doesn't mark it as manually controlled but changing color_temp # does await turn_light(False) # reset manually controlled status await turn_light(True) - assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + assert not manual_control[ENTITY_LIGHT] await switch.adapt_brightness_switch.async_turn_off() await turn_light(True, brightness=increased_brightness()) - assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + assert not manual_control[ENTITY_LIGHT] await turn_light(True, color_temp=(light._ct + 100) % 500) - assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + assert manual_control[ENTITY_LIGHT] await switch.adapt_brightness_switch.async_turn_on() # turn on again # Check that when 'adapt_color' is off, changing the color @@ -503,12 +506,12 @@ def increased_color_temp(): # does await turn_light(False) # reset manually controlled status await turn_light(True) - assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + assert not manual_control[ENTITY_LIGHT] await switch.adapt_color_switch.async_turn_off() await turn_light(True, color_temp=increased_color_temp()) - assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + assert not manual_control[ENTITY_LIGHT] await turn_light(True, brightness=increased_brightness()) - assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + assert manual_control[ENTITY_LIGHT] # Check that when 'adapt_color' adapt_brightness are both off # nothing marks it as manually controlled @@ -516,13 +519,25 @@ def increased_color_temp(): await turn_light(True) await switch.adapt_color_switch.async_turn_off() await switch.adapt_brightness_switch.async_turn_off() - assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + assert not manual_control[ENTITY_LIGHT] await turn_light(True, color_temp=increased_color_temp()) await turn_light(True, brightness=increased_brightness()) await turn_light( - True, color_temp=increased_color_temp(), brightness=increased_brightness() + True, + color_temp=increased_color_temp(), + brightness=increased_brightness(), ) - assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + assert not manual_control[ENTITY_LIGHT] + # Turn switches on again + await switch.adapt_color_switch.async_turn_on() + await switch.adapt_brightness_switch.async_turn_on() + + # Check that when no lights are specified, all are reset + await change_manual_control(True, {CONF_LIGHTS: switch._lights}) + assert all([manual_control[eid] for eid in switch._lights]) + # do not pass "lights" so reset all + await change_manual_control(False, {}) + assert all([not manual_control[eid] for eid in switch._lights]) async def test_apply_service(hass): From 14036c03b9c5068ca8ed226b038b91c57cd2e70a Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 27 Oct 2020 20:37:57 +0100 Subject: [PATCH 157/165] add separate_turn_on_commands option for MQTT lights See https://github.com/basnijholt/adaptive-lighting/issues/29 --- .../components/adaptive_lighting/const.py | 5 ++ .../components/adaptive_lighting/strings.json | 1 + .../components/adaptive_lighting/switch.py | 25 +++++++--- .../adaptive_lighting/translations/de.json | 1 + .../adaptive_lighting/translations/en.json | 1 + .../adaptive_lighting/translations/sv.json | 1 + .../adaptive_lighting/test_switch.py | 48 +++++++++++++++---- 7 files changed, 66 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/const.py b/homeassistant/components/adaptive_lighting/const.py index ccfeffa8c92bd0..e1f2e3cb889c5c 100644 --- a/homeassistant/components/adaptive_lighting/const.py +++ b/homeassistant/components/adaptive_lighting/const.py @@ -24,6 +24,10 @@ CONF_MIN_COLOR_TEMP, DEFAULT_MIN_COLOR_TEMP = "min_color_temp", 2000 CONF_ONLY_ONCE, DEFAULT_ONLY_ONCE = "only_once", False CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR = "prefer_rgb_color", False +CONF_SEPARATE_TURN_ON_COMMANDS, DEFAULT_SEPARATE_TURN_ON_COMMANDS = ( + "separate_turn_on_commands", + False, +) CONF_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_BRIGHTNESS = "sleep_brightness", 1 CONF_SLEEP_COLOR_TEMP, DEFAULT_SLEEP_COLOR_TEMP = "sleep_color_temp", 1000 CONF_SUNRISE_OFFSET, DEFAULT_SUNRISE_OFFSET = "sunrise_offset", 0 @@ -74,6 +78,7 @@ def int_between(min_int, max_int): (CONF_ONLY_ONCE, DEFAULT_ONLY_ONCE, bool), (CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL, bool), (CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES, bool), + (CONF_SEPARATE_TURN_ON_COMMANDS, DEFAULT_SEPARATE_TURN_ON_COMMANDS, bool), ] diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json index fe6d0dd8da06ff..274569da474c24 100644 --- a/homeassistant/components/adaptive_lighting/strings.json +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -29,6 +29,7 @@ "min_color_temp": "min_color_temp, in Kelvin", "only_once": "only_once, only adapt the lights when turning them on", "prefer_rgb_color": "prefer_rgb_color, use 'rgb_color' over 'color_temp' when possible", + "separate_turn_on_commands": "separate_turn_on_commands, for each attribute (color, brightness, etc.) in 'light.turn_on', required for some lights.", "sleep_brightness": "sleep_brightness, in %", "sleep_color_temp": "sleep_color_temp, in Kelvin", "sunrise_offset": "sunrise_offset, in +/- seconds", diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 497316bdb6acb2..5a613a204a1b77 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -100,6 +100,7 @@ CONF_MIN_COLOR_TEMP, CONF_ONLY_ONCE, CONF_PREFER_RGB_COLOR, + CONF_SEPARATE_TURN_ON_COMMANDS, CONF_SLEEP_BRIGHTNESS, CONF_SLEEP_COLOR_TEMP, CONF_SUNRISE_OFFSET, @@ -206,7 +207,7 @@ async def handle_set_manual_control(switch: AdaptiveSwitch, service_call: Servic """Set or unset lights as 'manually controlled'.""" lights = service_call.data[CONF_LIGHTS] if not lights: - all_lights = switch._lights + all_lights = switch._lights # pylint: disable=protected-access else: all_lights = _expand_light_groups(switch.hass, lights) _LOGGER.debug( @@ -492,6 +493,7 @@ def __init__( self._interval = data[CONF_INTERVAL] self._only_once = data[CONF_ONLY_ONCE] self._prefer_rgb_color = data[CONF_PREFER_RGB_COLOR] + self._separate_turn_on_commands = data[CONF_SEPARATE_TURN_ON_COMMANDS] self._take_over_control = data[CONF_TAKE_OVER_CONTROL] self._transition = min( data[CONF_TRANSITION], self._interval.total_seconds() // 2 @@ -747,12 +749,21 @@ async def _adapt_light( context.id, ) self.turn_on_off_listener.last_service_data[light] = service_data - await self.hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - service_data, - context=context, - ) + if self._separate_turn_on_commands: + service_datas = [ + {ATTR_ENTITY_ID: light, key: value} + for key, value in service_data.items() + if key != ATTR_ENTITY_ID + ] + else: + service_datas = [service_data] + for service_data in service_datas: + await self.hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + service_data, + context=context, + ) async def _update_attrs_and_maybe_adapt_lights( self, diff --git a/homeassistant/components/adaptive_lighting/translations/de.json b/homeassistant/components/adaptive_lighting/translations/de.json index b8d06afd216cb5..dae5af4eeeb35d 100644 --- a/homeassistant/components/adaptive_lighting/translations/de.json +++ b/homeassistant/components/adaptive_lighting/translations/de.json @@ -29,6 +29,7 @@ "min_color_temp": "min_color_temp, minimale Farbtemperatur in Kelvin", "only_once": "only_once, passe die Lichter nur beim Einschalten an", "prefer_rgb_color": "prefer_rgb_color, nutze 'rgb_color' vor 'color_temp', wenn möglich", + "separate_turn_on_commands": "separate_turn_on_commands, for each attribute (color, brightness, etc.) in 'light.turn_on', required for some lights.", "sleep_brightness": "sleep_brightness, Schlafhelligkeit in %", "sleep_color_temp": "sleep_color_temp, Schlaffarbtemperaturin Kelvin", "sunrise_offset": "sunrise_offset, Sonnenaufgang Verschiebung in +/- seconds", diff --git a/homeassistant/components/adaptive_lighting/translations/en.json b/homeassistant/components/adaptive_lighting/translations/en.json index c2c31dbba8f3c2..ed1d205bb6e707 100644 --- a/homeassistant/components/adaptive_lighting/translations/en.json +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -29,6 +29,7 @@ "min_color_temp": "min_color_temp, in Kelvin", "only_once": "only_once, only adapt the lights when turning them on", "prefer_rgb_color": "prefer_rgb_color, use 'rgb_color' over 'color_temp' when possible", + "separate_turn_on_commands": "separate_turn_on_commands, for each attribute (color, brightness, etc.) in 'light.turn_on', required for some lights.", "sleep_brightness": "sleep_brightness, in %", "sleep_color_temp": "sleep_color_temp, in Kelvin", "sunrise_offset": "sunrise_offset, in +/- seconds", diff --git a/homeassistant/components/adaptive_lighting/translations/sv.json b/homeassistant/components/adaptive_lighting/translations/sv.json index ed28840248a30b..8c9c4bf7f64bbb 100644 --- a/homeassistant/components/adaptive_lighting/translations/sv.json +++ b/homeassistant/components/adaptive_lighting/translations/sv.json @@ -32,6 +32,7 @@ "min_color_temp": "min_color_temp, i Kelvin", "only_once": "only_once, Adaptivt justera endast ljuskällorna när de sätts från 'av' till 'på'", "prefer_rgb_color": "prefer_rgb_color, Använd 'rgb_color' över 'color_temp' om möjligt", + "separate_turn_on_commands": "separate_turn_on_commands, for each attribute (color, brightness, etc.) in 'light.turn_on', required for some lights.", "sleep_brightness": "sleep_brightness, i %", "sleep_color_temp": "sleep_color_temp, i Kelvin", "sunrise_offset": "sunrise_offset, i +/- sekunder", diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index 8e1bdd4a25e2c1..f202544e33ce58 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -14,6 +14,7 @@ CONF_INITIAL_TRANSITION, CONF_MANUAL_CONTROL, CONF_PREFER_RGB_COLOR, + CONF_SEPARATE_TURN_ON_COMMANDS, CONF_SUNRISE_OFFSET, CONF_SUNRISE_TIME, CONF_SUNSET_TIME, @@ -147,7 +148,7 @@ async def setup_lights(hass): return lights -async def setup_lights_and_switch(hass): +async def setup_lights_and_switch(hass, extra_conf=None): """Create switch and demo lights.""" # Setup demo lights and turn on lights_instances = await setup_lights(hass) @@ -174,6 +175,7 @@ async def setup_lights_and_switch(hass): CONF_TRANSITION: 0, CONF_DETECT_NON_HA_CHANGES: True, CONF_PREFER_RGB_COLOR: False, + **(extra_conf or {}), }, ) await hass.async_block_till_done() @@ -439,9 +441,9 @@ async def turn_switch(state, entity_id): ) await hass.async_block_till_done() - async def change_manual_control( - set_to, extra_service_data={CONF_LIGHTS: [ENTITY_LIGHT]} - ): + async def change_manual_control(set_to, extra_service_data=None): + if extra_service_data is None: + extra_service_data = {CONF_LIGHTS: [ENTITY_LIGHT]} await hass.services.async_call( DOMAIN, SERVICE_SET_MANUAL_CONTROL, @@ -794,20 +796,20 @@ async def test_restore_off_state(hass, state): elif state is None: assert switch.is_on - for sw, initial_state in [ + for _switch, initial_state in [ (switch.sleep_mode_switch, False), (switch.adapt_brightness_switch, True), (switch.adapt_color_switch, True), ]: if state == STATE_ON: - assert sw.is_on + assert _switch.is_on elif state == STATE_OFF: - assert not sw.is_on + assert not _switch.is_on elif state is None: if initial_state: - assert sw.is_on + assert _switch.is_on else: - assert not sw.is_on + assert not _switch.is_on @pytest.mark.xfail(reason="Offset is larger than half a day") @@ -839,3 +841,31 @@ async def test_async_update_at_interval(hass): """Test '_async_update_at_interval' method.""" _, switch = await setup_switch(hass, {}) await switch._async_update_at_interval() + + +async def test_separate_turn_on_commands(hass): + """Test 'separate_turn_on_commands' argument.""" + switch, (light, *_) = await setup_lights_and_switch( + hass, {CONF_SEPARATE_TURN_ON_COMMANDS: True} + ) + # We just turn sleep mode on and off which should change the + # brightness and color. We don't test whether the number are exactly + # what we expect because we do this in other tests already, we merely + # check whether the brightness and color_temp change. + context = switch.create_context("test") # needs to be passed to update method + brightness = light.brightness + color_temp = light.color_temp + await switch.sleep_mode_switch.async_turn_on() + await switch._update_attrs_and_maybe_adapt_lights(context=context) + await hass.async_block_till_done() + sleep_brightness = light.brightness + sleep_color_temp = light.color_temp + assert sleep_brightness != brightness + assert sleep_color_temp != color_temp + await switch.sleep_mode_switch.async_turn_off() + await switch._update_attrs_and_maybe_adapt_lights(context=context) + await hass.async_block_till_done() + brightness = light.brightness + color_temp = light.color_temp + assert sleep_brightness != brightness + assert sleep_color_temp != color_temp From 011cc3e4cb31fd08481861e41d108a82802469fb Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 30 Oct 2020 07:04:41 +0100 Subject: [PATCH 158/165] improve separate_turn_on_commands option --- .../components/adaptive_lighting/switch.py | 49 +++++++++++++------ .../adaptive_lighting/test_switch.py | 7 ++- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 5a613a204a1b77..bb1c5cdad46af3 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -184,6 +184,28 @@ def is_our_context(context: Optional[Context]) -> bool: return context.id.startswith(_DOMAIN_SHORT) +def _copy_and_pop(dct, keys): + """Copy a dictionary and remove 'keys' if they exist.""" + copy = dct.copy() + for key in keys: + copy.pop(key, None) + return copy + + +def _split_service_data(service_data, adapt_brightness, adapt_color): + """Split service_data into two dictionaries (for color and brightness).""" + service_datas = [] + if adapt_color: + service_datas.append( + _copy_and_pop(service_data, (ATTR_WHITE_VALUE, ATTR_BRIGHTNESS)) + ) + if adapt_brightness: + service_datas.append( + _copy_and_pop(service_data, (ATTR_RGB_COLOR, ATTR_COLOR_TEMP)) + ) + return service_datas + + async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall): """Handle the entity service apply.""" hass = switch.hass @@ -741,23 +763,20 @@ async def _adapt_light( ) ): return - _LOGGER.debug( - "%s: Scheduling 'light.turn_on' with the following 'service_data': %s" - " with context.id='%s'", - self._name, - service_data, - context.id, - ) self.turn_on_off_listener.last_service_data[light] = service_data - if self._separate_turn_on_commands: - service_datas = [ - {ATTR_ENTITY_ID: light, key: value} - for key, value in service_data.items() - if key != ATTR_ENTITY_ID - ] - else: - service_datas = [service_data] + service_datas = ( + _split_service_data(service_data, adapt_brightness, adapt_color) + if self._separate_turn_on_commands + else [service_data] + ) for service_data in service_datas: + _LOGGER.debug( + "%s: Scheduling 'light.turn_on' with the following 'service_data': %s" + " with context.id='%s'", + self._name, + service_data, + context.id, + ) await self.hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py index f202544e33ce58..68f76fdaef5e83 100644 --- a/tests/components/adaptive_lighting/test_switch.py +++ b/tests/components/adaptive_lighting/test_switch.py @@ -13,6 +13,7 @@ CONF_DETECT_NON_HA_CHANGES, CONF_INITIAL_TRANSITION, CONF_MANUAL_CONTROL, + CONF_MIN_COLOR_TEMP, CONF_PREFER_RGB_COLOR, CONF_SEPARATE_TURN_ON_COMMANDS, CONF_SUNRISE_OFFSET, @@ -175,6 +176,7 @@ async def setup_lights_and_switch(hass, extra_conf=None): CONF_TRANSITION: 0, CONF_DETECT_NON_HA_CHANGES: True, CONF_PREFER_RGB_COLOR: False, + CONF_MIN_COLOR_TEMP: 2500, # to not coincide with sleep_color_temp **(extra_conf or {}), }, ) @@ -843,10 +845,11 @@ async def test_async_update_at_interval(hass): await switch._async_update_at_interval() -async def test_separate_turn_on_commands(hass): +@pytest.mark.parametrize("separate_turn_on_commands", (True, False)) +async def test_separate_turn_on_commands(hass, separate_turn_on_commands): """Test 'separate_turn_on_commands' argument.""" switch, (light, *_) = await setup_lights_and_switch( - hass, {CONF_SEPARATE_TURN_ON_COMMANDS: True} + hass, {CONF_SEPARATE_TURN_ON_COMMANDS: separate_turn_on_commands} ) # We just turn sleep mode on and off which should change the # brightness and color. We don't test whether the number are exactly From eff9bcc3c95ceaf229ca974909c4788b238405b9 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 30 Oct 2020 07:04:41 +0100 Subject: [PATCH 159/165] only adapt lights if switch is on and set_manual_control is called --- .../components/adaptive_lighting/switch.py | 35 ++++++++----------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index bb1c5cdad46af3..9ec21958c19026 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -184,25 +184,19 @@ def is_our_context(context: Optional[Context]) -> bool: return context.id.startswith(_DOMAIN_SHORT) -def _copy_and_pop(dct, keys): - """Copy a dictionary and remove 'keys' if they exist.""" - copy = dct.copy() - for key in keys: - copy.pop(key, None) - return copy - - def _split_service_data(service_data, adapt_brightness, adapt_color): """Split service_data into two dictionaries (for color and brightness).""" service_datas = [] if adapt_color: - service_datas.append( - _copy_and_pop(service_data, (ATTR_WHITE_VALUE, ATTR_BRIGHTNESS)) - ) + service_data_color = service_data.copy() + service_data_color.pop(ATTR_WHITE_VALUE, None) + service_data_color.pop(ATTR_BRIGHTNESS, None) + service_datas.append(service_data_color) if adapt_brightness: - service_datas.append( - _copy_and_pop(service_data, (ATTR_RGB_COLOR, ATTR_COLOR_TEMP)) - ) + service_data_brightness = service_data.copy() + service_data_brightness.pop(ATTR_RGB_COLOR, None) + service_data_brightness.pop(ATTR_COLOR_TEMP, None) + service_datas.append(service_data_brightness) return service_datas @@ -243,12 +237,13 @@ async def handle_set_manual_control(switch: AdaptiveSwitch, service_call: Servic else: switch.turn_on_off_listener.reset(*all_lights) # pylint: disable=protected-access - await switch._adapt_lights( - all_lights, - transition=switch._initial_transition, - force=True, - context=switch.create_context("service"), - ) + if switch.is_on: + await switch._update_attrs_and_maybe_adapt_lights( + all_lights, + transition=switch._initial_transition, + force=True, + context=switch.create_context("service"), + ) @callback From d0ed08926c7a8e55c9b06c54ae4ea97f5c68c250 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 19 Dec 2020 13:38:29 +0100 Subject: [PATCH 160/165] add switch entity_id to adaptive_lighting.manual_control event --- .../components/adaptive_lighting/switch.py | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 9ec21958c19026..921a228a561877 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -233,26 +233,30 @@ async def handle_set_manual_control(switch: AdaptiveSwitch, service_call: Servic if service_call.data[CONF_MANUAL_CONTROL]: for light in all_lights: switch.turn_on_off_listener.manual_control[light] = True - _fire_manual_control_event(switch.hass, light, service_call.context) + _fire_manual_control_event(switch, light, service_call.context) else: switch.turn_on_off_listener.reset(*all_lights) # pylint: disable=protected-access - if switch.is_on: - await switch._update_attrs_and_maybe_adapt_lights( - all_lights, - transition=switch._initial_transition, - force=True, - context=switch.create_context("service"), - ) + await switch._adapt_lights( + all_lights, + transition=switch._initial_transition, + force=True, + context=switch.create_context("service"), + ) @callback def _fire_manual_control_event( - hass: HomeAssistant, light: str, context: Context, is_async=True + switch: AdaptiveSwitch, light: str, context: Context, is_async=True ): """Fire an event that 'light' is marked as manual_control.""" + hass = switch.hass fire = hass.bus.async_fire if is_async else hass.bus.fire - fire(f"{DOMAIN}.manual_control", {ATTR_ENTITY_ID: light}, context=context) + fire( + f"{DOMAIN}.manual_control", + {ATTR_ENTITY_ID: light, SWITCH_DOMAIN: switch.entity_id}, + context=context, + ) async def async_setup_entry( @@ -751,6 +755,7 @@ async def _adapt_light( and self._detect_non_ha_changes and not force and await self.turn_on_off_listener.significant_change( + self, light, adapt_brightness, adapt_color, @@ -825,6 +830,7 @@ async def _adapt_lights( if ( self._take_over_control and self.turn_on_off_listener.is_manually_controlled( + self, light, force, self.adapt_brightness_switch.is_on, @@ -1231,6 +1237,7 @@ async def state_changed_event_listener(self, event: Event) -> None: def is_manually_controlled( self, + switch: AdaptiveSwitch, light: str, force: bool, adapt_brightness: bool, @@ -1255,7 +1262,7 @@ def is_manually_controlled( # Light was already on and 'light.turn_on' was not called by # the adaptive_lighting integration. manual_control = self.manual_control[light] = True - _fire_manual_control_event(self.hass, light, turn_on_event.context) + _fire_manual_control_event(switch, light, 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" @@ -1268,6 +1275,7 @@ def is_manually_controlled( async def significant_change( self, + switch: AdaptiveSwitch, light: str, adapt_brightness: bool, adapt_color: bool, @@ -1326,7 +1334,7 @@ async def significant_change( # N times in a row. We do this because sometimes a state changes # happens only *after* a new update interval has already started. self.manual_control[light] = True - _fire_manual_control_event(self.hass, light, context, is_async=False) + _fire_manual_control_event(switch, light, context, is_async=False) else: if n_changes > 1: _LOGGER.debug( From f1bee58127b1ddb45d2e228c1a0cbc6a1fc6a1e8 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 19 Dec 2020 14:08:25 +0100 Subject: [PATCH 161/165] log fire adaptive_lighting.manual_control event --- homeassistant/components/adaptive_lighting/switch.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 921a228a561877..4fd2bf8982dd5a 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -252,6 +252,11 @@ def _fire_manual_control_event( """Fire an event that 'light' is marked as manual_control.""" hass = switch.hass fire = hass.bus.async_fire if is_async else hass.bus.fire + _LOGGER.debug( + "'adaptive_lighting.manual_control' event fired for %s for light %s", + switch.entity_id, + light, + ) fire( f"{DOMAIN}.manual_control", {ATTR_ENTITY_ID: light, SWITCH_DOMAIN: switch.entity_id}, From 3874641db2b80f10705ad379d619341ee8d5024a Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 19 Dec 2020 14:34:03 +0100 Subject: [PATCH 162/165] wait between turn_on commands for transition/2 if separate_turn_on_commands is used --- .../components/adaptive_lighting/switch.py | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 4fd2bf8982dd5a..2575a3e557fe12 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -186,6 +186,10 @@ def is_our_context(context: Optional[Context]) -> bool: def _split_service_data(service_data, adapt_brightness, adapt_color): """Split service_data into two dictionaries (for color and brightness).""" + transition = service_data.get(ATTR_TRANSITION) + if transition is not None: + # Split the transition over both commands + service_data[ATTR_TRANSITION] /= 2 service_datas = [] if adapt_color: service_data_color = service_data.copy() @@ -769,12 +773,8 @@ async def _adapt_light( ): return self.turn_on_off_listener.last_service_data[light] = service_data - service_datas = ( - _split_service_data(service_data, adapt_brightness, adapt_color) - if self._separate_turn_on_commands - else [service_data] - ) - for service_data in service_datas: + + async def turn_on(service_data): _LOGGER.debug( "%s: Scheduling 'light.turn_on' with the following 'service_data': %s" " with context.id='%s'", @@ -789,6 +789,18 @@ async def _adapt_light( context=context, ) + if not self._separate_turn_on_commands: + await turn_on(service_data) + else: + service_data_color, service_data_brightness = _split_service_data( + service_data, adapt_brightness, adapt_color + ) + await turn_on(service_data_color) + transition = service_data_color.get(ATTR_TRANSITION) + if transition is not None: + await asyncio.sleep(transition) + await turn_on(service_data_brightness) + async def _update_attrs_and_maybe_adapt_lights( self, lights: Optional[List[str]] = None, From 42d5d5315214877a27cd1012b4aa6802bf4fb017 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 19 Dec 2020 16:35:05 +0100 Subject: [PATCH 163/165] deal with 'light.turn_on' with multiple lights, which appear as csv list Solves the bug reported in https://github.com/basnijholt/adaptive-lighting/issues/39 --- homeassistant/components/adaptive_lighting/switch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 2575a3e557fe12..597928f3c295f3 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -1175,7 +1175,7 @@ async def turn_on_off_event_listener(self, event: Event) -> None: service = event.data[ATTR_SERVICE] service_data = event.data[ATTR_SERVICE_DATA] - entity_ids = cv.ensure_list(service_data[ATTR_ENTITY_ID]) + entity_ids = cv.ensure_list_csv(service_data[ATTR_ENTITY_ID]) if not any(eid in self.lights for eid in entity_ids): return From afe8ce6df94ca661b1eb80c5b3b5bd95ae6f590b Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 27 Dec 2020 13:08:13 +0100 Subject: [PATCH 164/165] fix bug when resetting switch and it's off --- .../components/adaptive_lighting/switch.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 597928f3c295f3..0869accf02b613 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -241,12 +241,13 @@ async def handle_set_manual_control(switch: AdaptiveSwitch, service_call: Servic else: switch.turn_on_off_listener.reset(*all_lights) # pylint: disable=protected-access - await switch._adapt_lights( - all_lights, - transition=switch._initial_transition, - force=True, - context=switch.create_context("service"), - ) + if switch.is_on: + await switch._update_attrs_and_maybe_adapt_lights( + all_lights, + transition=switch._initial_transition, + force=True, + context=switch.create_context("service"), + ) @callback From 5bf889a0c83ef33431d2fb41878cd1a5ec1530f7 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 27 Dec 2020 13:34:20 +0100 Subject: [PATCH 165/165] separate_turn_on_commands fix when _split_service_data returns 1 item --- .../components/adaptive_lighting/switch.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/adaptive_lighting/switch.py b/homeassistant/components/adaptive_lighting/switch.py index 0869accf02b613..03ad8d937de089 100644 --- a/homeassistant/components/adaptive_lighting/switch.py +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -793,14 +793,16 @@ async def turn_on(service_data): if not self._separate_turn_on_commands: await turn_on(service_data) else: - service_data_color, service_data_brightness = _split_service_data( + # Could be a list of length 1 or 2 + service_datas = _split_service_data( service_data, adapt_brightness, adapt_color ) - await turn_on(service_data_color) - transition = service_data_color.get(ATTR_TRANSITION) - if transition is not None: - await asyncio.sleep(transition) - await turn_on(service_data_brightness) + await turn_on(service_datas[0]) + if len(service_datas) == 2: + transition = service_datas[0].get(ATTR_TRANSITION) + if transition is not None: + await asyncio.sleep(transition) + await turn_on(service_datas[1]) async def _update_attrs_and_maybe_adapt_lights( self,