adaptive-lighting/custom_components/adaptive_lighting/switch.py

1573 lines
58 KiB
Python
Raw Normal View History

2020-09-28 13:10:41 +02:00
"""Switch for the Adaptive Lighting integration."""
2020-09-30 21:14:23 +02:00
from __future__ import annotations
import asyncio
import base64
2020-09-19 11:10:54 +02:00
import bisect
2020-10-13 22:39:38 +02:00
from collections import defaultdict
2020-09-24 20:56:01 +02:00
from copy import deepcopy
from dataclasses import dataclass
import datetime
2020-09-11 20:31:51 +02:00
from datetime import timedelta
2020-10-09 15:07:50 +02:00
import functools
2020-09-26 17:39:17 +02:00
import logging
2020-10-09 18:09:11 +02:00
import math
2022-04-17 19:51:44 -07:00
from typing import Any
import astral
2020-08-25 18:08:33 +02:00
from homeassistant.components.light import (
2020-10-04 14:26:42 +02:00
ATTR_BRIGHTNESS,
ATTR_BRIGHTNESS_PCT,
ATTR_BRIGHTNESS_STEP,
ATTR_BRIGHTNESS_STEP_PCT,
ATTR_COLOR_NAME,
2020-08-25 18:08:33 +02:00
ATTR_COLOR_TEMP,
ATTR_HS_COLOR,
ATTR_KELVIN,
2020-08-25 18:08:33 +02:00
ATTR_RGB_COLOR,
2022-08-28 15:08:03 -07:00
ATTR_SUPPORTED_COLOR_MODES,
2020-08-25 18:08:33 +02:00
ATTR_TRANSITION,
ATTR_XY_COLOR,
2022-08-28 15:08:03 -07:00
COLOR_MODE_BRIGHTNESS,
COLOR_MODE_COLOR_TEMP,
COLOR_MODE_HS,
COLOR_MODE_RGB,
COLOR_MODE_RGBW,
COLOR_MODE_XY,
)
from homeassistant.components.light import (
SUPPORT_BRIGHTNESS,
SUPPORT_COLOR,
SUPPORT_COLOR_TEMP,
SUPPORT_TRANSITION,
2020-09-25 10:27:02 +02:00
VALID_TRANSITION,
is_on,
)
2022-08-28 15:08:03 -07:00
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.components.switch import SwitchEntity
2020-09-30 21:14:23 +02:00
from homeassistant.config_entries import ConfigEntry
2020-08-25 18:08:33 +02:00
from homeassistant.const import (
ATTR_AREA_ID,
2020-09-27 16:21:42 +02:00
ATTR_DOMAIN,
2020-08-25 18:08:33 +02:00
ATTR_ENTITY_ID,
2020-09-27 16:21:42 +02:00
ATTR_SERVICE,
ATTR_SERVICE_DATA,
ATTR_SUPPORTED_FEATURES,
2020-08-25 18:08:33 +02:00
CONF_NAME,
2020-09-27 16:21:42 +02:00
EVENT_CALL_SERVICE,
2020-10-06 14:14:19 +02:00
EVENT_HOMEASSISTANT_STARTED,
2020-10-05 23:41:57 +02:00
EVENT_STATE_CHANGED,
2020-09-27 16:21:42 +02:00
SERVICE_TURN_OFF,
2020-08-25 18:08:33 +02:00
SERVICE_TURN_ON,
STATE_OFF,
2020-08-25 18:08:33 +02:00
STATE_ON,
2020-09-11 20:31:51 +02:00
SUN_EVENT_SUNRISE,
SUN_EVENT_SUNSET,
)
2020-10-13 22:39:38 +02:00
from homeassistant.core import (
Context,
Event,
HomeAssistant,
ServiceCall,
State,
callback,
)
2020-09-25 10:27:02 +02:00
from homeassistant.helpers import entity_platform
2020-09-26 17:39:17 +02:00
import homeassistant.helpers.config_validation as cv
2020-09-11 20:31:51 +02:00
from homeassistant.helpers.event import (
async_track_state_change_event,
2020-09-11 20:31:51 +02:00
async_track_time_interval,
2020-08-25 18:08:33 +02:00
)
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.sun import get_astral_location
from homeassistant.helpers.template import area_entities
from homeassistant.util import slugify
2020-08-25 18:08:33 +02:00
from homeassistant.util.color import (
color_RGB_to_xy,
color_temperature_kelvin_to_mired,
color_temperature_to_rgb,
color_xy_to_hs,
)
2020-09-26 17:39:17 +02:00
import homeassistant.util.dt as dt_util
2022-08-28 15:08:03 -07:00
import voluptuous as vol
2020-09-12 23:25:36 +02:00
from .const import (
ADAPT_BRIGHTNESS_SWITCH,
ADAPT_COLOR_SWITCH,
ATTR_ADAPT_BRIGHTNESS,
ATTR_ADAPT_COLOR,
2020-09-28 13:10:41 +02:00
ATTR_TURN_ON_OFF_LISTENER,
2022-08-29 09:30:20 -07:00
CONF_ADAPT_DELAY,
2020-10-04 15:09:58 +02:00
CONF_DETECT_NON_HA_CHANGES,
2020-09-12 23:25:36 +02:00
CONF_INITIAL_TRANSITION,
CONF_INTERVAL,
CONF_LIGHTS,
CONF_MANUAL_CONTROL,
2020-09-12 23:25:36 +02:00
CONF_MAX_BRIGHTNESS,
2020-09-15 23:33:19 +02:00
CONF_MAX_COLOR_TEMP,
2020-09-12 23:25:36 +02:00
CONF_MIN_BRIGHTNESS,
2020-09-15 23:33:19 +02:00
CONF_MIN_COLOR_TEMP,
2020-09-12 23:25:36 +02:00
CONF_ONLY_ONCE,
CONF_PREFER_RGB_COLOR,
CONF_SEPARATE_TURN_ON_COMMANDS,
2020-09-12 23:25:36 +02:00
CONF_SLEEP_BRIGHTNESS,
2020-09-15 23:33:19 +02:00
CONF_SLEEP_COLOR_TEMP,
2022-08-28 15:08:03 -07:00
CONF_SLEEP_TRANSITION,
2020-09-12 23:25:36 +02:00
CONF_SUNRISE_OFFSET,
CONF_SUNRISE_TIME,
CONF_SUNSET_OFFSET,
CONF_SUNSET_TIME,
CONF_TAKE_OVER_CONTROL,
2020-09-12 23:25:36 +02:00
CONF_TRANSITION,
2020-09-29 23:39:46 +02:00
CONF_TURN_ON_LIGHTS,
DOMAIN,
2020-09-23 13:11:50 +02:00
EXTRA_VALIDATION,
ICON,
2020-09-25 10:27:02 +02:00
SERVICE_APPLY,
SERVICE_SET_MANUAL_CONTROL,
2020-10-20 00:06:10 +02:00
SLEEP_MODE_SWITCH,
SUN_EVENT_MIDNIGHT,
SUN_EVENT_NOON,
TURNING_OFF_DELAY,
2020-09-23 19:09:35 +02:00
VALIDATION_TUPLES,
replace_none_str,
2020-09-12 23:25:36 +02:00
)
2020-08-25 18:08:33 +02:00
_SUPPORT_OPTS = {
"brightness": SUPPORT_BRIGHTNESS,
"color_temp": SUPPORT_COLOR_TEMP,
"color": SUPPORT_COLOR,
"transition": SUPPORT_TRANSITION,
}
2020-09-25 10:49:58 +02:00
_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)
2020-10-05 23:41:57 +02:00
# Consider it a significant change when attribute changes more than
BRIGHTNESS_CHANGE = 25 # ≈10% of total range
2020-10-06 14:14:19 +02:00
COLOR_TEMP_CHANGE = 20 # ≈5% of total range
2020-10-09 18:09:11 +02:00
RGB_REDMEAN_CHANGE = 80 # ≈10% of total range
2020-10-05 23:41:57 +02:00
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_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"
2022-08-29 09:25:10 -07:00
def _int_to_bytes(i: int, signed: bool = False) -> bytes:
bits = i.bit_length()
if signed:
# Make room for the sign bit.
bits += 1
2022-08-29 09:25:10 -07:00
return i.to_bytes((bits + 7) // 8, "little", signed=signed)
def _short_hash(string: str, length: int = 4) -> str:
2020-10-08 20:28:04 +02:00
"""Create a hash of 'string' with length 'length'."""
str_hash_bytes = _int_to_bytes(hash(string), signed=True)
return base64.b85encode(str_hash_bytes)[:length]
def create_context(
2022-04-17 19:50:55 -07:00
name: str, which: str, index: int, parent: Context | None = None
) -> 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)
# Pack index with base85 to maximize the number of contexts we can create
# before we exceed the 36-character limit and are forced to wrap.
index_packed = base64.b85encode(_int_to_bytes(index, signed=False))
context_id = f"{_DOMAIN_SHORT}:{name_hash}:{which}:{index_packed}"[:36]
parent_id = parent.id if parent else None
return Context(id=context_id, parent_id=parent_id)
2022-04-17 19:50:55 -07:00
def is_our_context(context: Context | None) -> 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:
2020-12-19 14:06:48 +01:00
service_data_color = service_data.copy()
service_data_color.pop(ATTR_BRIGHTNESS, None)
service_datas.append(service_data_color)
if adapt_brightness:
2020-12-19 14:06:48 +01:00
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)
2022-08-28 16:17:41 -07:00
if not service_datas: # neither adapt_brightness nor adapt_color
return [service_data]
return service_datas
2020-09-30 21:14:23 +02:00
async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall):
2020-09-25 10:27:02 +02:00
"""Handle the entity service apply."""
2020-09-30 00:10:49 +02:00
hass = switch.hass
2020-09-25 16:45:10 +02:00
data = service_call.data
all_lights = data[CONF_LIGHTS]
if not all_lights:
all_lights = switch._lights
all_lights = _expand_light_groups(hass, all_lights)
2020-09-30 21:14:23 +02:00
switch.turn_on_off_listener.lights.update(all_lights)
_LOGGER.debug(
"Called 'adaptive_lighting.apply' service with '%s'",
data,
)
2020-09-30 21:14:23 +02:00
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],
2020-10-25 09:35:54 +01:00
data[CONF_PREFER_RGB_COLOR],
2020-10-04 14:26:42 +02:00
force=True,
context=switch.create_context("service", parent=service_call.context),
2020-09-30 21:14:23 +02:00
)
2020-09-25 10:27:02 +02:00
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)
2020-10-14 07:50:45 +02:00
_LOGGER.debug(
"Called 'adaptive_lighting.set_manual_control' service with '%s'",
2020-10-14 07:50:45 +02:00
service_call.data,
)
if service_call.data[CONF_MANUAL_CONTROL]:
2020-10-14 07:50:45 +02:00
for light in all_lights:
switch.turn_on_off_listener.manual_control[light] = True
_fire_manual_control_event(switch, light, service_call.context)
2020-10-14 07:50:45 +02:00
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", parent=service_call.context),
)
2020-10-13 22:39:38 +02:00
@callback
2020-10-14 07:50:45 +02:00
def _fire_manual_control_event(
switch: AdaptiveSwitch, light: str, context: Context, is_async=True
2020-10-13 22:39:38 +02:00
):
"""Fire an event that 'light' is marked as manual_control."""
hass = switch.hass
2020-10-13 22:39:38 +02:00
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,
)
2020-10-13 22:39:38 +02:00
2020-10-05 23:41:57 +02:00
async def async_setup_entry(
hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: bool
):
2020-09-19 11:10:54 +02:00
"""Set up the AdaptiveLighting switch."""
2020-09-28 23:39:55 +02:00
data = hass.data[DOMAIN]
2020-10-17 15:43:24 +02:00
assert config_entry.entry_id in data
2020-09-28 13:10:41 +02:00
2020-09-28 23:39:55 +02:00
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]
2020-09-28 13:10:41 +02:00
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,
)
2020-10-20 00:06:10 +02:00
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
2020-09-28 23:39:55 +02:00
data[config_entry.entry_id][SWITCH_DOMAIN] = switch
2020-09-25 10:27:02 +02:00
async_add_entities(
[switch, sleep_mode_switch, adapt_color_switch, adapt_brightness_switch],
update_before_add=True,
)
2020-09-25 10:27:02 +02:00
# Register `apply` service
platform = entity_platform.current_platform.get()
platform.async_register_entity_service(
SERVICE_APPLY,
{
2022-04-17 19:50:11 -07:00
vol.Optional(
CONF_LIGHTS, default=[]
): cv.entity_ids, # pylint: disable=protected-access
2020-09-25 16:45:10 +02:00
vol.Optional(
2020-09-27 16:21:42 +02:00
CONF_TRANSITION,
default=switch._initial_transition, # pylint: disable=protected-access
2020-09-25 16:45:10 +02:00
): VALID_TRANSITION,
vol.Optional(ATTR_ADAPT_BRIGHTNESS, default=True): cv.boolean,
vol.Optional(ATTR_ADAPT_COLOR, default=True): cv.boolean,
2020-10-25 09:35:54 +01:00
vol.Optional(CONF_PREFER_RGB_COLOR, default=False): cv.boolean,
2020-09-29 23:39:46 +02:00
vol.Optional(CONF_TURN_ON_LIGHTS, default=False): cv.boolean,
2020-09-25 10:27:02 +02:00
},
handle_apply,
)
2020-10-13 22:39:38 +02:00
platform.async_register_entity_service(
SERVICE_SET_MANUAL_CONTROL,
2020-10-14 07:50:45 +02:00
{
vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids,
vol.Optional(CONF_MANUAL_CONTROL, default=True): cv.boolean,
2020-10-14 07:50:45 +02:00
},
handle_set_manual_control,
2020-10-13 22:39:38 +02:00
)
2020-10-09 15:07:50 +02:00
def validate(config_entry: ConfigEntry):
"""Get the options and data from the config_entry and add defaults."""
2020-09-24 23:47:22 +02:00
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()}
2020-09-27 16:21:42 +02:00
for key, (validate_value, _) in EXTRA_VALIDATION.items():
2020-09-24 23:47:22 +02:00
value = data.get(key)
if value is not None:
2020-09-27 16:21:42 +02:00
data[key] = validate_value(value) # Fix the types of the inputs
2020-09-24 23:47:22 +02:00
return data
2022-04-17 19:50:55 -07:00
def match_switch_state_event(event: Event, from_or_to_state: list[str]):
2020-09-30 21:14:23 +02:00
"""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
2022-04-17 19:50:55 -07:00
def _expand_light_groups(hass: HomeAssistant, lights: list[str]) -> list[str]:
2020-09-30 00:10:49 +02:00
all_lights = set()
turn_on_off_listener = hass.data[DOMAIN][ATTR_TURN_ON_OFF_LISTENER]
2020-09-30 00:10:49 +02:00
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)
2020-09-30 00:10:49 +02:00
all_lights.update(group)
_LOGGER.debug("Expanded %s to %s", light, group)
else:
all_lights.add(light)
return list(all_lights)
2020-10-05 23:41:57 +02:00
def _supported_features(hass: HomeAssistant, light: str):
state = hass.states.get(light)
supported_features = state.attributes[ATTR_SUPPORTED_FEATURES]
supported = {
key for key, value in _SUPPORT_OPTS.items() if supported_features & value
}
2021-05-10 18:26:26 +02:00
supported_color_modes = state.attributes.get(ATTR_SUPPORTED_COLOR_MODES, set())
if COLOR_MODE_RGB in supported_color_modes:
supported.add("color")
# Adding brightness here, see
# comment https://github.com/basnijholt/adaptive-lighting/issues/112#issuecomment-836944011
supported.add("brightness")
2021-05-10 18:26:26 +02:00
if COLOR_MODE_RGBW in supported_color_modes:
supported.add("color")
supported.add("brightness") # see above url
if COLOR_MODE_XY in supported_color_modes:
supported.add("color")
supported.add("brightness") # see above url
if COLOR_MODE_HS in supported_color_modes:
supported.add("color")
supported.add("brightness") # see above url
2021-05-10 18:26:26 +02:00
if COLOR_MODE_COLOR_TEMP in supported_color_modes:
supported.add("color_temp")
supported.add("brightness") # see above url
2021-05-10 18:26:26 +02:00
if COLOR_MODE_BRIGHTNESS in supported_color_modes:
supported.add("brightness")
return supported
2020-10-11 12:41:30 +02:00
def color_difference_redmean(
2022-04-17 19:50:55 -07:00
rgb1: tuple[float, float, float], rgb2: tuple[float, float, float]
2020-10-11 12:41:30 +02:00
) -> float:
2020-10-11 11:35:03 +02:00
"""Distance between colors in RGB space (redmean metric).
2020-10-09 18:09:11 +02:00
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
2022-04-17 19:50:55 -07:00
delta_r, delta_g, delta_b = ((col1 - col2) for col1, col2 in zip(rgb1, rgb2))
2022-04-17 19:50:11 -07:00
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
2020-10-09 18:09:11 +02:00
return math.sqrt(red_term + green_term + blue_term)
def _attributes_have_changed(
2020-10-09 15:07:50 +02:00
light: str,
2022-04-17 19:50:55 -07:00
old_attributes: dict[str, Any],
new_attributes: dict[str, Any],
2020-10-09 15:07:50 +02:00
adapt_brightness: bool,
adapt_color: bool,
2020-10-09 15:07:50 +02:00
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_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]
2020-10-09 18:09:11 +02:00
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
2020-09-12 12:21:40 +02:00
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,
):
2020-09-12 12:21:40 +02:00
"""Initialize the Adaptive Lighting switch."""
self.hass = hass
2020-09-28 13:10:41 +02:00
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
2020-09-24 23:47:22 +02:00
data = validate(config_entry)
2020-09-24 23:51:41 +02:00
self._name = data[CONF_NAME]
2020-09-23 19:09:35 +02:00
self._lights = data[CONF_LIGHTS]
2020-10-04 15:09:58 +02:00
self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES]
2020-09-23 19:09:35 +02:00
self._initial_transition = data[CONF_INITIAL_TRANSITION]
self._sleep_transition = data[CONF_SLEEP_TRANSITION]
2020-09-23 19:09:35 +02:00
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 = data[CONF_TRANSITION]
self._adapt_delay = data[CONF_ADAPT_DELAY]
2021-04-30 10:46:14 +02:00
_loc = get_astral_location(self.hass)
if isinstance(_loc, tuple):
# Astral v2.2
location, _ = _loc
else:
# Astral v1
location = _loc
self._sun_light_settings = SunLightSettings(
name=self._name,
2021-04-30 10:46:14 +02:00
astral_location=location,
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,
transition=data[CONF_TRANSITION],
)
2020-09-25 00:00:35 +02:00
# Set other attributes
self._icon = ICON
self._state = None
2020-09-27 16:21:42 +02:00
# Tracks 'off' → 'on' state changes
2022-04-17 19:50:55 -07:00
self._on_to_off_event: dict[str, Event] = {}
# Tracks 'on' → 'off' state changes
2022-04-17 19:50:55 -07:00
self._off_to_on_event: dict[str, Event] = {}
2020-09-28 13:10:41 +02:00
# Locks that prevent light adjusting when waiting for a light to 'turn_off'
2022-04-17 19:50:55 -07:00
self._locks: dict[str, asyncio.Lock] = {}
# To count the number of `Context` instances
self._context_cnt: int = 0
2020-09-25 00:00:35 +02:00
# Set in self._update_attrs_and_maybe_adapt_lights
2022-04-17 19:50:55 -07:00
self._settings: dict[str, Any] = {}
# Set and unset tracker in async_turn_on and async_turn_off
2020-09-30 00:42:27 +02:00
self.remove_listeners = []
2020-09-26 18:48:48 +02:00
_LOGGER.debug(
2020-09-27 16:21:42 +02:00
"%s: Setting up with '%s',"
2020-09-26 18:48:48 +02:00
" config_entry.data: '%s',"
" config_entry.options: '%s', converted to '%s'.",
2020-09-27 16:21:42 +02:00
self._name,
2020-09-26 18:48:48 +02:00
self._lights,
config_entry.data,
config_entry.options,
data,
2020-09-19 11:10:54 +02:00
)
@property
def name(self):
"""Return the name of the device if any."""
return f"Adaptive Lighting: {self._name}"
2020-10-03 17:36:26 +02:00
@property
def unique_id(self):
"""Return the unique ID of entity."""
return self._name
2020-10-03 01:00:26 +02:00
@property
2022-04-17 19:50:55 -07:00
def is_on(self) -> bool | None:
2020-09-12 12:21:40 +02:00
"""Return true if adaptive lighting is on."""
return self._state
async def async_added_to_hass(self) -> None:
2020-09-28 13:10:41 +02:00
"""Call when entity about to be added to hass."""
2020-09-30 21:14:23 +02:00
if self.hass.is_running:
await self._setup_listeners()
else:
self.hass.bus.async_listen_once(
2020-10-06 14:14:19 +02:00
EVENT_HOMEASSISTANT_STARTED, self._setup_listeners
2020-09-30 21:14:23 +02:00
)
2020-09-28 13:10:41 +02:00
last_state = await self.async_get_last_state()
2020-09-30 12:54:02 +02:00
is_new_entry = last_state is None # newly added to HA
if is_new_entry or last_state.state == STATE_ON:
2020-09-30 00:42:27 +02:00
await self.async_turn_on(adapt_lights=not self._only_once)
else:
self._state = False
2020-09-30 21:14:23 +02:00
assert not self.remove_listeners
2020-09-28 13:10:41 +02:00
2020-10-03 17:36:26 +02:00
async def async_will_remove_from_hass(self):
"""Remove the listeners upon removing the component."""
self._remove_listeners()
2020-09-30 00:10:49 +02:00
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)
2020-09-28 13:10:41 +02:00
async def _setup_listeners(self, _=None) -> None:
2020-09-30 21:14:23 +02:00
_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)
2020-09-30 00:42:27 +02:00
return
2020-09-30 00:42:27 +02:00
assert not self.remove_listeners
2020-09-30 21:14:23 +02:00
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])
2020-09-30 21:14:23 +02:00
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:
2020-09-30 00:42:27 +02:00
while self.remove_listeners:
remove_listener = self.remove_listeners.pop()
remove_listener()
2020-09-29 10:52:22 +02:00
@property
def icon(self) -> str:
"""Icon to use in the frontend, if any."""
return self._icon
@property
2022-04-17 19:50:55 -07:00
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the attributes of the switch."""
if not self.is_on:
2020-10-04 14:26:42 +02:00
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(
2022-04-17 19:50:55 -07:00
self, which: str = "default", parent: Context | None = None
) -> 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:*************'
# 'adapt_lgt:XXXX:interval:************'
# 'adapt_lgt:XXXX:adapt_lights:********'
# 'adapt_lgt:XXXX:sleep:***************'
# 'adapt_lgt:XXXX:light_event:*********'
# 'adapt_lgt:XXXX:service:*************'
# The smallest space we have is for adapt_lights, which has
# 8 characters. In base85 encoding, that's enough space to hold values
# up to 2**48 - 1, which should give us plenty of calls before we wrap.
context = create_context(self._name, which, self._context_cnt, parent=parent)
self._context_cnt += 1
return context
2020-10-03 01:00:26 +02:00
async def async_turn_on( # pylint: disable=arguments-differ
2020-09-30 21:14:23 +02:00
self, adapt_lights: bool = True
2020-10-03 01:00:26 +02:00
) -> None:
2020-09-12 12:21:40 +02:00
"""Turn on adaptive lighting."""
2020-09-30 21:14:23 +02:00
_LOGGER.debug(
"%s: Called 'async_turn_on', current state is '%s'", self._name, self._state
)
2020-09-28 23:39:55 +02:00
if self.is_on:
return
self._state = True
2020-10-04 23:15:12 +02:00
self.turn_on_off_listener.reset(*self._lights)
2020-09-30 00:42:27 +02:00
await self._setup_listeners()
2020-09-29 23:11:52 +02:00
if adapt_lights:
await self._update_attrs_and_maybe_adapt_lights(
transition=self._initial_transition,
force=True,
context=self.create_context("turn_on"),
2020-09-30 21:14:23 +02:00
)
async def async_turn_off(self, **kwargs) -> None:
2020-09-12 12:21:40 +02:00
"""Turn off adaptive lighting."""
2020-09-28 23:39:55 +02:00
if not self.is_on:
return
self._state = False
2020-09-30 00:42:27 +02:00
self._remove_listeners()
2020-10-04 23:15:12 +02:00
self.turn_on_off_listener.reset(*self._lights)
2020-09-29 10:48:35 +02:00
async def _async_update_at_interval(self, now=None) -> None:
await self._update_attrs_and_maybe_adapt_lights(
2022-04-17 19:50:11 -07:00
transition=self._transition,
force=False,
context=self.create_context("interval"),
)
2020-09-29 23:39:46 +02:00
async def _adapt_light(
self,
2020-09-30 21:14:23 +02:00
light: str,
2022-04-17 19:50:55 -07:00
transition: int | None = None,
adapt_brightness: bool | None = None,
adapt_color: bool | None = None,
prefer_rgb_color: bool | None = None,
2020-10-04 14:26:42 +02:00
force: bool = False,
2022-04-17 19:50:55 -07:00
context: Context | None = None,
) -> None:
2020-09-30 21:14:23 +02:00
lock = self._locks.get(light)
if lock is not None and lock.locked():
_LOGGER.debug("%s: '%s' is locked", self._name, light)
return
2020-09-15 20:40:24 +02:00
service_data = {ATTR_ENTITY_ID: light}
features = _supported_features(self.hass, light)
2020-09-15 20:40:24 +02:00
2020-09-29 23:39:46 +02:00
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
2020-10-25 09:35:54 +01:00
if prefer_rgb_color is None:
prefer_rgb_color = self._prefer_rgb_color
2020-09-29 23:39:46 +02:00
2020-09-15 20:40:24 +02:00
if "transition" in features:
service_data[ATTR_TRANSITION] = transition
2020-09-29 23:39:46 +02:00
if "brightness" in features and adapt_brightness:
2020-10-09 15:07:50 +02:00
brightness = round(255 * self._settings["brightness_pct"] / 100)
service_data[ATTR_BRIGHTNESS] = brightness
2020-09-15 20:40:24 +02:00
if (
"color_temp" in features
and adapt_color
2020-10-25 09:35:54 +01:00
and not (prefer_rgb_color and "color" in features)
):
2020-09-28 16:31:41 +02:00
attributes = self.hass.states.get(light).attributes
min_mireds, max_mireds = attributes["min_mireds"], attributes["max_mireds"]
2020-10-04 14:26:42 +02:00
color_temp_mired = self._settings["color_temp_mired"]
color_temp_mired = max(min(color_temp_mired, max_mireds), min_mireds)
2020-09-28 16:31:41 +02:00
service_data[ATTR_COLOR_TEMP] = color_temp_mired
elif "color" in features and adapt_color:
2020-10-04 14:26:42 +02:00
service_data[ATTR_RGB_COLOR] = self._settings["rgb_color"]
2020-10-14 07:50:45 +02:00
context = context or self.create_context("adapt_lights")
2020-10-04 14:26:42 +02:00
if (
self._take_over_control
2020-10-04 15:09:58 +02:00
and self._detect_non_ha_changes
2020-10-04 14:26:42 +02:00
and not force
2020-10-04 15:09:58 +02:00
and await self.turn_on_off_listener.significant_change(
self,
2020-10-04 14:26:42 +02:00
light,
adapt_brightness,
adapt_color,
context,
2020-10-04 14:26:42 +02:00
)
):
return
2020-10-09 15:07:50 +02:00
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,
)
2020-09-15 20:40:24 +02:00
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,
2022-04-17 19:50:55 -07:00
lights: list[str] | None = None,
transition: int | None = None,
force: bool = False,
2022-04-17 19:50:55 -07:00
context: Context | None = None,
) -> None:
2020-10-14 07:50:45 +02:00
assert context is not None
2020-10-08 20:28:04 +02:00
_LOGGER.debug(
"%s: '_update_attrs_and_maybe_adapt_lights' called with context.id='%s'",
self._name,
context.id,
)
assert self.is_on
2020-10-04 14:26:42 +02:00
self._settings = self._sun_light_settings.get_settings(
self.sleep_mode_switch.is_on, transition
2020-10-03 01:00:26 +02:00
)
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)
2020-09-30 21:14:23 +02:00
async def _adapt_lights(
self,
2022-04-17 19:50:55 -07:00
lights: list[str],
transition: int | None,
force: bool,
2022-04-17 19:50:55 -07:00
context: Context | None,
) -> None:
2020-10-14 07:50:45 +02:00
assert context is not None
_LOGGER.debug(
"%s: '_adapt_lights(%s, %s, force=%s, context.id=%s)' called",
2020-10-03 14:55:02 +02:00
self.name,
lights,
transition,
force,
context.id,
)
2020-09-30 21:14:23 +02:00
for light in lights:
if not is_on(self.hass, light):
continue
2020-10-04 14:26:42 +02:00
if (
self._take_over_control
and self.turn_on_off_listener.is_manually_controlled(
self,
light,
2020-10-03 14:55:02 +02:00
force,
self.adapt_brightness_switch.is_on,
self.adapt_color_switch.is_on,
2020-10-04 14:26:42 +02:00
)
):
_LOGGER.debug(
2020-10-08 20:28:04 +02:00
"%s: '%s' is being manually controlled, stop adapting, context.id=%s.",
2020-10-04 14:26:42 +02:00
self._name,
light,
2020-10-08 20:28:04 +02:00
context.id,
2020-10-04 14:26:42 +02:00
)
continue
await self._adapt_light(light, transition, force=force, context=context)
2020-09-30 21:14:23 +02:00
async def _sleep_mode_switch_state_event(self, event: Event) -> None:
if not match_switch_state_event(event, (STATE_ON, STATE_OFF)):
2022-08-28 22:02:52 -07:00
_LOGGER.debug("%s: Ignoring sleep event %s", self._name, event)
2020-09-28 13:10:41 +02:00
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._sleep_transition,
force=True,
context=self.create_context("sleep", parent=event.context),
2020-09-30 21:14:23 +02:00
)
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
2020-10-05 23:41:57 +02:00
and old_state.state == STATE_OFF
and new_state is not None
2020-10-05 23:41:57 +02:00
and new_state.state == STATE_ON
):
2020-09-27 16:21:42 +02:00
_LOGGER.debug(
"%s: Detected an 'off''on' event for '%s' with context.id='%s'",
self._name,
entity_id,
event.context.id,
2020-09-27 16:21:42 +02:00
)
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:
2020-09-29 14:21:39 +02:00
lock = self._locks[entity_id] = asyncio.Lock()
2020-09-28 13:10:41 +02:00
async with lock:
if await self.turn_on_off_listener.maybe_cancel_adjusting(
entity_id,
off_to_on_event=event,
2020-09-28 14:35:32 +02:00
on_to_off_event=self._on_to_off_event.get(entity_id),
2020-09-28 13:10:41 +02:00
):
# Stop if a rapid 'off' → 'on' → 'off' happens.
_LOGGER.debug(
"%s: Cancelling adjusting lights for %s", self._name, entity_id
)
return
2022-08-29 09:30:20 -07:00
if self._adapt_delay > 0:
_LOGGER.debug(
"%s: sleep started for '%s' with context.id='%s'",
self._name,
entity_id,
event.context.id,
)
await asyncio.sleep(self._adapt_delay)
_LOGGER.debug(
"%s: sleep ended for '%s' with context.id='%s'",
self._name,
entity_id,
event.context.id,
)
2020-09-30 21:14:23 +02:00
await self._update_attrs_and_maybe_adapt_lights(
lights=[entity_id],
transition=self._initial_transition,
force=True,
context=self.create_context("light_event", parent=event.context),
)
2020-09-27 16:21:42 +02:00
elif (
old_state is not None
2020-10-05 23:41:57 +02:00
and old_state.state == STATE_ON
and new_state is not None
2020-10-05 23:41:57 +02:00
and new_state.state == STATE_OFF
):
2020-09-27 16:21:42 +02:00
# Tracks 'off' → 'on' state changes
2020-09-28 13:10:41 +02:00
self._on_to_off_event[entity_id] = event
2020-10-04 14:26:42 +02:00
self.turn_on_off_listener.reset(entity_id)
2020-09-28 13:10:41 +02:00
2020-09-27 16:21:42 +02:00
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
2020-10-22 12:34:36 +02:00
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
2020-10-03 17:36:26 +02:00
@property
def unique_id(self):
"""Return the unique ID of entity."""
return self._unique_id
2020-10-03 01:00:26 +02:00
@property
def icon(self) -> str:
"""Icon to use in the frontend, if any."""
return self._icon
@property
2022-04-17 19:50:55 -07:00
def is_on(self) -> bool | None:
"""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()
2020-10-22 11:15:38 +02:00
_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()
2020-10-22 11:15:38 +02:00
else:
await self.async_turn_off()
2020-10-03 01:00:26 +02:00
async def async_turn_on(self, **kwargs) -> None:
"""Turn on adaptive lighting sleep mode."""
2022-08-28 22:02:52 -07:00
_LOGGER.debug("%s: Turning on", self._name)
self._state = True
2020-10-03 01:00:26 +02:00
async def async_turn_off(self, **kwargs) -> None:
"""Turn off adaptive lighting sleep mode."""
2022-08-28 22:02:52 -07:00
_LOGGER.debug("%s: Turning off", self._name)
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
2022-04-17 19:50:55 -07:00
sunrise_offset: datetime.timedelta | None
sunrise_time: datetime.time | None
sunset_offset: datetime.timedelta | None
sunset_time: datetime.time | None
time_zone: datetime.tzinfo
transition: int
2022-04-17 19:50:55 -07:00
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")
2020-10-17 15:43:24 +02:00
date_time = datetime.datetime.combine(date, time)
try: # HA ≤2021.05, https://github.com/basnijholt/adaptive-lighting/issues/128
utc_time = self.time_zone.localize(date_time).astimezone(dt_util.UTC)
2022-04-17 19:50:11 -07:00
except AttributeError: # HA ≥2021.06
utc_time = date_time.replace(
tzinfo=dt_util.DEFAULT_TIME_ZONE
).astimezone(dt_util.UTC)
2020-10-17 15:43:24 +02:00
return utc_time
2021-11-08 03:34:50 +05:00
def calculate_noon_and_midnight(
2022-04-17 19:50:11 -07:00
sunset: datetime.datetime, sunrise: datetime.datetime
2022-04-17 19:50:55 -07:00
) -> tuple[datetime.datetime, datetime.datetime]:
2021-11-08 03:34:50 +05:00
middle = abs(sunset - sunrise) / 2
if sunset > sunrise:
noon = sunrise + middle
midnight = noon + timedelta(hours=12) * (1 if noon.hour < 12 else -1)
else:
midnight = sunset + middle
2022-04-17 19:50:11 -07:00
noon = midnight + timedelta(hours=12) * (
1 if midnight.hour < 12 else -1
)
2021-11-08 03:34:50 +05:00
return noon, midnight
location = self.astral_location
2020-10-17 15:43:24 +02:00
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:
try:
# Astral v1
solar_noon = location.solar_noon(date, local=False)
solar_midnight = location.solar_midnight(date, local=False)
except AttributeError:
# Astral v2
solar_noon = location.noon(date, local=False)
solar_midnight = location.midnight(date, local=False)
else:
2021-11-08 03:34:50 +05:00
(solar_noon, solar_midnight) = calculate_noon_and_midnight(sunset, sunrise)
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
2022-04-17 19:50:55 -07:00
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, transition: int) -> float:
"""Calculate the position of the sun in %."""
now = dt_util.utcnow()
target_time = now + timedelta(seconds=transition)
target_ts = target_time.timestamp()
today = self.relevant_events(target_time)
(_, 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) * ((target_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, transition
2022-04-17 19:50:55 -07:00
) -> dict[str, float | tuple[float, float] | tuple[float, float, float]]:
"""Get all light settings.
Calculating all values takes <0.5ms.
"""
2022-04-17 19:50:11 -07:00
percent = (
self.calc_percent(transition)
if transition is not None
else self.calc_percent(0)
)
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)
2022-04-17 19:50:55 -07:00
rgb_color: tuple[float, float, float] = color_temperature_to_rgb(
color_temp_kelvin
)
2022-04-17 19:50:55 -07:00
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,
2020-10-20 00:06:10 +02:00
"sun_position": percent,
}
2020-09-28 13:10:41 +02:00
class TurnOnOffListener:
2020-09-28 14:35:32 +02:00
"""Track 'light.turn_off' and 'light.turn_on' service calls."""
2020-09-28 13:10:41 +02:00
2020-10-09 15:07:50 +02:00
def __init__(self, hass: HomeAssistant):
2020-09-28 13:10:41 +02:00
"""Initialize the TurnOnOffListener that is shared among all switches."""
self.hass = hass
self.lights = set()
2020-09-28 14:35:32 +02:00
# Tracks 'light.turn_off' service calls
2022-04-17 19:50:55 -07:00
self.turn_off_event: dict[str, Event] = {}
2020-09-28 13:10:41 +02:00
# Tracks 'light.turn_on' service calls
2022-04-17 19:50:55 -07:00
self.turn_on_event: dict[str, Event] = {}
2020-10-09 15:07:50 +02:00
# Keep 'asyncio.sleep' tasks that can be cancelled by 'light.turn_on' events
2022-04-17 19:50:55 -07:00
self.sleep_tasks: dict[str, asyncio.Task] = {}
2020-10-03 14:55:02 +02:00
# Tracks which lights are manually controlled
2022-04-17 19:50:55 -07:00
self.manual_control: dict[str, bool] = {}
2020-10-13 22:39:38 +02:00
# Counts the number of times (in a row) a light had a changed state.
2022-04-17 19:50:55 -07:00
self.cnt_significant_changes: dict[str, int] = defaultdict(int)
2020-10-05 23:41:57 +02:00
# Track 'state_changed' events of self.lights resulting from this integration
2022-04-17 19:50:55 -07:00
self.last_state_change: dict[str, list[State]] = {}
2020-10-09 15:07:50 +02:00
# Track last 'service_data' to 'light.turn_on' resulting from this integration
2022-04-17 19:50:55 -07:00
self.last_service_data: dict[str, dict[str, Any]] = {}
2020-10-13 22:39:38 +02:00
# When a state is different `max_cnt_significant_changes` times in a row,
# mark it as manually_controlled.
2020-10-14 07:50:45 +02:00
self.max_cnt_significant_changes = 2
2020-10-13 22:39:38 +02:00
2020-09-30 00:42:27 +02:00
self.remove_listener = self.hass.bus.async_listen(
EVENT_CALL_SERVICE, self.turn_on_off_event_listener
)
2020-10-05 23:41:57 +02:00
self.remove_listener2 = self.hass.bus.async_listen(
EVENT_STATE_CHANGED, self.state_changed_event_listener
)
2020-09-28 13:10:41 +02:00
def reset(self, *lights, reset_manual_control=True) -> None:
"""Reset the 'manual_control' status of the lights."""
2020-10-04 14:26:42 +02:00
for light in lights:
if reset_manual_control:
self.manual_control[light] = False
2020-10-05 23:41:57 +02:00
self.last_state_change.pop(light, None)
2020-10-09 15:07:50 +02:00
self.last_service_data.pop(light, None)
2020-10-13 22:39:38 +02:00
self.cnt_significant_changes[light] = 0
2020-10-04 14:26:42 +02:00
2020-10-09 15:07:50 +02:00
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
2020-10-04 14:26:42 +02:00
service = event.data[ATTR_SERVICE]
service_data = event.data[ATTR_SERVICE_DATA]
if ATTR_ENTITY_ID in service_data:
entity_ids = cv.ensure_list_csv(service_data[ATTR_ENTITY_ID])
elif ATTR_AREA_ID in service_data:
area_ids = cv.ensure_list_csv(service_data[ATTR_AREA_ID])
entity_ids = []
for area_id in area_ids:
2022-08-29 19:07:34 -07:00
area_entity_ids = area_entities(self.hass, area_id)
for entity_id in area_entity_ids:
if entity_id.startswith(LIGHT_DOMAIN):
entity_ids.append(entity_id)
_LOGGER.debug(
2022-08-29 19:07:34 -07:00
"Found entity_ids '%s' for area_id '%s'", entity_ids, area_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
2020-10-04 14:26:42 +02:00
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
2020-10-09 15:07:50 +02:00
async def state_changed_event_listener(self, event: Event) -> None:
2020-10-05 23:41:57 +02:00
"""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:
2020-10-05 23:41:57 +02:00
return
new_state = event.data.get("new_state")
2020-10-09 09:43:37 +02:00
if new_state is not None and new_state.state == STATE_ON:
2020-10-05 23:41:57 +02:00
_LOGGER.debug(
"Detected a '%s' 'state_changed' event: '%s' with context.id='%s'",
2020-10-05 23:41:57 +02:00
entity_id,
new_state.attributes,
new_state.context.id,
2020-10-05 23:41:57 +02:00
)
2020-10-09 09:43:37 +02:00
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, ...)`.
2020-10-09 15:07:50 +02:00
# 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).
2022-04-17 19:50:55 -07:00
old_state: list[State] | None = 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]
2020-10-05 23:41:57 +02:00
2020-10-03 14:55:02 +02:00
def is_manually_controlled(
self,
switch: AdaptiveSwitch,
2020-10-03 14:55:02 +02:00
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:
2020-10-03 14:55:02 +02:00
# 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)
2020-10-03 14:55:02 +02:00
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
2020-10-04 15:09:58 +02:00
async def significant_change(
2020-10-04 23:15:12 +02:00
self,
switch: AdaptiveSwitch,
light: str,
adapt_brightness: bool,
adapt_color: bool,
context: Context,
) -> bool:
2020-10-04 14:26:42 +02:00
"""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
2020-10-04 14:26:42 +02:00
or switch is turned 'off' and 'on' again.
"""
2020-10-05 23:41:57 +02:00
if light not in self.last_state_change:
2020-10-04 14:26:42 +02:00
return False
2022-04-17 19:50:55 -07:00
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)
2020-10-09 15:07:50 +02:00
compare_to = functools.partial(
_attributes_have_changed,
light=light,
new_attributes=new_state.attributes,
adapt_brightness=adapt_brightness,
adapt_color=adapt_color,
2020-10-09 15:07:50 +02:00
context=context,
)
for index, old_state in enumerate(old_states):
2020-10-09 15:07:50 +02:00
changed = compare_to(old_attributes=old_state.attributes)
if not changed:
2020-10-04 14:26:42 +02:00
_LOGGER.debug(
2020-10-09 15:07:50 +02:00
"State of '%s' didn't change wrt change event nr. %s (context.id=%s)",
2020-10-04 14:26:42 +02:00
light,
index,
context.id,
2020-10-04 14:26:42 +02:00
)
break
2020-10-04 14:26:42 +02:00
2020-10-09 15:07:50 +02:00
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,
)
2020-10-13 22:39:38 +02:00
n_changes = self.cnt_significant_changes[light]
if changed:
self.cnt_significant_changes[light] += 1
if n_changes >= self.max_cnt_significant_changes:
2020-10-14 07:50:45 +02:00
# Only mark a light as significantly changing, if changed==True
# N times in a row. We do this because sometimes a state changes
2020-10-13 22:39:38 +02:00
# 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)
2020-10-13 22:39:38 +02:00
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
2020-10-04 14:26:42 +02:00
return changed
2020-09-28 13:10:41 +02:00
async def maybe_cancel_adjusting(
2022-04-17 19:50:55 -07:00
self, entity_id: str, off_to_on_event: Event, on_to_off_event: Event | None
2020-09-28 13:10:41 +02:00
) -> bool:
2020-09-27 16:21:42 +02:00
"""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.
"""
2020-09-28 13:10:41 +02:00
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)
2020-10-05 09:37:57 +02:00
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
2020-09-28 00:26:21 +02:00
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
2020-09-28 13:10:41 +02:00
2020-09-28 14:35:32 +02:00
if (
2020-10-04 23:15:12 +02:00
turn_off_event is not None
and id_on_to_off == turn_off_event.context.id
2020-09-28 14:35:32 +02:00
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
2020-09-28 00:26:21 +02:00
# from the same event, so wait at least the 'turn_off' transition time.
2020-09-28 14:35:32 +02:00
delay = max(transition, TURNING_OFF_DELAY)
2020-09-27 16:21:42 +02:00
else:
2020-09-28 00:26:21 +02:00
# State change 'off' → 'on' happened because the light state was set.
# Possibly because of polling.
2020-09-27 16:21:42 +02:00
delay = TURNING_OFF_DELAY
2020-09-28 13:10:41 +02:00
delta_time = (dt_util.utcnow() - on_to_off_event.time_fired).total_seconds()
if delta_time > delay:
return False
2020-09-28 13:10:41 +02:00
# 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
2020-09-28 13:10:41 +02:00
# is 'off' or the time has passed.
delay -= delta_time # delta_time has passed since the 'off' → 'on' event
2020-09-28 14:35:32 +02:00
_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
2020-09-28 14:35:32 +02:00
# 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
2020-09-28 14:35:32 +02:00
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:
2020-09-28 14:35:32 +02:00
# 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
2020-09-30 10:29:47 +02:00
# 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.
2020-09-28 14:35:32 +02:00
return False