adaptive-lighting/custom_components/adaptive_lighting/switch.py

747 lines
27 KiB
Python
Raw Normal View History

2020-09-28 13:10:41 +02:00
"""Switch for the Adaptive Lighting integration."""
import asyncio
2020-09-19 11:10:54 +02:00
import bisect
2020-09-24 20:56:01 +02:00
from copy import deepcopy
import datetime
2020-09-11 20:31:51 +02:00
from datetime import timedelta
2020-09-26 17:39:17 +02:00
import logging
2020-09-30 00:10:49 +02:00
from typing import Dict, List, Tuple
2020-09-25 10:27:02 +02:00
import voluptuous as vol
2020-08-25 18:08:33 +02:00
from homeassistant.components.light import (
ATTR_BRIGHTNESS_PCT,
2020-08-25 18:08:33 +02:00
ATTR_COLOR_TEMP,
ATTR_RGB_COLOR,
ATTR_TRANSITION,
2020-09-26 17:39:17 +02:00
DOMAIN as LIGHT_DOMAIN,
SUPPORT_BRIGHTNESS,
SUPPORT_COLOR,
SUPPORT_COLOR_TEMP,
SUPPORT_TRANSITION,
2020-09-25 10:27:02 +02:00
VALID_TRANSITION,
is_on,
)
2020-09-28 23:39:55 +02:00
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
2020-08-25 18:08:33 +02:00
from homeassistant.const import (
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,
2020-08-25 18:08:33 +02:00
CONF_NAME,
2020-09-27 16:21:42 +02:00
EVENT_CALL_SERVICE,
2020-09-28 13:10:41 +02:00
EVENT_HOMEASSISTANT_START,
2020-09-27 16:21:42 +02:00
SERVICE_TURN_OFF,
2020-08-25 18:08:33 +02:00
SERVICE_TURN_ON,
STATE_ON,
2020-09-11 20:31:51 +02:00
SUN_EVENT_SUNRISE,
SUN_EVENT_SUNSET,
)
from homeassistant.core import Context, Event
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,
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
2020-08-25 18:08:33 +02:00
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,
)
2020-09-26 17:39:17 +02:00
import homeassistant.util.dt as dt_util
2020-09-12 23:25:36 +02:00
from .const import (
2020-09-28 13:10:41 +02:00
ATTR_TURN_ON_OFF_LISTENER,
2020-09-29 23:11:52 +02:00
CONF_ADAPT_BRIGHTNESS,
CONF_ADAPT_COLOR_TEMP,
CONF_ADAPT_RGB_COLOR,
2020-09-12 23:25:36 +02:00
CONF_DISABLE_ENTITY,
CONF_DISABLE_STATE,
CONF_INITIAL_TRANSITION,
CONF_INTERVAL,
CONF_LIGHTS,
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,
2020-09-12 23:25:36 +02:00
CONF_SLEEP_BRIGHTNESS,
2020-09-15 23:33:19 +02:00
CONF_SLEEP_COLOR_TEMP,
2020-09-12 23:25:36 +02:00
CONF_SLEEP_ENTITY,
CONF_SLEEP_STATE,
CONF_SUNRISE_OFFSET,
CONF_SUNRISE_TIME,
CONF_SUNSET_OFFSET,
CONF_SUNSET_TIME,
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,
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-09-25 16:45:10 +02:00
async def handle_apply(switch, service_call):
2020-09-25 10:27:02 +02:00
"""Handle the entity service apply."""
2020-09-25 16:45:10 +02:00
if not isinstance(switch, AdaptiveSwitch):
2020-09-25 10:27:02 +02:00
raise ValueError("Apply can only be called for a AdaptiveSwitch.")
2020-09-30 00:10:49 +02:00
hass = switch.hass
2020-09-25 16:45:10 +02:00
data = service_call.data
2020-09-30 00:10:49 +02:00
all_lights = _expand_light_groups(hass, data[CONF_LIGHTS])
2020-09-25 16:45:10 +02:00
tasks = [
2020-09-29 23:11:52 +02:00
await switch._adapt_light( # pylint: disable=protected-access
light,
data[CONF_TRANSITION],
2020-09-29 23:39:46 +02:00
data[CONF_ADAPT_BRIGHTNESS],
data[CONF_ADAPT_COLOR_TEMP],
data[CONF_ADAPT_RGB_COLOR],
2020-09-25 16:45:10 +02:00
)
2020-09-30 00:10:49 +02:00
for light in all_lights
if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light)
2020-09-25 16:45:10 +02:00
]
2020-09-25 10:33:49 +02:00
if tasks:
await asyncio.wait(tasks)
2020-09-25 10:27:02 +02:00
2020-09-19 11:10:54 +02:00
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the AdaptiveLighting switch."""
2020-09-28 23:39:55 +02:00
data = hass.data[DOMAIN]
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
switch = AdaptiveSwitch(hass, config_entry, turn_on_off_listener)
2020-09-28 23:39:55 +02:00
data[config_entry.entry_id][SWITCH_DOMAIN] = switch
2020-09-25 10:27:02 +02:00
# Register `apply` service
platform = entity_platform.current_platform.get()
platform.async_register_entity_service(
SERVICE_APPLY,
{
vol.Required(CONF_LIGHTS): cv.entity_ids,
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,
2020-09-29 23:39:46 +02:00
vol.Optional(CONF_ADAPT_BRIGHTNESS, default=True): cv.boolean,
vol.Optional(CONF_ADAPT_COLOR_TEMP, default=True): cv.boolean,
vol.Optional(CONF_ADAPT_RGB_COLOR, default=True): cv.boolean,
vol.Optional(CONF_TURN_ON_LIGHTS, default=False): cv.boolean,
2020-09-25 10:27:02 +02:00
},
handle_apply,
)
async_add_entities([switch], update_before_add=True)
2020-09-24 23:47:22 +02:00
def validate(config_entry):
"""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
2020-09-30 00:10:49 +02:00
def _expand_light_groups(hass, lights) -> List[str]:
all_lights = set()
for light in lights:
state = hass.states.get(light)
if state is None:
_LOGGER.debug("State of %s is None", light)
all_lights.add(light)
elif "entity_id" in state.attributes: # it's a light group
group = state.attributes["entity_id"]
all_lights.update(group)
_LOGGER.debug("Expanded %s to %s", light, group)
else:
all_lights.add(light)
return list(all_lights)
2020-09-12 12:21:40 +02:00
class AdaptiveSwitch(SwitchEntity, RestoreEntity):
"""Representation of a Adaptive Lighting switch."""
2020-09-28 13:10:41 +02:00
def __init__(self, hass, config_entry, turn_on_off_listener):
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
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-09-29 23:11:52 +02:00
self._adapt_brightness = data[CONF_ADAPT_BRIGHTNESS]
self._adapt_color_temp = data[CONF_ADAPT_COLOR_TEMP]
self._adapt_rgb_color = data[CONF_ADAPT_RGB_COLOR]
2020-09-23 19:09:35 +02:00
self._disable_entity = data[CONF_DISABLE_ENTITY]
self._disable_state = data[CONF_DISABLE_STATE]
self._initial_transition = data[CONF_INITIAL_TRANSITION]
self._interval = data[CONF_INTERVAL]
self._max_brightness = data[CONF_MAX_BRIGHTNESS]
self._max_color_temp = data[CONF_MAX_COLOR_TEMP]
self._min_brightness = data[CONF_MIN_BRIGHTNESS]
self._min_color_temp = data[CONF_MIN_COLOR_TEMP]
self._only_once = data[CONF_ONLY_ONCE]
self._prefer_rgb_color = data[CONF_PREFER_RGB_COLOR]
2020-09-23 19:09:35 +02:00
self._sleep_brightness = data[CONF_SLEEP_BRIGHTNESS]
self._sleep_color_temp = data[CONF_SLEEP_COLOR_TEMP]
self._sleep_entity = data[CONF_SLEEP_ENTITY]
self._sleep_state = data[CONF_SLEEP_STATE]
self._sunrise_offset = data[CONF_SUNRISE_OFFSET]
self._sunrise_time = data[CONF_SUNRISE_TIME]
self._sunset_offset = data[CONF_SUNSET_OFFSET]
self._sunset_time = data[CONF_SUNSET_TIME]
self._transition = data[CONF_TRANSITION]
2020-09-25 00:00:35 +02:00
# Set other attributes
self._icon = ICON
self._entity_id = f"switch.{DOMAIN}_{slugify(self._name)}"
self._state = None
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: Dict[str, Event] = {}
# Locks that prevent light adjusting when waiting for a light to 'turn_off'
self._locks: Dict[str, asyncio.Lock] = {}
2020-09-25 00:00:35 +02:00
2020-09-12 14:33:57 +02:00
# Initialize attributes that will be set in self._update_attrs
2020-09-11 20:31:51 +02:00
self._percent = None
self._brightness = None
2020-09-15 21:03:01 +02:00
self._color_temp_kelvin = None
self._color_temp_mired = None
2020-09-11 20:31:51 +02:00
self._rgb_color = None
self._xy_color = None
self._hs_color = None
# 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 entity_id(self):
"""Return the entity ID of the switch."""
return self._entity_id
@property
def name(self):
"""Return the name of the device if any."""
return self._name
@property
def is_on(self):
2020-09-12 12:21:40 +02:00
"""Return true if adaptive lighting is on."""
return self._state
def _supported_features(self, light):
state = self.hass.states.get(light)
supported_features = state.attributes["supported_features"]
return {
key for key, value in _SUPPORT_OPTS.items() if supported_features & value
}
2020-09-28 13:10:41 +02:00
async def async_added_to_hass(self):
"""Call when entity about to be added to hass."""
if self._lights:
if self.hass.is_running:
2020-09-30 00:42:27 +02:00
await self._setup_listeners()
2020-09-28 13:10:41 +02:00
else:
self.hass.bus.async_listen_once(
2020-09-30 00:42:27 +02:00
EVENT_HOMEASSISTANT_START, self._setup_listeners
2020-09-28 13:10:41 +02:00
)
last_state = await self.async_get_last_state()
if last_state and last_state.state == STATE_ON:
self._state = True
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-28 13:10:41 +02:00
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
2020-09-30 00:42:27 +02:00
async def _setup_listeners(self, _=None):
if not self.is_on:
return
assert not self.remove_listeners
2020-09-30 00:10:49 +02:00
self._expand_light_groups()
rm_interval = async_track_time_interval(
self.hass, self._async_update_at_interval, self._interval
)
rm_state = async_track_state_change_event(
self.hass, self._lights, self._light_event
2020-09-28 23:39:55 +02:00
)
2020-09-30 00:42:27 +02:00
self.remove_listeners.extend([rm_interval, rm_state])
2020-09-28 13:10:41 +02:00
track_kwargs = dict(hass=self.hass, action=self._state_changed)
if self._sleep_entity is not None:
kwgs = dict(track_kwargs, entity_ids=self._sleep_entity)
rm_from = async_track_state_change(**kwgs, from_state=self._sleep_state)
rm_to = async_track_state_change(**kwgs, to_state=self._sleep_state)
2020-09-30 00:42:27 +02:00
self.remove_listeners.extend([rm_from, rm_to])
2020-09-28 13:10:41 +02:00
if self._disable_entity is not None:
kwgs = dict(track_kwargs, entity_ids=self._disable_entity)
rm_from = async_track_state_change(**kwgs, from_state=self._disable_state)
rm_to = async_track_state_change(**kwgs, to_state=self._disable_state)
2020-09-30 00:42:27 +02:00
self.remove_listeners.extend([rm_from, rm_to])
2020-09-30 00:42:27 +02:00
def _remove_listeners(self):
while self.remove_listeners:
remove_listener = self.remove_listeners.pop()
remove_listener()
2020-09-29 10:52:22 +02:00
@property
def icon(self):
"""Icon to use in the frontend, if any."""
return self._icon
@property
def device_state_attributes(self):
"""Return the attributes of the switch."""
2020-09-12 12:30:26 +02:00
attrs = {
"percent": self._percent,
"brightness": self._brightness,
2020-09-15 21:03:01 +02:00
"color_temp_kelvin": self._color_temp_kelvin,
"color_temp_mired": self._color_temp_mired,
2020-09-12 12:30:26 +02:00
"rgb_color": self._rgb_color,
"xy_color": self._xy_color,
"hs_color": self._hs_color,
}
if not self.is_on:
2020-09-27 16:21:42 +02:00
return {key: None for key in attrs}
2020-09-12 12:30:26 +02:00
return attrs
2020-09-29 10:48:35 +02:00
async def async_turn_on(
2020-09-30 00:42:27 +02:00
self, adapt_lights=True
2020-09-29 10:48:35 +02:00
): # pylint: disable=arguments-differ
2020-09-12 12:21:40 +02:00
"""Turn on adaptive lighting."""
2020-09-28 23:39:55 +02:00
if self.is_on:
return
self._state = True
2020-09-30 00:42:27 +02:00
await self._setup_listeners()
2020-09-29 23:11:52 +02:00
if adapt_lights:
2020-09-28 21:08:12 +02:00
await self._update_lights(transition=self._initial_transition, force=True)
2020-09-05 20:16:15 +02:00
async def async_turn_off(self, **kwargs):
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-09-29 10:48:35 +02:00
async def _update_attrs(self):
2020-09-12 12:21:40 +02:00
"""Update Adaptive Values."""
# Setting all values because this method takes <0.5ms to execute.
2020-09-11 20:58:43 +02:00
self._percent = self._calc_percent()
2020-09-30 00:42:27 +02:00
self._brightness = self._calc_brightness()
2020-09-15 21:03:01 +02:00
self._color_temp_kelvin = self._calc_color_temp_kelvin()
self._color_temp_mired = color_temperature_kelvin_to_mired(
self._color_temp_kelvin
)
2020-09-15 21:03:01 +02:00
self._rgb_color = color_temperature_to_rgb(self._color_temp_kelvin)
2020-09-11 20:58:43 +02:00
self._xy_color = color_RGB_to_xy(*self._rgb_color)
self._hs_color = color_xy_to_hs(*self._xy_color)
self.async_write_ha_state()
2020-09-27 16:21:42 +02:00
_LOGGER.debug("%s: '_update_attrs' called", self._name)
2020-09-11 20:58:43 +02:00
async def _async_update_at_interval(self, now=None):
await self._update_lights(force=False)
2020-09-11 20:58:43 +02:00
async def _update_lights(self, lights=None, transition=None, force=False):
await self._update_attrs()
2020-09-11 20:58:43 +02:00
if self._only_once and not force:
return
2020-09-29 23:11:52 +02:00
await self._adapt_lights(lights or self._lights, transition)
def _get_sun_events(self, date):
2020-09-11 20:58:43 +02:00
def _replace_time(date, key):
time = getattr(self, f"_{key}_time")
2020-09-28 21:16:10 +02:00
date_time = datetime.datetime.combine(datetime.date.today(), time)
time_zone = self.hass.config.time_zone
utc_time = time_zone.localize(date_time).astimezone(dt_util.UTC)
return date.replace(
hour=utc_time.hour,
minute=utc_time.minute,
second=utc_time.second,
microsecond=utc_time.microsecond,
)
2020-09-11 20:31:51 +02:00
2020-09-11 20:58:43 +02:00
location = get_astral_location(self.hass)
sunrise = (
location.sunrise(date, local=False)
if self._sunrise_time is None
else _replace_time(date, "sunrise")
) + self._sunrise_offset
sunset = (
location.sunset(date, local=False)
if self._sunset_time is None
else _replace_time(date, "sunset")
) + self._sunset_offset
2020-09-12 13:03:26 +02:00
2020-09-12 12:21:40 +02:00
if self._sunrise_time is None and self._sunset_time is None:
2020-09-12 13:18:15 +02:00
solar_noon = location.solar_noon(date, local=False)
solar_midnight = location.solar_midnight(date, local=False)
2020-09-11 20:58:43 +02:00
else:
solar_noon = sunrise + (sunset - sunrise) / 2
solar_midnight = sunset + ((sunrise + timedelta(days=1)) - sunset) / 2
2020-09-11 20:31:51 +02:00
2020-09-19 12:41:12 +02:00
events = [
2020-09-19 12:18:50 +02:00
(SUN_EVENT_SUNRISE, sunrise.timestamp()),
(SUN_EVENT_SUNSET, sunset.timestamp()),
(SUN_EVENT_NOON, solar_noon.timestamp()),
(SUN_EVENT_MIDNIGHT, solar_midnight.timestamp()),
]
2020-09-19 12:41:12 +02:00
# Check whether order is correct
events = sorted(events, key=lambda x: x[1])
events_names, _ = zip(*events)
assert events_names in _ALLOWED_ORDERS, events_names
2020-09-19 12:41:12 +02:00
return events
2020-09-11 20:31:51 +02:00
2020-09-19 11:10:54 +02:00
def _relevant_events(self, now):
2020-09-19 12:18:50 +02:00
events = [
self._get_sun_events(now + timedelta(days=days)) for days in [-1, 0, 1]
]
events = sum(events, []) # flatten lists
2020-09-19 11:10:54 +02:00
events = sorted(events, key=lambda x: x[1])
i_now = bisect.bisect([ts for _, ts in events], now.timestamp())
2020-09-19 12:18:50 +02:00
return events[i_now - 1 : i_now + 1]
2020-09-19 11:10:54 +02:00
2020-09-11 20:31:51 +02:00
def _calc_percent(self):
now = dt_util.utcnow()
now_ts = now.timestamp()
2020-09-19 11:10:54 +02:00
today = self._relevant_events(now)
2020-09-27 16:21:42 +02:00
(_, prev_ts), (next_event, next_ts) = today
h, x = ( # pylint: disable=invalid-name
2020-09-19 12:18:50 +02:00
(prev_ts, next_ts)
2020-09-19 12:24:15 +02:00
if next_event in (SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE)
2020-09-19 12:18:50 +02:00
else (next_ts, prev_ts)
)
2020-09-19 12:24:15 +02:00
k = 1 if next_event in (SUN_EVENT_SUNSET, SUN_EVENT_NOON) else -1
2020-09-19 12:18:50 +02:00
percentage = (0 - k) * ((now_ts - h) / (h - x)) ** 2 + k
2020-09-11 20:31:51 +02:00
return percentage
2020-09-11 20:31:51 +02:00
def _is_sleep(self):
return (
self._sleep_entity is not None
and self.hass.states.get(self._sleep_entity).state in self._sleep_state
)
2020-09-15 21:03:01 +02:00
def _calc_color_temp_kelvin(self):
2020-09-11 20:31:51 +02:00
if self._is_sleep():
2020-09-15 21:03:01 +02:00
return self._sleep_color_temp
2020-09-11 20:31:51 +02:00
if self._percent > 0:
2020-09-15 21:03:01 +02:00
delta = self._max_color_temp - self._min_color_temp
return (delta * self._percent) + self._min_color_temp
return self._min_color_temp
2020-09-01 18:55:12 +02:00
def _calc_brightness(self) -> float:
2020-09-05 20:14:51 +02:00
if self._is_sleep():
2020-08-25 18:23:12 +02:00
return self._sleep_brightness
2020-09-11 20:31:51 +02:00
if self._percent > 0:
2020-08-25 18:23:12 +02:00
return self._max_brightness
2020-08-31 23:46:36 +02:00
delta_brightness = self._max_brightness - self._min_brightness
percent = 1 + self._percent
2020-09-01 08:38:19 +02:00
return (delta_brightness * percent) + self._min_brightness
def _is_disabled(self):
return (
self._disable_entity is not None
and self.hass.states.get(self._disable_entity).state in self._disable_state
)
2020-09-29 23:39:46 +02:00
async def _adapt_light(
self,
light,
transition=None,
adapt_brightness=None,
adapt_color_temp=None,
adapt_rgb_color=None,
):
2020-09-15 20:40:24 +02:00
service_data = {ATTR_ENTITY_ID: light}
features = self._supported_features(light)
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
if adapt_color_temp is None:
adapt_color_temp = self._adapt_color_temp
if adapt_rgb_color is None:
adapt_rgb_color = self._adapt_rgb_color
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-09-15 20:40:24 +02:00
service_data[ATTR_BRIGHTNESS_PCT] = self._brightness
if (
"color_temp" in features
2020-09-29 23:39:46 +02:00
and adapt_color_temp
2020-09-28 23:39:55 +02:00
and not (self._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"]
color_temp_mired = max(min(self._color_temp_mired, max_mireds), min_mireds)
service_data[ATTR_COLOR_TEMP] = color_temp_mired
2020-09-29 23:39:46 +02:00
elif "color" in features and adapt_rgb_color:
2020-09-15 20:40:24 +02:00
service_data[ATTR_RGB_COLOR] = self._rgb_color
_LOGGER.debug(
2020-09-27 16:21:42 +02:00
"%s: Scheduling 'light.turn_on' with the following 'service_data': %s",
self._name,
service_data,
)
return self.hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
service_data,
context=Context(),
2020-09-15 20:40:24 +02:00
)
2020-09-30 00:42:27 +02:00
def _should_adapt(self):
if not self._lights or not self.is_on or self._is_disabled():
return False
2020-08-31 23:46:36 +02:00
return True
2020-09-29 23:11:52 +02:00
async def _adapt_lights(self, lights, transition):
2020-09-30 00:42:27 +02:00
if not self._should_adapt():
2020-08-25 18:25:36 +02:00
return
_LOGGER.debug(
2020-09-29 23:11:52 +02:00
"%s: '_adapt_lights(%s, %s)' called", self.name, lights, transition
)
tasks = [
2020-09-29 23:11:52 +02:00
await self._adapt_light(light, transition)
for light in lights
if is_on(self.hass, light)
]
if tasks:
await asyncio.wait(tasks)
async def _state_changed(self, entity_id, from_state, to_state):
2020-09-20 22:44:14 +02:00
_LOGGER.debug(
2020-09-27 16:21:42 +02:00
"%s: _state_changed, from_state: '%s', to_state: '%s'",
self._name,
from_state,
to_state,
2020-09-20 22:44:14 +02:00
)
2020-09-28 13:10:41 +02:00
lock = self._locks.get(entity_id)
if lock is not None and lock.locked:
return
2020-09-12 12:21:40 +02:00
await self._update_lights(transition=self._initial_transition, force=True)
async def _light_event(self, event):
old_state = event.data.get("old_state")
new_state = event.data.get("new_state")
entity_id = event.data.get("entity_id")
if (
old_state is not None
and old_state.state == "off"
and new_state is not None
and new_state.state == "on"
):
2020-09-27 16:21:42 +02:00
_LOGGER.debug(
"%s: Detected an 'off' → 'on' event for '%s'", self._name, entity_id
)
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
await self._update_lights(
lights=[entity_id],
transition=self._initial_transition,
force=True,
)
2020-09-27 16:21:42 +02:00
elif (
old_state is not None
and old_state.state == "on"
and new_state is not None
and new_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-09-27 16:21:42 +02:00
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
def __init__(self, hass):
"""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
2020-09-28 13:10:41 +02:00
self.turn_off_event: Dict[str, Tuple[str, float]] = {}
# Tracks 'light.turn_on' service calls
self.turn_on_event: Dict[str, Tuple[str]] = {}
self.sleep_tasks: Dict[str, asyncio.Task] = {}
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-09-28 13:10:41 +02:00
async def maybe_cancel_adjusting(
self, entity_id, off_to_on_event, on_to_off_event
) -> 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
id_turn_off, transition = self.turn_off_event.get(entity_id, (None, None))
id_turn_on = self.turn_on_event.get(entity_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 (
id_on_to_off == id_turn_off
and id_on_to_off is not None
and transition is not None # 'turn_off' is called with transition=...
):
# State change 'on' → 'off' and 'light.turn_off(..., transition=...)' come
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 or disable_state) for some time, we wait below until the light
# is 'off' or the time has passed.
delay -= delta_time # delta_time has passed since the 'off' → 'on' event
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-28 14:35:32 +02:00
return False
2020-09-27 16:21:42 +02:00
2020-09-28 13:10:41 +02:00
async def turn_on_off_event_listener(self, event):
2020-09-28 14:35:32 +02:00
"""Track 'light.turn_off' and 'light.turn_on' service calls."""
2020-09-28 00:26:21 +02:00
domain = event.data.get(ATTR_DOMAIN)
if domain != LIGHT_DOMAIN:
2020-09-27 16:21:42 +02:00
return
2020-09-28 13:10:41 +02:00
2020-09-28 00:26:21 +02:00
service = event.data.get(ATTR_SERVICE)
2020-09-27 16:21:42 +02:00
service_data = event.data.get(ATTR_SERVICE_DATA, {})
2020-09-28 13:10:41 +02:00
entity_ids = service_data.get(ATTR_ENTITY_ID)
if isinstance(entity_ids, str):
entity_ids = [entity_ids]
2020-09-28 00:26:21 +02:00
if not any(eid in self.lights for eid in entity_ids):
2020-09-28 13:10:41 +02:00
return
2020-09-28 00:26:21 +02:00
if service == SERVICE_TURN_OFF:
transition = service_data.get(ATTR_TRANSITION)
2020-09-28 14:35:32 +02:00
_LOGGER.debug(
"Detected an 'light.turn_off('%s', transition=%s)' event",
entity_ids,
2020-09-28 14:35:32 +02:00
transition,
)
for eid in entity_ids:
2020-09-28 14:35:32 +02:00
self.turn_off_event[eid] = (event.context.id, transition)
2020-09-28 13:10:41 +02:00
2020-09-28 00:26:21 +02:00
elif service == SERVICE_TURN_ON:
_LOGGER.debug("Detected an 'light.turn_on('%s')' event", entity_ids)
for eid in entity_ids:
task = self.sleep_tasks.get(eid)
if task is not None:
task.cancel()
2020-09-28 13:10:41 +02:00
self.turn_on_event[eid] = event.context.id