track state_changed

This commit is contained in:
Bas Nijholt 2020-10-05 23:41:57 +02:00
commit 313c1addf4
5 changed files with 88 additions and 49 deletions

View file

@ -25,10 +25,13 @@ Resources:
lights to 2700K (warm white) until your hub goes into "Sleep mode".
"""
import logging
from typing import Any, Dict
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_SOURCE
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from .const import (
@ -45,7 +48,7 @@ PLATFORMS = ["switch"]
def _all_unique_names(value):
"""Validate that all enties have a unique profile name."""
"""Validate that all entities have a unique profile name."""
hosts = [device[CONF_NAME] for device in value]
schema = vol.Schema(vol.Unique())
schema(hosts)
@ -58,20 +61,20 @@ CONFIG_SCHEMA = vol.Schema(
)
async def async_setup(hass, config):
async def async_setup(hass: HomeAssistant, config: Dict[str, Any]):
"""Import integration from config."""
if DOMAIN in config:
for entry in config[DOMAIN]:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=entry
DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=entry
)
)
return True
async def async_setup_entry(hass, config_entry: ConfigEntry):
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry):
"""Set up the component."""
data = hass.data.setdefault(DOMAIN, {})
@ -98,7 +101,9 @@ async def async_unload_entry(hass, config_entry: ConfigEntry) -> bool:
data = hass.data[DOMAIN]
data[config_entry.entry_id][UNDO_UPDATE_LISTENER]()
if len(data) == 1: # no more config_entries
data.pop(ATTR_TURN_ON_OFF_LISTENER).remove_listener()
turn_on_off_listener = data.pop(ATTR_TURN_ON_OFF_LISTENER)
turn_on_off_listener.remove_listener()
turn_on_off_listener.remove_listener2()
if unload_ok:
data.pop(config_entry.entry_id)

View file

@ -4,6 +4,7 @@ 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
@ -29,24 +30,24 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors = {}
if user_input is not None:
await self.async_set_unique_id(user_input["name"])
await self.async_set_unique_id(user_input[CONF_NAME])
self._abort_if_unique_id_configured()
return self.async_create_entry(title=user_input["name"], data=user_input)
return self.async_create_entry(title=user_input[CONF_NAME], data=user_input)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required("name"): str}),
data_schema=vol.Schema({vol.Required(CONF_NAME): str}),
errors=errors,
)
async def async_step_import(self, user_input=None):
"""Handle configuration by yaml file."""
await self.async_set_unique_id(user_input["name"])
await self.async_set_unique_id(user_input[CONF_NAME])
for entry in self._async_current_entries():
if entry.unique_id == self.unique_id:
self.hass.config_entries.async_update_entry(entry, data=user_input)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=user_input["name"], data=user_input)
return self.async_create_entry(title=user_input[CONF_NAME], data=user_input)
@staticmethod
@callback

View file

@ -6,12 +6,12 @@
"title": "Choose a name for the Adaptive Lighting",
"description": "Every instance can contain multiple lights!",
"data": {
"name": "Name"
"name": "[%key:common::config_flow::data::name%]"
}
}
},
"abort": {
"already_configured": "This name is already configured."
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"options": {

View file

@ -41,6 +41,7 @@ from homeassistant.const import (
CONF_NAME,
EVENT_CALL_SERVICE,
EVENT_HOMEASSISTANT_START,
EVENT_STATE_CHANGED,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
@ -48,7 +49,7 @@ from homeassistant.const import (
SUN_EVENT_SUNRISE,
SUN_EVENT_SUNSET,
)
from homeassistant.core import Context, Event, ServiceCall
from homeassistant.core import Context, Event, HomeAssistant, ServiceCall, State
from homeassistant.helpers import entity_platform
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.event import (
@ -114,6 +115,11 @@ _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 = 250 # ≈5% of total range
RGB_CHANGE = 30 # ≈12% of total range per component
async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall):
"""Handle the entity service apply."""
@ -136,7 +142,9 @@ async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall):
)
async def async_setup_entry(hass, config_entry: ConfigEntry, async_add_entities: bool):
async def async_setup_entry(
hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: bool
):
"""Set up the AdaptiveLighting switch."""
data = hass.data[DOMAIN]
@ -197,7 +205,7 @@ def match_state_event(event: Event, from_or_to_state: List[str]):
return match
def _expand_light_groups(hass, lights: List[str]) -> List[str]:
def _expand_light_groups(hass: HomeAssistant, lights: List[str]) -> List[str]:
all_lights = set()
for light in lights:
state = hass.states.get(light)
@ -213,13 +221,13 @@ def _expand_light_groups(hass, lights: List[str]) -> List[str]:
return list(all_lights)
def _supported_features(hass, light: str):
def _supported_features(hass: HomeAssistant, light: str):
state = hass.states.get(light)
supported_features = state.attributes["supported_features"]
return {key for key, value in _SUPPORT_OPTS.items() if supported_features & value}
def abs_rel_diff(val_a, val_b):
def abs_rel_diff(val_a, val_b) -> float:
"""Absolute relative difference in %."""
if val_b == 0:
# To avoid ZeroDivisionError
@ -287,6 +295,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
self._locks: Dict[str, asyncio.Lock] = {}
# To identify that this integration made a change
self.__context = Context() # self._context will be overwritten
self.turn_on_off_listener.contexts.add(self.__context)
# Set in self._update_attrs_and_maybe_adapt_lights
self._settings: Dict[str, Any] = {}
@ -296,12 +305,14 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
_LOGGER.debug(
"%s: Setting up with '%s',"
" config_entry.data: '%s',"
" config_entry.options: '%s', converted to '%s'.",
" config_entry.options: '%s', converted to '%s',"
" with context '%s'.",
self._name,
self._lights,
config_entry.data,
config_entry.options,
data,
self.__context,
)
@property
@ -477,7 +488,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
)
):
return
self.turn_on_off_listener.last_service_data[light] = service_data
_LOGGER.debug(
"%s: Scheduling 'light.turn_on' with the following 'service_data': %s",
self._name,
@ -523,6 +533,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
continue
if (
self._take_over_control
and False # XXX: REMOVE THIS
and self.turn_on_off_listener.is_manually_controlled(
light,
force,
@ -538,7 +549,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
await self._adapt_light(light, transition, force=force)
async def _sleep_state_event(self, event: Event):
if not match_state_event(event, ("on", "off")):
if not match_state_event(event, (STATE_ON, STATE_OFF)):
return
_LOGGER.debug("%s: _sleep_state_event, event: '%s'", self._name, event)
self.turn_on_off_listener.reset(*self._lights)
@ -552,9 +563,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
entity_id = event.data.get("entity_id")
if (
old_state is not None
and old_state.state == "off"
and old_state.state == STATE_OFF
and new_state is not None
and new_state.state == "on"
and new_state.state == STATE_ON
):
_LOGGER.debug(
"%s: Detected an 'off''on' event for '%s'", self._name, entity_id
@ -584,9 +595,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
)
elif (
old_state is not None
and old_state.state == "on"
and old_state.state == STATE_ON
and new_state is not None
and new_state.state == "off"
and new_state.state == STATE_OFF
):
# Tracks 'off' → 'on' state changes
self._on_to_off_event[entity_id] = event
@ -596,7 +607,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
class AdaptiveSleepModeSwitch(SwitchEntity, RestoreEntity):
"""Representation of a Adaptive Lighting switch."""
def __init__(self, hass, config_entry):
def __init__(self, hass: HomeAssistant, config_entry):
"""Initialize the Adaptive Lighting switch."""
self.hass = hass
data = validate(config_entry)
@ -790,6 +801,7 @@ class TurnOnOffListener:
"""Initialize the TurnOnOffListener that is shared among all switches."""
self.hass = hass
self.lights = set()
self.contexts = set() # contexts of different AdaptiveSwitch instances
# Tracks 'light.turn_off' service calls
self.turn_off_event: Dict[str, Event] = {}
@ -799,18 +811,21 @@ class TurnOnOffListener:
self.sleep_tasks: Dict[str, asyncio.Task] = {}
# Tracks which lights are manually controlled
self.manually_controlled: Dict[str, bool] = {}
# Track which settings were applied to a light
self.last_service_data: Dict[str, Dict[str, Any]] = {}
# Track 'state_changed' events of self.lights resulting from this integration
self.last_state_change: Dict[str, State] = {}
self.remove_listener = self.hass.bus.async_listen(
EVENT_CALL_SERVICE, self.turn_on_off_event_listener
)
self.remove_listener2 = self.hass.bus.async_listen(
EVENT_STATE_CHANGED, self.state_changed_event_listener
)
def reset(self, *lights):
"""Reset the 'manually_controlled' status of the lights."""
for light in lights:
self.manually_controlled[light] = False
self.last_service_data.pop(light, None)
self.last_state_change.pop(light, None)
async def turn_on_off_event_listener(self, event: Event):
"""Track 'light.turn_off' and 'light.turn_on' service calls."""
@ -844,6 +859,25 @@ class TurnOnOffListener:
task.cancel()
self.turn_on_event[eid] = event
async def state_changed_event_listener(self, event: Event):
"""Track 'state_changed' events."""
entity_id = event.data.get(ATTR_ENTITY_ID, "")
if entity_id not in self.lights and entity_id.split(".")[0] != LIGHT_DOMAIN:
return
new_state = event.data.get("new_state")
if (
new_state is not None
and new_state.state == STATE_ON
and new_state.context in self.contexts
):
_LOGGER.debug(
"Detected a '%s' 'state_changed' event: '%s'",
entity_id,
new_state.attributes,
)
self.last_state_change[entity_id] = new_state
def is_manually_controlled(
self,
light: str,
@ -881,7 +915,6 @@ class TurnOnOffListener:
adapt_color_temp,
adapt_rgb_color,
context,
threshold=5,
):
"""Has the light made a significant change since last update.
@ -890,10 +923,10 @@ class TurnOnOffListener:
detected, we mark the light as 'manually_controlled' until the light
or switch is turned 'off' and 'on' again.
"""
if light not in self.last_service_data:
if light not in self.last_state_change:
return False
changed = False
service_data = self.last_service_data[light]
old_attributes = self.last_state_change[light].attributes
await self.hass.services.async_call(
HA_DOMAIN,
SERVICE_UPDATE_ENTITY,
@ -904,56 +937,56 @@ class TurnOnOffListener:
attributes = self.hass.states.get(light).attributes
if (
adapt_brightness
and ATTR_BRIGHTNESS_PCT in service_data
and ATTR_BRIGHTNESS in old_attributes
and ATTR_BRIGHTNESS in attributes
):
applied_brightness = round(255 * service_data[ATTR_BRIGHTNESS_PCT] / 100)
current_brightness = attributes["brightness"]
if abs_rel_diff(current_brightness, applied_brightness) > threshold:
last_brightness = old_attributes[ATTR_BRIGHTNESS]
current_brightness = attributes[ATTR_BRIGHTNESS]
if abs(current_brightness - last_brightness) > BRIGHTNESS_CHANGE:
_LOGGER.debug(
"Brightness of '%s' significantly changed from %s to %s",
light,
applied_brightness,
last_brightness,
current_brightness,
)
changed = True
if (
adapt_color_temp
and ATTR_COLOR_TEMP in service_data
and ATTR_COLOR_TEMP in old_attributes
and ATTR_COLOR_TEMP in attributes
):
applied_color_temp = service_data[ATTR_COLOR_TEMP]
last_color_temp = old_attributes[ATTR_COLOR_TEMP]
current_color_temp = attributes[ATTR_COLOR_TEMP]
if abs_rel_diff(current_color_temp, applied_color_temp) > threshold:
if abs(current_color_temp - last_color_temp) > COLOR_TEMP_CHANGE:
_LOGGER.debug(
"Color temperature of '%s' significantly changed from %s to %s",
light,
applied_color_temp,
last_color_temp,
current_color_temp,
)
changed = True
if (
adapt_rgb_color
and ATTR_RGB_COLOR in service_data
and ATTR_RGB_COLOR in old_attributes
and ATTR_RGB_COLOR in attributes
):
applied_rgb_color = service_data[ATTR_RGB_COLOR]
last_rgb_color = old_attributes[ATTR_RGB_COLOR]
current_rgb_color = attributes[ATTR_RGB_COLOR]
for col_applied, col_current in zip(applied_rgb_color, current_rgb_color):
if abs_rel_diff(col_applied, col_current) > threshold:
for last_col, current_col in zip(last_rgb_color, current_rgb_color):
if abs(last_col - current_col) > RGB_CHANGE:
_LOGGER.debug(
"color RGB of '%s' significantly changed from %s to %s",
light,
applied_rgb_color,
last_rgb_color,
current_rgb_color,
)
changed = True
break
if (ATTR_RGB_COLOR in service_data and ATTR_RGB_COLOR not in attributes) or (
ATTR_COLOR_TEMP in service_data and ATTR_COLOR_TEMP not in attributes
if (ATTR_RGB_COLOR in old_attributes and ATTR_RGB_COLOR not in attributes) or (
ATTR_COLOR_TEMP in old_attributes and ATTR_COLOR_TEMP not in attributes
):
# Light switched from RGB mode to color_temp or visa versa
_LOGGER.debug(

View file

@ -6,12 +6,12 @@
"title": "Choose a name for the Adaptive Lighting",
"description": "Every instance can contain multiple lights!",
"data": {
"name": "Name"
"name": "[%key:common::config_flow::data::name%]"
}
}
},
"abort": {
"already_configured": "This name is already configured."
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"options": {