diff --git a/CODEOWNERS b/CODEOWNERS index e5d67234f6f887..4b4fc9a3e34a46 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/* @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..786daae245c633 --- /dev/null +++ b/homeassistant/components/adaptive_lighting/__init__.py @@ -0,0 +1,90 @@ +"""Adaptive Lighting integration in Home-Assistant.""" +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 ( + _DOMAIN_SCHEMA, + ATTR_TURN_ON_OFF_LISTENER, + CONF_NAME, + DOMAIN, + UNDO_UPDATE_LISTENER, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["switch"] + + +def _all_unique_names(value): + """Validate that all entities 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: 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={CONF_SOURCE: SOURCE_IMPORT}, data=entry + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Set up the component.""" + data = hass.data.setdefault(DOMAIN, {}) + + undo_listener = config_entry.add_update_listener(async_update_options) + 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) + ) + + 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 = await hass.config_entries.async_forward_entry_unload( + config_entry, "switch" + ) + data = hass.data[DOMAIN] + data[config_entry.entry_id][UNDO_UPDATE_LISTENER]() + 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 not data: + hass.data.pop(DOMAIN) + + 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..8fa74f5c69cc7e --- /dev/null +++ b/homeassistant/components/adaptive_lighting/config_flow.py @@ -0,0 +1,109 @@ +"""Config flow for Adaptive Lighting integration.""" +import logging + +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 + +from .const import ( # pylint: disable=unused-import + CONF_LIGHTS, + DOMAIN, + EXTRA_VALIDATION, + NONE_STR, + VALIDATION_TUPLES, +) +from .switch import _supported_features + +_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[CONF_NAME]) + self._abort_if_unique_id_configured() + 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(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[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[CONF_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 + value = user_input.get(key) + try: + 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=None) + 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 = [ + 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: + 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..e1f2e3cb889c5c --- /dev/null +++ b/homeassistant/components/adaptive_lighting/const.py @@ -0,0 +1,127 @@ +"""Constants for the Adaptive Lighting integration.""" +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_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 +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", 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 +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", 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" +SERVICE_APPLY = "apply" +CONF_TURN_ON_LIGHTS = "turn_on_lights" + +TURNING_OFF_DELAY = 5 + + +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 = [ + (CONF_LIGHTS, DEFAULT_LIGHTS, cv.entity_ids), + (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), + (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_MAX_COLOR_TEMP, DEFAULT_MAX_COLOR_TEMP, int_between(1000, 10000)), + (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), + (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), + (CONF_SEPARATE_TURN_ON_COMMANDS, DEFAULT_SEPARATE_TURN_ON_COMMANDS, bool), +] + + +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() + + +# conf_option: (validator, coerce) tuples +# these validators cannot be serialized but can be serialized when coerced by coerce. +EXTRA_VALIDATION = { + CONF_INTERVAL: (cv.time_period, timedelta_as_int), + 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..1346158421d90f --- /dev/null +++ b/homeassistant/components/adaptive_lighting/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "adaptive_lighting", + "name": "Adaptive Lighting", + "documentation": "https://www.home-assistant.io/integrations/adaptive_lighting", + "config_flow": true, + "dependencies": [], + "codeowners": ["@basnijholt"], + "requirements": [] +} diff --git a/homeassistant/components/adaptive_lighting/services.yaml b/homeassistant/components/adaptive_lighting/services.yaml new file mode 100644 index 00000000000000..10bfd2a87b0803 --- /dev/null +++ b/homeassistant/components/adaptive_lighting/services.yaml @@ -0,0 +1,36 @@ +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 + adapt_brightness: + description: "Adapt the 'brightness', default: true" + example: 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 +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 + 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, if not specified, all lights in the switch are selected. + example: light.bedroom_ceiling diff --git a/homeassistant/components/adaptive_lighting/strings.json b/homeassistant/components/adaptive_lighting/strings.json new file mode 100644 index 00000000000000..274569da474c24 --- /dev/null +++ b/homeassistant/components/adaptive_lighting/strings.json @@ -0,0 +1,49 @@ +{ + "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": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "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", + "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", + "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", + "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", + "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 (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" + } + } + }, + "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..03ad8d937de089 --- /dev/null +++ b/homeassistant/components/adaptive_lighting/switch.py @@ -0,0 +1,1461 @@ +"""Switch for the Adaptive Lighting integration.""" +from __future__ import annotations + +import asyncio +import bisect +from collections import defaultdict +from copy import deepcopy +from dataclasses import dataclass +import datetime +from datetime import timedelta +import functools +import hashlib +import logging +import math +from typing import Any, Dict, List, Optional, Tuple, Union + +import astral +import voluptuous as vol + +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, + SUPPORT_COLOR_TEMP, + SUPPORT_TRANSITION, + SUPPORT_WHITE_VALUE, + VALID_TRANSITION, + 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, + ATTR_SERVICE, + ATTR_SERVICE_DATA, + ATTR_SUPPORTED_FEATURES, + CONF_NAME, + EVENT_CALL_SERVICE, + EVENT_HOMEASSISTANT_STARTED, + EVENT_STATE_CHANGED, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + SUN_EVENT_SUNRISE, + SUN_EVENT_SUNSET, +) +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 ( + async_track_state_change_event, + 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 ( + ADAPT_BRIGHTNESS_SWITCH, + ADAPT_COLOR_SWITCH, + ATTR_ADAPT_BRIGHTNESS, + ATTR_ADAPT_COLOR, + ATTR_TURN_ON_OFF_LISTENER, + CONF_DETECT_NON_HA_CHANGES, + CONF_INITIAL_TRANSITION, + CONF_INTERVAL, + CONF_LIGHTS, + CONF_MANUAL_CONTROL, + CONF_MAX_BRIGHTNESS, + CONF_MAX_COLOR_TEMP, + CONF_MIN_BRIGHTNESS, + 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, + CONF_SUNRISE_TIME, + CONF_SUNSET_OFFSET, + CONF_SUNSET_TIME, + CONF_TAKE_OVER_CONTROL, + CONF_TRANSITION, + CONF_TURN_ON_LIGHTS, + DOMAIN, + EXTRA_VALIDATION, + ICON, + SERVICE_APPLY, + SERVICE_SET_MANUAL_CONTROL, + SLEEP_MODE_SWITCH, + SUN_EVENT_MIDNIGHT, + SUN_EVENT_NOON, + TURNING_OFF_DELAY, + VALIDATION_TUPLES, + replace_none_str, +) + +_SUPPORT_OPTS = { + "brightness": SUPPORT_BRIGHTNESS, + "white_value": SUPPORT_WHITE_VALUE, + "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) + +# 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_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_XY_COLOR, +} + +BRIGHTNESS_ATTRS = { + ATTR_BRIGHTNESS, + ATTR_WHITE_VALUE, + 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" + + +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.""" + # 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: + """Check whether this integration created 'context'.""" + if context is None: + return False + return context.id.startswith(_DOMAIN_SHORT) + + +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() + 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_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 + + +async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall): + """Handle the entity service apply.""" + hass = switch.hass + data = service_call.data + all_lights = _expand_light_groups(hass, data[CONF_LIGHTS]) + switch.turn_on_off_listener.lights.update(all_lights) + + for light in all_lights: + if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light): + await switch._adapt_light( # pylint: disable=protected-access + light, + data[CONF_TRANSITION], + data[ATTR_ADAPT_BRIGHTNESS], + data[ATTR_ADAPT_COLOR], + data[CONF_PREFER_RGB_COLOR], + force=True, + ) + + +async def handle_set_manual_control(switch: AdaptiveSwitch, service_call: ServiceCall): + """Set or unset lights as 'manually controlled'.""" + lights = service_call.data[CONF_LIGHTS] + if not lights: + all_lights = switch._lights # pylint: disable=protected-access + else: + all_lights = _expand_light_groups(switch.hass, lights) + _LOGGER.debug( + "Called 'adaptive_lighting.set_manual_control' service with '%s'", + service_call.data, + ) + 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, 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"), + ) + + +@callback +def _fire_manual_control_event( + 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 + _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}, + context=context, + ) + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: bool +): + """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) + turn_on_off_listener = data[ATTR_TURN_ON_OFF_LISTENER] + + 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, adapt_color_switch, adapt_brightness_switch], + update_before_add=True, + ) + + # 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, # pylint: disable=protected-access + ): 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, + ) + + platform.async_register_entity_service( + SERVICE_SET_MANUAL_CONTROL, + { + vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, + vol.Optional(CONF_MANUAL_CONTROL, default=True): cv.boolean, + }, + handle_set_manual_control, + ) + + +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) + 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_value, _) in EXTRA_VALIDATION.items(): + value = data.get(key) + if value is not None: + data[key] = validate_value(value) # Fix the types of the inputs + return data + + +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 + + 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: 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: + _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"] + turn_on_off_listener.lights.discard(light) + all_lights.update(group) + _LOGGER.debug("Expanded %s to %s", light, group) + else: + all_lights.add(light) + return list(all_lights) + + +def _supported_features(hass: HomeAssistant, light: str): + state = hass.states.get(light) + supported_features = state.attributes[ATTR_SUPPORTED_FEATURES] + 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], + new_attributes: Dict[str, Any], + adapt_brightness: bool, + adapt_color: bool, + context: Context, +) -> bool: + 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, + ) + 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 + 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, + ) + return True + + if ( + adapt_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] + 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 + ) + 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, + ) + return True + return False + + +class AdaptiveSwitch(SwitchEntity, RestoreEntity): + """Representation of a Adaptive Lighting switch.""" + + def __init__( + self, + hass, + 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._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] + 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 + ) + + 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._state = None + + # 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] = {} + # 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] = {} + + # Set and unset tracker in async_turn_on and async_turn_off + self.remove_listeners = [] + _LOGGER.debug( + "%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, + data, + ) + + @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 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.""" + if self.hass.is_running: + await self._setup_listeners() + else: + self.hass.bus.async_listen_once( + 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 + 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 + + 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) + self._lights = list(all_lights) + + 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) + return + + assert not self.remove_listeners + + 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_mode_switch_state_event, + ) + + self.remove_listeners.extend([remove_interval, 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) + + def _remove_listeners(self) -> None: + while self.remove_listeners: + remove_listener = self.remove_listeners.pop() + remove_listener() + + @property + def icon(self) -> str: + """Icon to use in the frontend, if any.""" + return self._icon + + @property + 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} + manual_control = [ + light + for light in self._lights + if self.turn_on_off_listener.manual_control.get(light) + ] + 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.""" + # 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' + # '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 + return context + + async def async_turn_on( # pylint: disable=arguments-differ + self, adapt_lights: bool = True + ) -> None: + """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 + self.turn_on_off_listener.reset(*self._lights) + await self._setup_listeners() + if adapt_lights: + await self._update_attrs_and_maybe_adapt_lights( + transition=self._initial_transition, + force=True, + context=self.create_context("turn_on"), + ) + + async def async_turn_off(self, **kwargs) -> None: + """Turn off adaptive lighting.""" + if not self.is_on: + return + self._state = False + self._remove_listeners() + 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, context=self.create_context("interval") + ) + + async def _adapt_light( + self, + light: str, + 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: + 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) + + if transition is None: + transition = self._transition + if adapt_brightness is None: + 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 + + if "brightness" in features and adapt_brightness: + 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 + 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 = 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_color: + service_data[ATTR_RGB_COLOR] = self._settings["rgb_color"] + + context = context or self.create_context("adapt_lights") + if ( + self._take_over_control + and self._detect_non_ha_changes + and not force + and await self.turn_on_off_listener.significant_change( + self, + light, + adapt_brightness, + adapt_color, + context, + ) + ): + return + self.turn_on_off_listener.last_service_data[light] = service_data + + async def turn_on(service_data): + _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, + service_data, + context=context, + ) + + if not self._separate_turn_on_commands: + await turn_on(service_data) + else: + # Could be a list of length 1 or 2 + service_datas = _split_service_data( + service_data, adapt_brightness, adapt_color + ) + 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, + lights: Optional[List[str]] = None, + transition: Optional[int] = None, + 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, + context.id, + ) + assert self.is_on + self._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 + if (self._only_once and not force) or not lights: + return + await self._adapt_lights(lights, transition, force, context) + + async def _adapt_lights( + self, + lights: List[str], + transition: Optional[int], + 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, + lights, + transition, + force, + context.id, + ) + for light in lights: + if not is_on(self.hass, light): + continue + if ( + self._take_over_control + and self.turn_on_off_listener.is_manually_controlled( + self, + light, + force, + self.adapt_brightness_switch.is_on, + self.adapt_color_switch.is_on, + ) + ): + _LOGGER.debug( + "%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) + + async def _sleep_mode_switch_state_event(self, event: Event) -> None: + if not match_switch_state_event(event, (STATE_ON, STATE_OFF)): + return + _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("sleep"), + ) + + 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") + if ( + old_state is not None + and old_state.state == STATE_OFF + and new_state is not None + and new_state.state == STATE_ON + ): + _LOGGER.debug( + "%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, reset_manual_control=False) + # 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() + async with lock: + if await self.turn_on_off_listener.maybe_cancel_adjusting( + entity_id, + off_to_on_event=event, + on_to_off_event=self._on_to_off_event.get(entity_id), + ): + # Stop if a rapid 'off' → 'on' → 'off' happens. + _LOGGER.debug( + "%s: Cancelling adjusting lights for %s", self._name, entity_id + ) + return + + await self._update_attrs_and_maybe_adapt_lights( + lights=[entity_id], + transition=self._initial_transition, + force=True, + context=self.create_context("light_event"), + ) + elif ( + old_state is not None + and old_state.state == STATE_ON + and new_state is not None + and new_state.state == STATE_OFF + ): + # Tracks 'off' → 'on' state changes + self._on_to_off_event[entity_id] = event + self.turn_on_off_listener.reset(entity_id) + + +class SimpleSwitch(SwitchEntity, RestoreEntity): + """Representation of a Adaptive Lighting switch.""" + + def __init__( + self, which: str, initial_state: bool, hass: HomeAssistant, config_entry + ): + """Initialize the Adaptive Lighting switch.""" + self.hass = hass + data = validate(config_entry) + self._icon = ICON + self._state = None + self._which = which + 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 + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def unique_id(self): + """Return the unique ID of entity.""" + return self._unique_id + + @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() + _LOGGER.debug("%s: last state is %s", self._name, last_state) + 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() + + async def async_turn_on(self, **kwargs) -> None: + """Turn on adaptive lighting sleep mode.""" + self._state = True + + async def async_turn_off(self, **kwargs) -> 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.""" + + 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(date, time) + utc_time = self.time_zone.localize(date_time).astimezone(dt_util.UTC) + return utc_time + + 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, + "sun_position": percent, + } + + +class TurnOnOffListener: + """Track 'light.turn_off' and 'light.turn_on' service calls.""" + + def __init__(self, hass: HomeAssistant): + """Initialize the TurnOnOffListener that is shared among all switches.""" + self.hass = hass + self.lights = set() + + # Tracks 'light.turn_off' service calls + self.turn_off_event: Dict[str, Event] = {} + # Tracks 'light.turn_on' service calls + self.turn_on_event: Dict[str, Event] = {} + # 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.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 + 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 = 2 + + 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_manual_control=True) -> None: + """Reset the 'manual_control' status of the lights.""" + for light in lights: + 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 + + 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: + return + + service = event.data[ATTR_SERVICE] + service_data = event.data[ATTR_SERVICE_DATA] + entity_ids = cv.ensure_list_csv(service_data[ATTR_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 with context.id='%s'", + entity_ids, + transition, + event.context.id, + ) + for eid in entity_ids: + self.turn_off_event[eid] = event + self.reset(eid) + + elif service == SERVICE_TURN_ON: + _LOGGER.debug( + "Detected an 'light.turn_on('%s')' event with context.id='%s'", + entity_ids, + event.context.id, + ) + 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 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: + return + + new_state = event.data.get("new_state") + 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 + # `new_state=dict(brightness=100, ...)`. However, after polling the light + # could still only be `new_state=dict(brightness=50, ...)`. + # 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 + # 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' is already in 'self.last_state_change' (%s)" + " adding this state also", + entity_id, + 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, + switch: AdaptiveSwitch, + 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) + if manual_control: + # 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 not is_our_context(turn_on_event.context) + and not force + ): + 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(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" + " 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( + self, + switch: AdaptiveSwitch, + light: str, + adapt_brightness: bool, + adapt_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 + 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_state_change: + return False + old_states: List[State] = self.last_state_change[light] + await self.hass.helpers.entity_component.async_update_entity(light) + 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=adapt_color, + context=context, + ) + for index, old_state in enumerate(old_states): + changed = compare_to(old_attributes=old_state.attributes) + if not changed: + _LOGGER.debug( + "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, + ) + + 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, 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.manual_control[light] = True + _fire_manual_control_event(switch, light, context, is_async=False) + 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( + 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. + + 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 (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. + """ + 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 + + turn_off_event = self.turn_off_event.get(entity_id) + 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 + + 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 ( + 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=... + ): + # State change 'on' → 'off' and 'light.turn_off(..., transition=...)' come + # from the same event, so wait at least the 'turn_off' transition time. + delay = max(transition, TURNING_OFF_DELAY) + else: + # State change 'off' → 'on' happened because the light state was set. + # Possibly because of polling. + delay = TURNING_OFF_DELAY + + delta_time = (dt_util.utcnow() - on_to_off_event.time_fired).total_seconds() + 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) 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("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 + # specified time in the 'turn_off' service. + 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 + + if transition is not None: + # 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 + + # 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 diff --git a/homeassistant/components/adaptive_lighting/translations/de.json b/homeassistant/components/adaptive_lighting/translations/de.json new file mode 100644 index 00000000000000..dae5af4eeeb35d --- /dev/null +++ b/homeassistant/components/adaptive_lighting/translations/de.json @@ -0,0 +1,49 @@ +{ + "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", + "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", + "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 new file mode 100644 index 00000000000000..ed1d205bb6e707 --- /dev/null +++ b/homeassistant/components/adaptive_lighting/translations/en.json @@ -0,0 +1,49 @@ +{ + "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": "Device 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", + "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", + "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", + "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", + "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 (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 >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" + } + } + }, + "error": { + "option_error": "Invalid option" + } + } +} diff --git a/homeassistant/components/adaptive_lighting/translations/sv.json b/homeassistant/components/adaptive_lighting/translations/sv.json new file mode 100644 index 00000000000000..8c9c4bf7f64bbb --- /dev/null +++ b/homeassistant/components/adaptive_lighting/translations/sv.json @@ -0,0 +1,52 @@ +{ + "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", + "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", + "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" + } + } +} 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", 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.""" 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..53901cf98aee1f --- /dev/null +++ b/tests/components/adaptive_lighting/test_config_flow.py @@ -0,0 +1,131 @@ +"""Test Adaptive Lighting config flow.""" +from homeassistant import data_entry_flow +from homeassistant.components.adaptive_lighting.const import ( + CONF_SUNRISE_TIME, + CONF_SUNSET_TIME, + DEFAULT_NAME, + DOMAIN, + NONE_STR, + VALIDATION_TUPLES, +) +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_NAME + +from tests.common import MockConfigEntry + +DEFAULT_DATA = {key: default for key, default, _ in VALIDATION_TUPLES} + + +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 + + +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] = "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): + """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, + ) + + +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_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 diff --git a/tests/components/adaptive_lighting/test_switch.py b/tests/components/adaptive_lighting/test_switch.py new file mode 100644 index 00000000000000..68f76fdaef5e83 --- /dev/null +++ b/tests/components/adaptive_lighting/test_switch.py @@ -0,0 +1,874 @@ +"""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 ( + ADAPT_BRIGHTNESS_SWITCH, + ADAPT_COLOR_SWITCH, + ATTR_TURN_ON_OFF_LISTENER, + 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, + 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, + SLEEP_MODE_SWITCH, + 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.demo.light import DemoLight +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, +) +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, + CONF_PLATFORM, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import Context, State +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, + 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"), +] + +_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 + + +@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}) + 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] + return entry, switch + + +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 = [ + DemoLight( + unique_id="light_1", + name="Bed Light", + state=True, + ct=200, + ), + DemoLight( + unique_id="light_2", + name="Ceiling Lights", + state=True, + ct=380, + ), + DemoLight( + unique_id="light_3", + name="Kitchen Lights", + state=False, + 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_lights_and_switch(hass, extra_conf=None): + """Create switch and demo lights.""" + # Setup demo lights and turn on + lights_instances = await setup_lights(hass) + 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", + ] + assert all(hass.states.get(light) is not None for light in lights) + _, switch = 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, + 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 {}), + }, + ) + 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, {}) + + 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 + + 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()) == 5 + + +@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 # pylint: disable=redefined-outer-name +): + """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": timezone}, + ) + _, switch = await setup_switch(hass, {}) + # Shouldn't raise an exception ever + await switch._update_attrs_and_maybe_adapt_lights( + context=switch.create_context("test") + ) + + +@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 # pylint: disable=redefined-outer-name +): + """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": timezone}, + ) + _, switch = await setup_switch( + hass, + { + CONF_SUNRISE_TIME: datetime.time(SUNRISE.hour), + CONF_SUNSET_TIME: datetime.time(SUNSET.hour), + }, + ) + + 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) + 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) + + 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 + 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. + 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 + 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 + 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. + 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 + 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 + 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 + + +async def test_light_settings(hass): + """Test that light settings are correctly applied.""" + switch, _ = await setup_lights_and_switch(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_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_lights_and_switch(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, (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) + await hass.async_block_till_done() + + 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() + 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, 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, + { + ATTR_ENTITY_ID: switch.entity_id, + CONF_MANUAL_CONTROL: set_to, + **extra_service_data, + }, + blocking=True, + ) + 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 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 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 manual_control[ENTITY_LIGHT] + + # Check that toggling light off to on resets manual control + await change_manual_control(True) + assert manual_control[ENTITY_LIGHT] + await turn_light(False) + await turn_light(True, brightness=increased_brightness()) + 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 manual_control[ENTITY_LIGHT] + await turn_switch(False, entity_id) + await turn_switch(True, entity_id) + 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 manual_control[ENTITY_LIGHT] + await switch.adapt_brightness_switch.async_turn_off() + await turn_light(True, brightness=increased_brightness()) + assert not manual_control[ENTITY_LIGHT] + await turn_light(True, color_temp=(light._ct + 100) % 500) + 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 + # 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 manual_control[ENTITY_LIGHT] + await switch.adapt_color_switch.async_turn_off() + await turn_light(True, color_temp=increased_color_temp()) + assert not manual_control[ENTITY_LIGHT] + await turn_light(True, brightness=increased_brightness()) + assert 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 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 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): + """Test adaptive_lighting.apply service.""" + 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): + """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_lights_and_switch(hass) + + 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=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(1e-3) + hass.states.async_set(ENTITY_LIGHT, STATE_ON) + # Set state to off after a second (like happens IRL) + await asyncio.sleep(1e-3) + 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() + state = hass.states.get(ENTITY_LIGHT).state + if turn_light_state_at_end: + assert sleep_task.cancelled() + assert state == STATE_ON + else: + assert state == STATE_OFF + + +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() + + 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] + + # 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 + 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] + + +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()) + + +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=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 + ) + # 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]) +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"} + + +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, 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) 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 + elif state == STATE_OFF: + assert not switch.is_on + elif state is None: + assert switch.is_on + + 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 _switch.is_on + elif state == STATE_OFF: + assert not _switch.is_on + elif state is None: + if initial_state: + 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.""" + _, switch = await setup_switch(hass, {CONF_SUNRISE_OFFSET: 3600 * 12}) + 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.""" + _, switch = await setup_switch(hass, {}) + + 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.""" + _, switch = await setup_switch(hass, {}) + await switch._async_update_at_interval() + + +@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: 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 + # 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