diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 5c95f279..eaf3035b 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -273,6 +273,8 @@ VALIDATION_TUPLES = [ ), ] +CONST_COLOR = "color" + def timedelta_as_int(value): """Convert a `datetime.timedelta` object to an integer. diff --git a/custom_components/adaptive_lighting/manifest.json b/custom_components/adaptive_lighting/manifest.json index 92b27dfa..2dec4c83 100644 --- a/custom_components/adaptive_lighting/manifest.json +++ b/custom_components/adaptive_lighting/manifest.json @@ -8,5 +8,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues", "requirements": ["ulid-transform"], - "version": "1.10.1" + "version": "1.11.0" } diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 5b816b67..3eabf7ff 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -23,7 +23,11 @@ from homeassistant.components.light import ( ATTR_COLOR_NAME, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, ATTR_XY_COLOR, @@ -32,6 +36,7 @@ from homeassistant.components.light import ( COLOR_MODE_HS, COLOR_MODE_RGB, COLOR_MODE_RGBW, + COLOR_MODE_RGBWW, COLOR_MODE_XY, ) from homeassistant.components.light import ( @@ -129,6 +134,7 @@ from .const import ( CONF_TRANSITION, CONF_TURN_ON_LIGHTS, CONF_USE_DEFAULTS, + CONST_COLOR, DOMAIN, EXTRA_VALIDATION, ICON_BRIGHTNESS, @@ -155,6 +161,16 @@ _SUPPORT_OPTS = { "transition": SUPPORT_TRANSITION, } +VALID_COLOR_MODES = { + COLOR_MODE_BRIGHTNESS: ATTR_BRIGHTNESS, + COLOR_MODE_COLOR_TEMP: ATTR_COLOR_TEMP_KELVIN, + COLOR_MODE_HS: ATTR_HS_COLOR, + COLOR_MODE_RGB: ATTR_RGB_COLOR, + COLOR_MODE_RGBW: ATTR_RGBW_COLOR, + COLOR_MODE_RGBWW: ATTR_RGBWW_COLOR, + COLOR_MODE_XY: ATTR_XY_COLOR, +} + _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))} @@ -424,6 +440,7 @@ def _fire_manual_control_event( switch.entity_id, light, ) + switch.turn_on_off_listener.mark_as_manual_control(light) fire( f"{DOMAIN}.manual_control", {ATTR_ENTITY_ID: light, SWITCH_DOMAIN: switch.entity_id}, @@ -519,7 +536,6 @@ async def async_setup_entry( all_lights = _expand_light_groups(switch.hass, lights) if service_call.data[CONF_MANUAL_CONTROL]: for light in all_lights: - switch.turn_on_off_listener.mark_as_manual_control(light) _fire_manual_control_event(switch, light, service_call.context) else: switch.turn_on_off_listener.reset(*all_lights) @@ -623,33 +639,51 @@ def _expand_light_groups(hass: HomeAssistant, lights: list[str]) -> list[str]: return list(all_lights) +def _supported_to_attributes(supported): + supported_attributes = {} + supports_colors = False + for mode, attr in VALID_COLOR_MODES.items(): + if mode not in supported: + continue + supported_attributes[attr] = True + if ( + not supports_colors + and mode != COLOR_MODE_BRIGHTNESS + and mode != COLOR_MODE_COLOR_TEMP + ): + supports_colors = True + return supported_attributes, supports_colors + + def _supported_features(hass: HomeAssistant, light: str): state = hass.states.get(light) - supported_features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - supported = { - key for key, value in _SUPPORT_OPTS.items() if supported_features & value + legacy_supported_features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + legacy_supported = { + key for key, value in _SUPPORT_OPTS.items() if legacy_supported_features & value } supported_color_modes = state.attributes.get(ATTR_SUPPORTED_COLOR_MODES, set()) - if COLOR_MODE_RGB in supported_color_modes: - supported.add("color") + supported, supports_colors = _supported_to_attributes( + legacy_supported.union(supported_color_modes) + ) + min_kelvin = state.attributes.get(ATTR_MIN_COLOR_TEMP_KELVIN) + max_kelvin = state.attributes.get(ATTR_MAX_COLOR_TEMP_KELVIN) + supported.update( + { + ATTR_MIN_COLOR_TEMP_KELVIN: min_kelvin, + ATTR_MAX_COLOR_TEMP_KELVIN: max_kelvin, + } + ) + if supports_colors: # Adding brightness here, see # comment https://github.com/basnijholt/adaptive-lighting/issues/112#issuecomment-836944011 - supported.add("brightness") - if COLOR_MODE_RGBW in supported_color_modes: - supported.add("color") - supported.add("brightness") # see above url - if COLOR_MODE_XY in supported_color_modes: - supported.add("color") - supported.add("brightness") # see above url - if COLOR_MODE_HS in supported_color_modes: - supported.add("color") - supported.add("brightness") # see above url - if COLOR_MODE_COLOR_TEMP in supported_color_modes: - supported.add("color_temp") - supported.add("brightness") # see above url - if COLOR_MODE_BRIGHTNESS in supported_color_modes: - supported.add("brightness") - return supported + supported[ATTR_BRIGHTNESS] = True + if CONST_COLOR not in legacy_supported: + # supports_colors = False + _LOGGER.debug( + "'supported_color_modes' supports color but the legacy 'supported_features'" + " bitfield says we do not. Despite this we'll assume light '%s' supports colors", + ) + return supported, supports_colors def color_difference_redmean( @@ -1014,6 +1048,12 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if self.turn_on_off_listener.manual_control.get(light) ] extra_state_attributes.update(self._settings) + timers = self.turn_on_off_listener.auto_reset_manual_control_timers + extra_state_attributes["autoreset_time_remaining"] = { + light: time + for light in self._lights + if (timer := timers.get(light)) and (time := timer.remaining_time()) > 0 + } return extra_state_attributes def create_context( @@ -1082,9 +1122,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if lock is not None and lock.locked(): _LOGGER.debug("%s: '%s' is locked", self._name, light) return - service_data = {ATTR_ENTITY_ID: light} - features = _supported_features(self.hass, light) - if transition is None: transition = self._transition if adapt_brightness is None: @@ -1094,16 +1131,19 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if prefer_rgb_color is None: prefer_rgb_color = self._prefer_rgb_color - # Check transition == 0 to fix #378 - if "transition" in features and transition > 0: - service_data[ATTR_TRANSITION] = transition - # The switch might be off and not have _settings set. self._settings = self._sun_light_settings.get_settings( self.sleep_mode_switch.is_on, transition ) - if "brightness" in features and adapt_brightness: + # Build service data. + service_data = {ATTR_ENTITY_ID: light} + features, supports_colors = _supported_features(self.hass, light) + + # Check transition == 0 to fix #378 + if ATTR_TRANSITION in features and transition > 0: + service_data[ATTR_TRANSITION] = transition + if ATTR_BRIGHTNESS in features and adapt_brightness: brightness = round(255 * self._settings["brightness_pct"] / 100) service_data[ATTR_BRIGHTNESS] = brightness @@ -1112,36 +1152,23 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): and self._sun_light_settings.sleep_rgb_or_color_temp == "rgb_color" ) if ( - "color_temp" in features + ATTR_COLOR_TEMP_KELVIN in features and adapt_color - and not (prefer_rgb_color and "color" in features) - and not (sleep_rgb and "color" in features) + and not (prefer_rgb_color and supports_colors) + and not (sleep_rgb and supports_colors) ): _LOGGER.debug("%s: Setting color_temp of light %s", self._name, light) - attributes = self.hass.states.get(light).attributes - min_kelvin = attributes["min_color_temp_kelvin"] - max_kelvin = attributes["max_color_temp_kelvin"] + min_kelvin = features[ATTR_MIN_COLOR_TEMP_KELVIN] + max_kelvin = features[ATTR_MAX_COLOR_TEMP_KELVIN] color_temp_kelvin = self._settings["color_temp_kelvin"] color_temp_kelvin = max(min(color_temp_kelvin, max_kelvin), min_kelvin) service_data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin - elif "color" in features and adapt_color: + elif supports_colors and adapt_color: _LOGGER.debug("%s: Setting rgb_color of light %s", self._name, light) service_data[ATTR_RGB_COLOR] = self._settings["rgb_color"] context = context or self.create_context("adapt_lights") - if ( - self._take_over_control - and self._detect_non_ha_changes - and not force - and await self.turn_on_off_listener.significant_change( - self, - light, - adapt_brightness, - adapt_color, - context, - ) - ): - return + # See #80. Doesn't check if transitions differ but it does the job. last_service_data = self.turn_on_off_listener.last_service_data if not force and last_service_data.get(light) == service_data: @@ -1230,9 +1257,11 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if not filtered_lights: return - await self._adapt_lights(filtered_lights, transition, force, context) + await self._update_manual_control_and_maybe_adapt( + filtered_lights, transition, force, context + ) - async def _adapt_lights( + async def _update_manual_control_and_maybe_adapt( self, lights: list[str], transition: int | None, @@ -1241,34 +1270,53 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): ) -> None: assert context is not None _LOGGER.debug( - "%s: '_adapt_lights(%s, %s, force=%s, context.id=%s)' called", + "%s: '_update_manual_control_and_maybe_adapt(%s, %s, force=%s, context.id=%s)' called", self.name, lights, transition, force, context.id, ) + + adapt_brightness = self.adapt_brightness_switch.is_on + adapt_color = self.adapt_color_switch.is_on + for light in lights: if not is_on(self.hass, light): continue - if ( - self._take_over_control - and self.turn_on_off_listener.is_manually_controlled( + + manually_controlled = self.turn_on_off_listener.is_manually_controlled( + self, + light, + force, + adapt_brightness, + adapt_color, + ) + + significant_change = ( + self._detect_non_ha_changes + and not force + and await self.turn_on_off_listener.significant_change( self, light, - force, - self.adapt_brightness_switch.is_on, - self.adapt_color_switch.is_on, + adapt_brightness, + adapt_color, + context, ) - ): - _LOGGER.debug( - "%s: '%s' is being manually controlled, stop adapting, context.id=%s.", - self._name, - light, - context.id, - ) - continue - await self._adapt_light(light, transition, force=force, context=context) + ) + + if self._take_over_control and (manually_controlled or significant_change): + if manually_controlled: + _LOGGER.debug( + "%s: '%s' is being manually controlled, stop adapting, context.id=%s.", + self._name, + light, + context.id, + ) + else: + _fire_manual_control_event(self, light, context) + else: + await self._adapt_light(light, transition, force=force, context=context) async def _sleep_mode_switch_state_event(self, event: Event) -> None: if not match_switch_state_event(event, (STATE_ON, STATE_OFF)): @@ -1894,7 +1942,7 @@ class TurnOnOffListener: ): # Light was already on and 'light.turn_on' was not called by # the adaptive_lighting integration. - manual_control = self.mark_as_manual_control(light) + manual_control = True _fire_manual_control_event(switch, light, turn_on_event.context) _LOGGER.debug( "'%s' was already on and 'light.turn_on' was not called by the" @@ -1962,8 +2010,6 @@ class TurnOnOffListener: light, context.id, ) - self.mark_as_manual_control(light) - _fire_manual_control_event(switch, light, context, is_async=False) return True _LOGGER.debug( "%s: Light '%s' correctly matches our last adapt's service data, continuing..." @@ -2106,3 +2152,10 @@ class _AsyncSingleShotTimer: if self.task: self.task.cancel() self.callback = None + + def remaining_time(self): + """Return the remaining time before the timer expires.""" + if self.start_time is not None: + elapsed_time = (dt_util.utcnow() - self.start_time).total_seconds() + return max(0, self.delay - elapsed_time) + return 0 diff --git a/tests/test_switch.py b/tests/test_switch.py index f6d671f4..476e83cc 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -692,9 +692,15 @@ async def test_auto_reset_manual_control(hass): await turn_light(True, brightness=1) await turn_light(True, brightness=10) assert manual_control[light.entity_id] + assert ( + switch.extra_state_attributes["autoreset_time_remaining"][light.entity_id] > 0 + ) await asyncio.sleep(0.3) # Should be enough time for auto reset await update() assert not manual_control[light.entity_id], (light, manual_control) + assert ( + light.entity_id not in switch.extra_state_attributes["autoreset_time_remaining"] + ) # Do a couple of quick changes and check that light is not reset for i in range(3):