mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-12 06:44:04 +02:00
track state_changed
This commit is contained in:
parent
f1c2101148
commit
313c1addf4
5 changed files with 88 additions and 49 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue