diff --git a/custom_components/adaptive_lighting/__init__.py b/custom_components/adaptive_lighting/__init__.py index b5ec5bc5..be8868f5 100755 --- a/custom_components/adaptive_lighting/__init__.py +++ b/custom_components/adaptive_lighting/__init__.py @@ -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) diff --git a/custom_components/adaptive_lighting/config_flow.py b/custom_components/adaptive_lighting/config_flow.py index 575d7cd7..6bf5e430 100644 --- a/custom_components/adaptive_lighting/config_flow.py +++ b/custom_components/adaptive_lighting/config_flow.py @@ -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 diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index d08cbc19..57949ea0 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -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": { diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 69d92b47..85fed2af 100755 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -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( diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index d08cbc19..57949ea0 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -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": {