diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index c9dfd3a5..ccfeffa8 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -12,9 +12,6 @@ SUN_EVENT_MIDNIGHT = "solar_midnight" CONF_NAME, DEFAULT_NAME = "name", "default" CONF_LIGHTS, DEFAULT_LIGHTS = "lights", [] -CONF_ADAPT_BRIGHTNESS, DEFAULT_ADAPT_BRIGHTNESS = "adapt_brightness", True -CONF_ADAPT_COLOR_TEMP, DEFAULT_ADAPT_COLOR_TEMP = "adapt_color_temp", True -CONF_ADAPT_RGB_COLOR, DEFAULT_ADAPT_RGB_COLOR = "adapt_rgb_color", True CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES = ( "detect_non_ha_changes", False, @@ -37,9 +34,13 @@ CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", True CONF_TRANSITION, DEFAULT_TRANSITION = "transition", 45 SLEEP_MODE_SWITCH = "sleep_mode_switch" +ADAPT_COLOR_SWITCH = "adapt_color_switch" +ADAPT_BRIGHTNESS_SWITCH = "adapt_brightness_switch" ATTR_TURN_ON_OFF_LISTENER = "turn_on_off_listener" UNDO_UPDATE_LISTENER = "undo_update_listener" NONE_STR = "None" +ATTR_ADAPT_COLOR = "adapt_color" +ATTR_ADAPT_BRIGHTNESS = "adapt_brightness" SERVICE_SET_MANUAL_CONTROL = "set_manual_control" CONF_MANUAL_CONTROL = "manual_control" @@ -56,9 +57,6 @@ def int_between(min_int, max_int): VALIDATION_TUPLES = [ (CONF_LIGHTS, DEFAULT_LIGHTS, cv.entity_ids), - (CONF_ADAPT_BRIGHTNESS, DEFAULT_ADAPT_BRIGHTNESS, bool), - (CONF_ADAPT_COLOR_TEMP, DEFAULT_ADAPT_COLOR_TEMP, bool), - (CONF_ADAPT_RGB_COLOR, DEFAULT_ADAPT_RGB_COLOR, bool), (CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR, bool), (CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION), (CONF_TRANSITION, DEFAULT_TRANSITION, VALID_TRANSITION), diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 8c662cf6..1f16680f 100755 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -19,9 +19,17 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, + ATTR_COLOR_NAME, ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + ATTR_KELVIN, ATTR_RGB_COLOR, ATTR_TRANSITION, + ATTR_WHITE_VALUE, + ATTR_XY_COLOR, DOMAIN as LIGHT_DOMAIN, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, @@ -64,6 +72,7 @@ from homeassistant.helpers.event import ( ) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.sun import get_astral_location +from homeassistant.util import slugify from homeassistant.util.color import ( color_RGB_to_xy, color_temperature_kelvin_to_mired, @@ -73,10 +82,11 @@ from homeassistant.util.color import ( import homeassistant.util.dt as dt_util from .const import ( + ADAPT_BRIGHTNESS_SWITCH, + ADAPT_COLOR_SWITCH, + ATTR_ADAPT_BRIGHTNESS, + ATTR_ADAPT_COLOR, ATTR_TURN_ON_OFF_LISTENER, - CONF_ADAPT_BRIGHTNESS, - CONF_ADAPT_COLOR_TEMP, - CONF_ADAPT_RGB_COLOR, CONF_DETECT_NON_HA_CHANGES, CONF_INITIAL_TRANSITION, CONF_INTERVAL, @@ -129,6 +139,23 @@ BRIGHTNESS_CHANGE = 25 # ≈10% of total range COLOR_TEMP_CHANGE = 20 # ≈5% of total range RGB_REDMEAN_CHANGE = 80 # ≈10% of total range +COLOR_ATTRS = { # Should ATTR_PROFILE be in here? + ATTR_COLOR_NAME, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + ATTR_KELVIN, + ATTR_RGB_COLOR, + ATTR_WHITE_VALUE, # Should this be here? + 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" @@ -165,9 +192,8 @@ async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall): await switch._adapt_light( # pylint: disable=protected-access light, data[CONF_TRANSITION], - data[CONF_ADAPT_BRIGHTNESS], - data[CONF_ADAPT_COLOR_TEMP], - data[CONF_ADAPT_RGB_COLOR], + data[ATTR_ADAPT_BRIGHTNESS], + data[ATTR_ADAPT_COLOR], force=True, ) @@ -214,13 +240,27 @@ async def async_setup_entry( data[ATTR_TURN_ON_OFF_LISTENER] = TurnOnOffListener(hass) turn_on_off_listener = data[ATTR_TURN_ON_OFF_LISTENER] - sleep_mode_switch = AdaptiveSleepModeSwitch(hass, config_entry) - switch = AdaptiveSwitch(hass, config_entry, turn_on_off_listener, sleep_mode_switch) + 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, + ) 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 data[config_entry.entry_id][SWITCH_DOMAIN] = switch - async_add_entities([switch, sleep_mode_switch], update_before_add=True) + async_add_entities( + [switch, sleep_mode_switch, adapt_color_switch, adapt_brightness_switch], + update_before_add=True, + ) # Register `apply` service platform = entity_platform.current_platform.get() @@ -232,9 +272,8 @@ async def async_setup_entry( CONF_TRANSITION, default=switch._initial_transition, # pylint: disable=protected-access ): VALID_TRANSITION, - 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(ATTR_ADAPT_BRIGHTNESS, default=True): cv.boolean, + vol.Optional(ATTR_ADAPT_COLOR, default=True): cv.boolean, vol.Optional(CONF_TURN_ON_LIGHTS, default=False): cv.boolean, }, handle_apply, @@ -264,7 +303,7 @@ def validate(config_entry: ConfigEntry): return data -def match_state_event(event: Event, from_or_to_state: List[str]): +def match_switch_state_event(event: Event, from_or_to_state: List[str]): """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 @@ -324,8 +363,7 @@ def _attributes_have_changed( old_attributes: Dict[str, Any], new_attributes: Dict[str, Any], adapt_brightness: bool, - adapt_color_temp: bool, - adapt_rgb_color: bool, + adapt_color: bool, context: Context, ) -> bool: if ( @@ -347,7 +385,7 @@ def _attributes_have_changed( return True if ( - adapt_color_temp + adapt_color and ATTR_COLOR_TEMP in old_attributes and ATTR_COLOR_TEMP in new_attributes ): @@ -365,7 +403,7 @@ def _attributes_have_changed( return True if ( - adapt_rgb_color + adapt_color and ATTR_RGB_COLOR in old_attributes and ATTR_RGB_COLOR in new_attributes ): @@ -407,20 +445,21 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): hass, config_entry: ConfigEntry, turn_on_off_listener: TurnOnOffListener, - sleep_mode_switch: AdaptiveSleepModeSwitch, + sleep_mode_switch: SimpleSwitch, + adapt_color_switch: SimpleSwitch, + adapt_brightness_switch: SimpleSwitch, ): """Initialize the Adaptive Lighting switch.""" self.hass = hass 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 data = validate(config_entry) self._name = data[CONF_NAME] self._lights = data[CONF_LIGHTS] - 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] self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES] self._initial_transition = data[CONF_INITIAL_TRANSITION] self._interval = data[CONF_INTERVAL] @@ -527,12 +566,20 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): 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_state_event, - ) - self.remove_listeners.extend([remove_interval, remove_sleep]) + remove_simple_switches = [ + async_track_state_change_event( + self.hass, + switch.entity_id, + self._simple_switch_state_event, + ) + for switch in ( + self.sleep_mode_switch, + self.adapt_brightness_switch, + self.adapt_color_switch, + ) + ] + + self.remove_listeners.extend([remove_interval, *remove_simple_switches]) if self._lights: self._expand_light_groups() @@ -614,8 +661,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): light: str, transition: Optional[int] = None, adapt_brightness: Optional[bool] = None, - adapt_color_temp: Optional[bool] = None, - adapt_rgb_color: Optional[bool] = None, + adapt_color: Optional[bool] = None, force: bool = False, context: Optional[Context] = None, ) -> None: @@ -629,11 +675,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): 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 + adapt_brightness = self.adapt_brightness_switch.is_on + if adapt_color is None: + adapt_color = self.adapt_color_switch.is_on if "transition" in features: service_data[ATTR_TRANSITION] = transition @@ -644,7 +688,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if ( "color_temp" in features - and adapt_color_temp + and adapt_color and not (self._prefer_rgb_color and "color" in features) ): attributes = self.hass.states.get(light).attributes @@ -652,7 +696,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): color_temp_mired = self._settings["color_temp_mired"] color_temp_mired = max(min(color_temp_mired, max_mireds), min_mireds) service_data[ATTR_COLOR_TEMP] = color_temp_mired - elif "color" in features and adapt_rgb_color: + elif "color" in features and adapt_color: service_data[ATTR_RGB_COLOR] = self._settings["rgb_color"] context = context or self.create_context("adapt_lights") @@ -662,9 +706,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): and not force and await self.turn_on_off_listener.significant_change( light, - self._adapt_brightness, - self._adapt_color_temp, - self._adapt_rgb_color, + adapt_brightness, + adapt_color, context, ) ): @@ -732,6 +775,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): and self.turn_on_off_listener.is_manually_controlled( light, force, + self.adapt_brightness_switch.is_on, + self.adapt_color_switch.is_on, ) ): _LOGGER.debug( @@ -743,15 +788,22 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): continue await self._adapt_light(light, transition, force=force, context=context) - async def _sleep_state_event(self, event: Event) -> None: - if not match_state_event(event, (STATE_ON, STATE_OFF)): + async def _simple_switch_state_event(self, event: Event) -> None: + if not match_switch_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) + which = { + self.sleep_mode_switch.entity_id: "sleep_sw", + self.adapt_color_switch.entity_id: "color_sw", + self.adapt_brightness_switch.entity_id: "brigt_sw", + }[event.data[ATTR_ENTITY_ID]] + _LOGGER.debug("%s: _simple_switch_state_event, event: '%s'", self._name, event) + if which == "sleep_sw": + # 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._initial_transition, force=True, - context=self.create_context("sleep"), + context=self.create_context(which), ) async def _light_event(self, event: Event) -> None: @@ -805,26 +857,32 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self.turn_on_off_listener.reset(entity_id) -class AdaptiveSleepModeSwitch(SwitchEntity, RestoreEntity): +class SimpleSwitch(SwitchEntity, RestoreEntity): """Representation of a Adaptive Lighting switch.""" - def __init__(self, hass: HomeAssistant, config_entry): + 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._name = data[CONF_NAME] self._icon = ICON self._state = None + self._which = which + self._unique_id = f"{self._name}_{slugify(self._which)}" + self._name = f"Adaptive Lighting {which}: {self._name}" + self._initial_state = initial_state @property def name(self): """Return the name of the device if any.""" - return f"Adaptive Lighting Sleep Mode: {self._name}" + return self._name @property def unique_id(self): """Return the unique ID of entity.""" - return f"{self._name}_sleep_mode" + return self._unique_id @property def icon(self) -> str: @@ -839,10 +897,15 @@ class AdaptiveSleepModeSwitch(SwitchEntity, RestoreEntity): 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() - if last_state is None or STATE_OFF: # newly added to HA - await self.async_turn_off() - else: + if last_state is None: # newly added to HA + if self._initial_state: + await self.async_turn_on() + else: + await self.async_turn_off() + elif STATE_ON: await self.async_turn_on() + elif STATE_OFF: + await self.async_turn_off() async def async_turn_on(self, **kwargs) -> None: """Turn on adaptive lighting sleep mode.""" @@ -1124,6 +1187,8 @@ class TurnOnOffListener: self, 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) @@ -1137,26 +1202,29 @@ class TurnOnOffListener: and not is_our_context(turn_on_event.context) and not force ): - # 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(self.hass, 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, - ) + 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(self.hass, 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 async def significant_change( self, light: str, adapt_brightness: bool, - adapt_color_temp: bool, - adapt_rgb_color: bool, + adapt_color: bool, context: Context, ) -> bool: """Has the light made a significant change since last update. @@ -1176,8 +1244,7 @@ class TurnOnOffListener: light=light, new_attributes=new_state.attributes, adapt_brightness=adapt_brightness, - adapt_color_temp=adapt_color_temp, - adapt_rgb_color=adapt_rgb_color, + adapt_color=adapt_color, context=context, ) for index, old_state in enumerate(old_states):