diff --git a/custom_components/adaptive_lighting/__init__.py b/custom_components/adaptive_lighting/__init__.py index 0ac9ef71..d4e148ec 100644 --- a/custom_components/adaptive_lighting/__init__.py +++ b/custom_components/adaptive_lighting/__init__.py @@ -8,7 +8,7 @@ import homeassistant.helpers.config_validation as cv import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_SOURCE, Platform -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.helpers import service from .const import ( @@ -49,15 +49,6 @@ CONFIG_SCHEMA = vol.Schema( ) -async def reload_configuration_yaml(event: Event) -> None: - """Reload configuration.yaml.""" - hass: HomeAssistant | None = event.data.get("hass") - if hass is not None: - await hass.services.async_call("homeassistant", "check_config", {}) - else: - _LOGGER.error("HomeAssistant instance not found in event data.") - - async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: """Import integration from config.""" hass.services.async_register( @@ -104,10 +95,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b """Set up the component.""" data = hass.data.setdefault(DOMAIN, {}) - # This will reload any changes the user made to any YAML configurations. - # Called during 'quick reload' or hass.reload_config_entry - hass.bus.async_listen("hass.config.entry_updated", reload_configuration_yaml) - undo_listener = config_entry.add_update_listener(async_update_options) data[config_entry.entry_id] = {UNDO_UPDATE_LISTENER: undo_listener} await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -126,12 +113,16 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry, "switch", ) - data = hass.data[DOMAIN] - data[config_entry.entry_id][UNDO_UPDATE_LISTENER]() - if unload_ok: - data.pop(config_entry.entry_id) + if not unload_ok: + return False - if len(data) == 1 and ATTR_ADAPTIVE_LIGHTING_MANAGER in data: + data = hass.data[DOMAIN] + data.pop(config_entry.entry_id)[UNDO_UPDATE_LISTENER]() + + # Keys that are not config entries; "__yaml__" must outlive the entries so + # a later reload can tell imported YAML switches from deleted ones. + non_entry_keys = {ATTR_ADAPTIVE_LIGHTING_MANAGER, "__yaml__"} + if ATTR_ADAPTIVE_LIGHTING_MANAGER in data and not set(data) - non_entry_keys: # no more config_entries manager = data.pop(ATTR_ADAPTIVE_LIGHTING_MANAGER) manager.disable() diff --git a/custom_components/adaptive_lighting/color_and_brightness.py b/custom_components/adaptive_lighting/color_and_brightness.py index 4645f994..4777e168 100644 --- a/custom_components/adaptive_lighting/color_and_brightness.py +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -326,6 +326,22 @@ class SunLightSettings: delta_brightness = self.max_brightness - self.min_brightness return (delta_brightness * (1 + sun_position)) + self.min_brightness + def _brightness_ramp_seconds(self) -> float: + return ( + self.brightness_mode_time_dark.total_seconds() + + self.brightness_mode_time_light.total_seconds() + ) + + def _brightness_pct_step(self, dt: datetime.datetime) -> float: + event, ts_event = self.sun.closest_event(dt) + after_event = dt.timestamp() >= ts_event + if event == SunEvent.SUNRISE: + return self.max_brightness if after_event else self.min_brightness + if event == SunEvent.SUNSET: + return self.min_brightness if after_event else self.max_brightness + msg = "Unsupported sun event" + raise ValueError(msg) + def _brightness_pct_tanh(self, dt: datetime.datetime) -> float: event, ts_event = self.sun.closest_event(dt) dark = self.brightness_mode_time_dark.total_seconds() @@ -387,6 +403,10 @@ class SunLightSettings: if is_sleep: return self.sleep_brightness assert self.brightness_mode in ("default", "linear", "tanh") + if self.brightness_mode != "default" and self._brightness_ramp_seconds() <= 0: + # A zero-length (or negative) ramp leaves nothing to interpolate + # over, so switch instantly at sunrise/sunset instead. + return self._brightness_pct_step(dt) if self.brightness_mode == "default": return self._brightness_pct_default(dt) if self.brightness_mode == "linear": @@ -404,8 +424,8 @@ class SunLightSettings: if sun_position == 0 or not self.adapt_until_sleep: return self.min_color_temp if self.adapt_until_sleep and sun_position < 0: - delta = abs(self.min_color_temp - self.sleep_color_temp) - ct = (delta * abs(1 + sun_position)) + self.sleep_color_temp + delta = self.min_color_temp - self.sleep_color_temp + ct = (delta * (1 + sun_position)) + self.sleep_color_temp return 5 * round(ct / 5) # round to nearest 5 msg = "Should not happen" raise ValueError(msg) @@ -581,9 +601,13 @@ def lerp_color_hsv( hsv1 = colorsys.rgb_to_hsv(*[x / 255.0 for x in rgb1]) hsv2 = colorsys.rgb_to_hsv(*[x / 255.0 for x in rgb2]) - # Linear interpolation in HSV space + # White/grey has no hue, so fade along the other color's hue instead + hue1 = hsv2[0] if hsv1[1] == 0 else hsv1[0] + hue2 = hsv1[0] if hsv2[1] == 0 else hsv2[0] + # Linear interpolation in HSV space, taking the short way around the hue circle + hue_delta = (hue2 - hue1 + 0.5) % 1.0 - 0.5 hsv = ( - hsv1[0] + t * (hsv2[0] - hsv1[0]), + (hue1 + t * hue_delta) % 1.0, hsv1[1] + t * (hsv2[1] - hsv1[1]), hsv1[2] + t * (hsv2[2] - hsv1[2]), ) diff --git a/custom_components/adaptive_lighting/services.yaml b/custom_components/adaptive_lighting/services.yaml index c95e31b5..5cfa727c 100644 --- a/custom_components/adaptive_lighting/services.yaml +++ b/custom_components/adaptive_lighting/services.yaml @@ -85,12 +85,6 @@ change_switch_settings: required: false selector: boolean: null - turn_on_lights: - description: Whether to turn on lights that are currently off. 🔆 - example: false - required: false - selector: - boolean: null initial_transition: description: Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️ example: 1 diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index 9e22ff57..524655ff 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -176,10 +176,6 @@ "description": "Show all options as attributes on the switch in Home Assistant when set to `true`. 📝", "name": "include_config_in_attributes" }, - "turn_on_lights": { - "description": "Whether to turn on lights that are currently off. 🔆", - "name": "turn_on_lights" - }, "initial_transition": { "description": "Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️", "name": "initial_transition" diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index db1c2d0b..a9356cc2 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1118,6 +1118,24 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): all_lights, self._auto_reset_manual_control_time, ) + if not self._auto_reset_manual_control_time: + # Auto-reset is off here: drop a delay left from an earlier setting, + # unless another profile for the same light still uses one. + still_reset: set[str] = set() + for entry in self.hass.data[DOMAIN].values(): + if ( + isinstance(entry, dict) + and (switch := entry.get(SWITCH_DOMAIN)) + and switch is not self + and switch._auto_reset_manual_control_time + ): + still_reset.update(switch._resolve_lights()) + timers = self.manager.auto_reset_manual_control_timers + for light in set(all_lights) - still_reset: + self.manager.auto_reset_manual_control_times.pop(light, None) + # A timer started under the old delay must not reset the light. + if timer := timers.pop(light, None): + timer.cancel() async def _setup_listeners(self, _: Event[NoEventData] | None = None) -> None: _LOGGER.debug("%s: Called '_setup_listeners'", self._name) @@ -1464,7 +1482,10 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): not data.force and not is_on(self.hass, data.entity_id) # if proactively adapting, we are sure that it came from a `light.turn_on` - and not self.manager.is_proactively_adapting(data.context.id) + and not self.manager.is_proactively_adapting( + data.context.id, + data.entity_id, + ) ): # Do a last-minute check if the entity is still on. _LOGGER.debug( @@ -1645,7 +1666,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): entity_id: str, event: Event[EventStateChangedData], ) -> None: - assert not self.manager.is_proactively_adapting(event.context.id) + assert not self.manager.is_proactively_adapting(event.context.id, entity_id) from_turn_on = self.manager._off_to_on_state_event_is_from_turn_on( entity_id, event, @@ -1711,8 +1732,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if self._adapt_delay > 0: await asyncio.sleep(self._adapt_delay) - # Runtime settings may retire this profile's target while the event waits. - if self._removed or entity_id not in self.lights: + # Runtime settings may retire this profile's target, or the switch may be + # turned off, while the event waits. + if self._removed or not self.is_on or entity_id not in self.lights: return await self._update_attrs_and_maybe_adapt_lights( @@ -1913,7 +1935,7 @@ class AdaptiveLightingManager: ), ] - self._proactively_adapting_contexts: dict[str, str] = {} + self._proactively_adapting_contexts: dict[str, set[str]] = {} self._context_cnt: int = 0 try: @@ -1956,18 +1978,25 @@ class AdaptiveLightingManager: """Declare the adaptation with context_id as proactively adapting, and associate it to an entity_id. """ # noqa: D205 - self._proactively_adapting_contexts[context_id] = entity_id + self._proactively_adapting_contexts.setdefault(context_id, set()).add( + entity_id, + ) - def is_proactively_adapting(self, context_id: str) -> bool: - """Determine whether an adaptation with the given context_id is proactive.""" - is_proactively_adapting_context = ( - context_id in self._proactively_adapting_contexts + def is_proactively_adapting(self, context_id: str, entity_id: str) -> bool: + """Determine whether we are proactively adapting entity_id under context_id. + + Scoped per entity: one automation run shares a context across calls, and + a proactive turn-on of one light must not hide manual changes to another. + """ + is_proactively_adapting_context = entity_id in ( + self._proactively_adapting_contexts.get(context_id, ()) ) _LOGGER.debug( - "is_proactively_adapting_context='%s', context_id='%s'", + "is_proactively_adapting_context='%s', context_id='%s', entity_id='%s'", is_proactively_adapting_context, context_id, + entity_id, ) return is_proactively_adapting_context @@ -1979,10 +2008,13 @@ class AdaptiveLightingManager: """ # First get the keys to avoid modifying the dict while iterating it keys = [ - k for k, v in self._proactively_adapting_contexts.items() if v == entity_id + k for k, v in self._proactively_adapting_contexts.items() if entity_id in v ] for key in keys: - self._proactively_adapting_contexts.pop(key) + entity_ids = self._proactively_adapting_contexts[key] + entity_ids.discard(entity_id) + if not entity_ids: + self._proactively_adapting_contexts.pop(key) def create_context( self, @@ -2711,7 +2743,7 @@ class AdaptiveLightingManager: timer is not None and timer.is_running() and not is_our_context(event.context) - and not self.is_proactively_adapting(event.context.id) + and not self.is_proactively_adapting(event.context.id, eid) and event.time_fired > timer.start_time # type: ignore[operator] ): # Only external turn-ons extend manual control, not our adaptations. @@ -2852,7 +2884,7 @@ class AdaptiveLightingManager: event.context.id, ) - if self.is_proactively_adapting(event.context.id): + if self.is_proactively_adapting(event.context.id, entity_id): _LOGGER.debug( "Skipping responding to 'off' → 'on' event for '%s' with context.id='%s' because" " we are already proactively adapting", @@ -2899,7 +2931,7 @@ class AdaptiveLightingManager: if ( turn_on_event is None - or self.is_proactively_adapting(turn_on_event.context.id) + or self.is_proactively_adapting(turn_on_event.context.id, light) or is_our_context(turn_on_event.context) or force ): diff --git a/custom_components/adaptive_lighting/translations/bg.json b/custom_components/adaptive_lighting/translations/bg.json index a4e62001..d446b38c 100644 --- a/custom_components/adaptive_lighting/translations/bg.json +++ b/custom_components/adaptive_lighting/translations/bg.json @@ -174,10 +174,6 @@ "description": "Показва всички опции като атрибути на ключа в Home Assistant, когато е зададено на \"true\". 📝", "name": "include_config_in_attributes" }, - "turn_on_lights": { - "description": "Дали да се включат лампите, които в момента са изключени. 🔆", - "name": "turn_on_lights" - }, "initial_transition": { "description": "Продължителност на първия преход, когато лампите преминават от 'изключено' на 'включено' в секунди. ⏲️", "name": "initial_transition" diff --git a/custom_components/adaptive_lighting/translations/ca.json b/custom_components/adaptive_lighting/translations/ca.json index 9042b60e..a6576c66 100644 --- a/custom_components/adaptive_lighting/translations/ca.json +++ b/custom_components/adaptive_lighting/translations/ca.json @@ -98,9 +98,6 @@ "entity_id": { "description": "ID de la entitat de l'interruptor. 📝" }, - "turn_on_lights": { - "description": "Si s'encenen les llums que estan apagades en aquest moment. 🔆" - }, "initial_transition": { "description": "Durada de la primera transició quan els llums canvien `off` a `on` en segons. ⏲️" }, diff --git a/custom_components/adaptive_lighting/translations/cs.json b/custom_components/adaptive_lighting/translations/cs.json index 0ac5c4a0..d6ebbf87 100644 --- a/custom_components/adaptive_lighting/translations/cs.json +++ b/custom_components/adaptive_lighting/translations/cs.json @@ -123,9 +123,6 @@ "sleep_rgb_or_color_temp": { "description": "V režimu spánku se použije buď `\"rgb_color\"`, nebo `\"color_temp\"`. 🌙" }, - "turn_on_lights": { - "description": "Zda se mají zapnout světla, která jsou aktuálně vypnutá. 🔆" - }, "initial_transition": { "description": "Doba trvání prvního přechodu, kdy se světla změní z `vypnuto` na `zapnuto`, v sekundách. ⏲️" }, diff --git a/custom_components/adaptive_lighting/translations/da.json b/custom_components/adaptive_lighting/translations/da.json index 5925dbc7..02b21218 100644 --- a/custom_components/adaptive_lighting/translations/da.json +++ b/custom_components/adaptive_lighting/translations/da.json @@ -119,9 +119,6 @@ "entity_id": { "description": "Entity ID af kontakten. 📝" }, - "turn_on_lights": { - "description": "Om lys der i øjeblikket er slukket, skal tændes. 🔆" - }, "sleep_transition": { "description": "Varigheden af overgangen, når \"sovetilstand\" skiftes, i sekunder. 😴" }, diff --git a/custom_components/adaptive_lighting/translations/de.json b/custom_components/adaptive_lighting/translations/de.json index 4885e753..77af835c 100644 --- a/custom_components/adaptive_lighting/translations/de.json +++ b/custom_components/adaptive_lighting/translations/de.json @@ -196,9 +196,6 @@ "entity_id": { "description": "Entity ID des Schalters. 📝" }, - "turn_on_lights": { - "description": "Ob Lichter eingeschaltet werden sollen, die derzeit ausgeschaltet sind. 🔆" - }, "initial_transition": { "description": "Dauer des ersten Übergangs, wenn das Licht von `off` auf `on` schaltet, in Sekunden. ⏲️" }, diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index cc589156..00b00e9e 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -177,10 +177,6 @@ "description": "Show all options as attributes on the switch in Home Assistant when set to `true`. 📝", "name": "include_config_in_attributes" }, - "turn_on_lights": { - "description": "Whether to turn on lights that are currently off. 🔆", - "name": "turn_on_lights" - }, "initial_transition": { "description": "Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️", "name": "initial_transition" diff --git a/custom_components/adaptive_lighting/translations/es.json b/custom_components/adaptive_lighting/translations/es.json index 5944193c..f28cd062 100644 --- a/custom_components/adaptive_lighting/translations/es.json +++ b/custom_components/adaptive_lighting/translations/es.json @@ -137,9 +137,6 @@ "prefer_rgb_color": { "description": "Preferir ajustes de color RGB a temperatura de color cuando sea posible. 🌈" }, - "turn_on_lights": { - "description": "Encender (o no) luces que estén apagadas. 🔆" - }, "max_brightness": { "description": "Porcentaje máximo de brillo. 💡" }, diff --git a/custom_components/adaptive_lighting/translations/fi.json b/custom_components/adaptive_lighting/translations/fi.json index b8310c7c..a52e4609 100644 --- a/custom_components/adaptive_lighting/translations/fi.json +++ b/custom_components/adaptive_lighting/translations/fi.json @@ -50,9 +50,6 @@ "entity_id": { "description": "Kytkimen entiteettitunnus. 📝" }, - "turn_on_lights": { - "description": "Sytytetäänkö valot, jotka ovat tällä hetkellä pois päältä. 🔆" - }, "include_config_in_attributes": { "description": "Näytä kaikki vaihtoehdot attribuutteina kotiavustajan kytkimellä, kun sen arvo on \"true\". 📝" }, diff --git a/custom_components/adaptive_lighting/translations/fr.json b/custom_components/adaptive_lighting/translations/fr.json index 4feaa9e8..0ebd79f0 100644 --- a/custom_components/adaptive_lighting/translations/fr.json +++ b/custom_components/adaptive_lighting/translations/fr.json @@ -162,9 +162,6 @@ "sleep_rgb_or_color_temp": { "description": "Utilisez soit \"rgb_color\" soit \"color_temp\" en mode nuit. 🌙" }, - "turn_on_lights": { - "description": "Indique s'il faut allumer les lumières qui sont actuellement éteintes. 🔆" - }, "include_config_in_attributes": { "description": "Afficher toutes les options en tant qu'attributs sur l'interrupteur dans Home Assistant lorsqu'il est défini sur \"true\". 📝" }, diff --git a/custom_components/adaptive_lighting/translations/hu.json b/custom_components/adaptive_lighting/translations/hu.json index deda5379..50c5544c 100644 --- a/custom_components/adaptive_lighting/translations/hu.json +++ b/custom_components/adaptive_lighting/translations/hu.json @@ -98,9 +98,6 @@ "sleep_rgb_or_color_temp": { "description": "Az `\"rgb_color\" vagy a `\"color_temp\" használata alvó üzemmódban. 🌙" }, - "turn_on_lights": { - "description": "A jelenleg kikapcsolt fények bekapcsolása. 🔆" - }, "initial_transition": { "description": "Az első transition időtartama, amikor a lights \"kikapcsolt\" állapotból \"bekapcsolt\" állapotba váltanak, másodpercben. ⏲️" }, diff --git a/custom_components/adaptive_lighting/translations/id.json b/custom_components/adaptive_lighting/translations/id.json index 6d4d8fcf..abeb1dab 100644 --- a/custom_components/adaptive_lighting/translations/id.json +++ b/custom_components/adaptive_lighting/translations/id.json @@ -26,9 +26,6 @@ "sleep_rgb_or_color_temp": { "description": "Gunakan `\"rgb_color\"` atau `\"color_temp\"` dalam mode tidur. 🌙" }, - "turn_on_lights": { - "description": "Kalau ingin menyalakan lampu yang sedang mati. 🔆" - }, "initial_transition": { "description": "Durasi transisi pertama saat lampu berubah dari `mati` ke `hidup` dalam hitungan detik. ⏲️" }, diff --git a/custom_components/adaptive_lighting/translations/it.json b/custom_components/adaptive_lighting/translations/it.json index d6236f67..688207c7 100644 --- a/custom_components/adaptive_lighting/translations/it.json +++ b/custom_components/adaptive_lighting/translations/it.json @@ -117,9 +117,6 @@ "sleep_rgb_or_color_temp": { "description": "Usa uno tra `\"rgb_color\"` or`\"color_temp\"` in modalità notturna. 🌙" }, - "turn_on_lights": { - "description": "Seleziona per accedere le luci, qualora fossero spente. 🔆" - }, "initial_transition": { "description": "Durata della prima transizione quando le luci passano dallo stato `off` a `on`, espressa in secondi. ⏲️" }, diff --git a/custom_components/adaptive_lighting/translations/ko.json b/custom_components/adaptive_lighting/translations/ko.json index 58b0424b..6afaf1e7 100644 --- a/custom_components/adaptive_lighting/translations/ko.json +++ b/custom_components/adaptive_lighting/translations/ko.json @@ -167,10 +167,6 @@ "description": "`true`로 설정하면 Home Assistant에서 스위치의 모든 옵션을 속성으로 표시합니다. 📝", "name": "include_config_in_attributes" }, - "turn_on_lights": { - "description": "현재 꺼져 있는 조명을 켤지 여부. 🔆", - "name": "turn_on_lights" - }, "initial_transition": { "description": "조명이 `off`에서 `on`으로 바뀔 때 첫 번째 전환의 지속 시간, 초 단위. ⏲️", "name": "initial_transition" diff --git a/custom_components/adaptive_lighting/translations/nb.json b/custom_components/adaptive_lighting/translations/nb.json index 85c29f76..c297aaa3 100644 --- a/custom_components/adaptive_lighting/translations/nb.json +++ b/custom_components/adaptive_lighting/translations/nb.json @@ -169,9 +169,6 @@ "adapt_delay": { "description": "Ventetid (sekunder) mellom at lyset skrues på og Adaptive Lightning sender endringer. Kan hjelpe til for å unngå blinking." }, - "turn_on_lights": { - "description": "Skru på lys som fortiden er skrudd av." - }, "take_over_control": { "description": "Skrur av Adaptive Lightning hvis en annen kilde kaller `light.turn_on` mens lysene er på og blir styrt. Merk at dette kaller `homeassistant.update_entity` hvert eneste`interval`!" } diff --git a/custom_components/adaptive_lighting/translations/nl.json b/custom_components/adaptive_lighting/translations/nl.json index cfe3e677..6a94b381 100644 --- a/custom_components/adaptive_lighting/translations/nl.json +++ b/custom_components/adaptive_lighting/translations/nl.json @@ -154,9 +154,6 @@ "sleep_rgb_or_color_temp": { "description": "Gebruik één van beide `\"rgb_color\"` of `\"color_temp\"` in slaapstand. 🌙" }, - "turn_on_lights": { - "description": "Of de lampen moeten worden aangezet die momenteel uit zijn.🔆" - }, "sunrise_time": { "description": "Stel een vaste tijd (HH:MM:SS) in voor zonsopkomst. 🌅" }, diff --git a/custom_components/adaptive_lighting/translations/pl.json b/custom_components/adaptive_lighting/translations/pl.json index 8957db09..05669a25 100644 --- a/custom_components/adaptive_lighting/translations/pl.json +++ b/custom_components/adaptive_lighting/translations/pl.json @@ -145,9 +145,6 @@ "max_brightness": { "description": "Maksymalna jasność (w procentach). 💡" }, - "turn_on_lights": { - "description": "Czy włączyć światła, które są aktualnie wyłączone? 🔆" - }, "initial_transition": { "description": "Długość pierwszego przejścia, gdy światło zostanie przełączone z `off` na `on` (w sekundach). ⏲️" }, diff --git a/custom_components/adaptive_lighting/translations/pt-BR.json b/custom_components/adaptive_lighting/translations/pt-BR.json index eae954dc..c5cdf6ba 100644 --- a/custom_components/adaptive_lighting/translations/pt-BR.json +++ b/custom_components/adaptive_lighting/translations/pt-BR.json @@ -109,9 +109,6 @@ "sleep_brightness": { "description": "Porcentagem do brilho das luzes no modo dormir. 😴" }, - "turn_on_lights": { - "description": "Se deve ligar as luzes que estão atualmente desligadas. 🔆" - }, "initial_transition": { "description": "Duração da primeira transição, em segundos, quando as luzes alternarem de 'desligado' para 'ligado'. ⏲️" }, diff --git a/custom_components/adaptive_lighting/translations/pt.json b/custom_components/adaptive_lighting/translations/pt.json index 888f2e1e..3b73d977 100644 --- a/custom_components/adaptive_lighting/translations/pt.json +++ b/custom_components/adaptive_lighting/translations/pt.json @@ -12,9 +12,6 @@ "sunset_offset": { "description": "Ajustar a hora do pôr do sol com um offset positivo ou negativo em segundos. ⏰" }, - "turn_on_lights": { - "description": "Para ligar luzes que estão neste momento desligadas. 🔆" - }, "entity_id": { "description": "ID Entidade do interruptor. 📝" }, diff --git a/custom_components/adaptive_lighting/translations/ru.json b/custom_components/adaptive_lighting/translations/ru.json index a466c94c..c0ddbf99 100644 --- a/custom_components/adaptive_lighting/translations/ru.json +++ b/custom_components/adaptive_lighting/translations/ru.json @@ -170,9 +170,6 @@ "sleep_rgb_or_color_temp": { "description": "Используйте либо «rgb_color», либо «color_temp» в режиме сна. 🌙" }, - "turn_on_lights": { - "description": "Включать ли свет, который в данный момент выключен. 🔆" - }, "initial_transition": { "description": "Длительность первого плавного перехода, когда освещение переключается с «выключено» на «включено» в секундах. ⏲️" }, diff --git a/custom_components/adaptive_lighting/translations/sk.json b/custom_components/adaptive_lighting/translations/sk.json index 733dff85..de5824aa 100644 --- a/custom_components/adaptive_lighting/translations/sk.json +++ b/custom_components/adaptive_lighting/translations/sk.json @@ -106,9 +106,6 @@ "sleep_rgb_or_color_temp": { "description": "V režime spánku použiť `\"rgb_color\"` alebo `\"color_temp\"`. 🌙" }, - "turn_on_lights": { - "description": "Či sa majú zapnúť svetlá, ktoré sú momentálne vypnuté. 🔆" - }, "initial_transition": { "description": "Trvanie prvého prechodu, keď sú svetlá zapnuté z `off` na `on` (v sekundách). ⏲️" }, diff --git a/custom_components/adaptive_lighting/translations/sv.json b/custom_components/adaptive_lighting/translations/sv.json index 37d0d169..432caeb8 100644 --- a/custom_components/adaptive_lighting/translations/sv.json +++ b/custom_components/adaptive_lighting/translations/sv.json @@ -141,9 +141,6 @@ "sleep_rgb_or_color_temp": { "description": "Använd antingen`\"rgb_color\"` eller `\"color_temp\"` i sovläge. 🌙" }, - "turn_on_lights": { - "description": "Om att tända lampor som är för närvarande släckta. 🔆" - }, "initial_transition": { "description": "Den första övergångens varaktighet när lampan slås från ”av” till ”på” i sekunder. ⏲️" }, diff --git a/custom_components/adaptive_lighting/translations/ta.json b/custom_components/adaptive_lighting/translations/ta.json index 39c6c918..9909ac5c 100644 --- a/custom_components/adaptive_lighting/translations/ta.json +++ b/custom_components/adaptive_lighting/translations/ta.json @@ -31,9 +31,6 @@ "include_config_in_attributes": { "description": "`உண்மை` என அமைக்கப்பட்டிருக்கும் போது வீட்டு உதவியாளரின் சுவிட்சில் உள்ள அனைத்து விருப்பங்களையும் பண்புகளாகக் காட்டுங்கள். ." }, - "turn_on_lights": { - "description": "தற்போது முடக்கப்பட்ட விளக்குகளை இயக்க வேண்டுமா. ." - }, "initial_transition": { "description": "விளக்குகள் `ஆஃப்` முதல்` ஆன் `வரை நொடிகளில் மாறும் போது முதல் மாற்றத்தின் காலம். ." }, diff --git a/custom_components/adaptive_lighting/translations/tr.json b/custom_components/adaptive_lighting/translations/tr.json index d6f929f6..8a1c1df3 100644 --- a/custom_components/adaptive_lighting/translations/tr.json +++ b/custom_components/adaptive_lighting/translations/tr.json @@ -146,9 +146,6 @@ "prefer_rgb_color": { "description": "Mümkün olduğunda ışık renk sıcaklığı yerine RGB renk ayarını tercih edip etmeyeceğini belirler. 🌈" }, - "turn_on_lights": { - "description": "Şu anda kapalı olan ışıkların açılıp açılmayacağını belirler. 🔆" - }, "adapt_delay": { "description": "Işık açıldıktan sonra Adaptive Lighting’in değişiklikleri uygulamasına kadar bekleme süresi (saniye cinsinden). Titremeyi önlemeye yardımcı olabilir. ⏲️" } diff --git a/custom_components/adaptive_lighting/translations/uk.json b/custom_components/adaptive_lighting/translations/uk.json index 999a434b..1cf9dc24 100644 --- a/custom_components/adaptive_lighting/translations/uk.json +++ b/custom_components/adaptive_lighting/translations/uk.json @@ -206,9 +206,6 @@ "prefer_rgb_color": { "description": "Чи надавати перевагу налаштуванню кольору RGB над температурою кольору світла, коли це можливо. 🌈" }, - "turn_on_lights": { - "description": "Чи вмикати світло, яке наразі вимкнене. 🔆" - }, "take_over_control_mode": { "description": "Режим призупинення адаптації, коли інші джерела змінюють яскравість та/або колір світла. `pause_all` завжди призупиняє адаптацію як яскравості, так і кольору. `pause_changed` призупиняє адаптацію лише змінених атрибутів та продовжує адаптацію незмінних атрибутів, наприклад, продовжує адаптацію кольору, коли змінювалася лише яскравість." } diff --git a/custom_components/adaptive_lighting/translations/ur.json b/custom_components/adaptive_lighting/translations/ur.json index f61e47b1..7e72aee2 100644 --- a/custom_components/adaptive_lighting/translations/ur.json +++ b/custom_components/adaptive_lighting/translations/ur.json @@ -26,9 +26,6 @@ "sleep_rgb_or_color_temp": { "description": "نیند کے موڈ میں \"rgb_color\" یا \"color_temp\" کا استعمال کریں۔ 🌙" }, - "turn_on_lights": { - "description": "کیا ان لائٹس کو آن کرنا ہے جو فی الحال بند ہیں۔ 🔆" - }, "initial_transition": { "description": "پہلی منتقلی کا دورانیہ جب لائٹس سیکنڈوں میں 'بند' سے 'آن' میں تبدیل ہوجاتی ہیں۔ ⏲️" }, diff --git a/custom_components/adaptive_lighting/translations/zh-Hans.json b/custom_components/adaptive_lighting/translations/zh-Hans.json index 1cebfdd0..7a76eb47 100644 --- a/custom_components/adaptive_lighting/translations/zh-Hans.json +++ b/custom_components/adaptive_lighting/translations/zh-Hans.json @@ -167,10 +167,6 @@ "description": "在Home Assistant中将所有选项显示为开关的属性时,设置为`true`。📝", "name": "include_config_in_attributes" }, - "turn_on_lights": { - "description": "是否打开当前关闭的灯光。🔆", - "name": "turn_on_lights" - }, "initial_transition": { "description": "灯光从“关闭”到“开启”时的第一个过渡持续时间,以秒为单位。⏲️", "name": "initial_transition" diff --git a/tests/test_switch.py b/tests/test_switch.py index a3f5b689..0aadc46a 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -53,6 +53,7 @@ from homeassistant.components.adaptive_lighting.const import ( CONF_SEND_SPLIT_DELAY, CONF_SEPARATE_TURN_ON_COMMANDS, CONF_SKIP_REDUNDANT_COMMANDS, + CONF_SLEEP_COLOR_TEMP, CONF_SLEEP_RGB_OR_COLOR_TEMP, CONF_SLEEP_TRANSITION, CONF_SUNRISE_OFFSET, @@ -3252,7 +3253,7 @@ async def test_proactive_adaptation_toggle(hass): context=Context(id="test1"), ) - assert switch.manager.is_proactively_adapting("test1") + assert switch.manager.is_proactively_adapting("test1", ENTITY_LIGHT_3) # Toggle OFF await hass.services.async_call( @@ -3263,7 +3264,7 @@ async def test_proactive_adaptation_toggle(hass): context=Context(id="test2"), ) - assert not switch.manager.is_proactively_adapting("test2") + assert not switch.manager.is_proactively_adapting("test2", ENTITY_LIGHT_3) async def test_proactive_adaptation_transition_override(hass): @@ -3372,8 +3373,8 @@ async def test_proactive_multiple_lights_all_at_once(hass): assert events[2].data["service_data"][ATTR_ENTITY_ID] == [ENTITY_LIGHT_3] assert ":skpp:" in events[2].context.id - assert switch1.manager.is_proactively_adapting("test1") - assert switch2.manager.is_proactively_adapting("test1") + assert switch1.manager.is_proactively_adapting("test1", ENTITY_LIGHT_1) + assert switch2.manager.is_proactively_adapting("test1", ENTITY_LIGHT_1) await hass.async_block_till_done() @@ -3457,8 +3458,8 @@ async def test_proactive_multiple_lights_one_switch_and_one_skipped(hass): assert events[1].data["service_data"][ATTR_ENTITY_ID] == [ENTITY_LIGHT_3] assert ":skpp:" in events[1].context.id - assert switch1.manager.is_proactively_adapting("test1") - assert switch2.manager.is_proactively_adapting("test1") + assert switch1.manager.is_proactively_adapting("test1", ENTITY_LIGHT_1) + assert switch2.manager.is_proactively_adapting("test1", ENTITY_LIGHT_1) await hass.async_block_till_done() @@ -3619,7 +3620,12 @@ def test_lerp_color_hsv(): assert lerp_color_hsv((255, 0, 0), (0, 255, 0), 0) == (255, 0, 0) assert lerp_color_hsv((255, 0, 0), (0, 255, 0), 1) == (0, 255, 0) assert lerp_color_hsv((255, 0, 0), (0, 255, 0), 0.5) == (255, 255, 0) - assert lerp_color_hsv((0, 0, 255), (255, 255, 255), 0.5) == (128, 255, 128) + # Fading to white keeps the hue of the colored end instead of passing through green + assert lerp_color_hsv((0, 0, 255), (255, 255, 255), 0.5) == (128, 128, 255) + # Hue takes the short way around the color wheel: orange -> pink, not via cyan + mid = lerp_color_hsv((255, 140, 40), (255, 0, 128), 0.5) + assert mid[0] == 255 + assert mid[1] < 140 # Tests that the interpolation is consistent for t in [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]: @@ -3792,8 +3798,12 @@ async def test_light_group( assert ":lght:" in events[2].context.id assert ":lght:" in events[3].context.id assert len(events) == 4 - assert not switch.manager.is_proactively_adapting(events[0].context.id) - assert not switch.manager.is_proactively_adapting(events[1].context.id) + for event in events[:2]: + for light in ("light.light_4", "light.light_5"): + assert not switch.manager.is_proactively_adapting( + event.context.id, + light, + ) # Turn off all lights, and then turn on all lights await hass.services.async_call( @@ -6344,3 +6354,170 @@ async def test_unloaded_polling_profile_preserves_other_split_adaptation( await hass.async_block_till_done() assert len(calls) == 2 assert ATTR_COLOR_TEMP_KELVIN in calls[-1] + + +@pytest.mark.parametrize("brightness_mode", ["linear", "tanh"]) +async def test_zero_length_brightness_ramp_switches_instantly(hass, brightness_mode): + """A ramp of zero seconds must not divide by zero; brightness steps instead.""" + _, switch = await setup_switch( + hass, + { + CONF_SUNRISE_TIME: datetime.time(SUNRISE.hour), + CONF_SUNSET_TIME: datetime.time(SUNSET.hour), + CONF_BRIGHTNESS_MODE: brightness_mode, + CONF_BRIGHTNESS_MODE_TIME_DARK: datetime.timedelta(0), + CONF_BRIGHTNESS_MODE_TIME_LIGHT: datetime.timedelta(0), + }, + ) + settings = switch._sun_light_settings + second = datetime.timedelta(seconds=1) + sunset = SUNSET.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone(dt_util.UTC) + sunrise = SUNRISE.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone(dt_util.UTC) + assert settings.brightness_pct(sunset - second, False) == settings.max_brightness + assert settings.brightness_pct(sunset + second, False) == settings.min_brightness + assert settings.brightness_pct(sunrise - second, False) == settings.min_brightness + assert settings.brightness_pct(sunrise + second, False) == settings.max_brightness + + +async def test_adapt_until_sleep_with_sleep_color_temp_above_min(hass): + """Color temp must move smoothly from min to a cooler sleep color after sunset.""" + _, switch = await setup_switch( + hass, + { + CONF_MIN_COLOR_TEMP: 2000, + CONF_SLEEP_COLOR_TEMP: 3000, + CONF_ADAPT_UNTIL_SLEEP: True, + }, + ) + settings = switch._sun_light_settings + assert settings.color_temp_kelvin(-0.0001) == 2000 + assert settings.color_temp_kelvin(-0.5) == 2500 + assert settings.color_temp_kelvin(-1) == 3000 + + +async def test_autoreset_control_seconds_zero_disables_existing_reset(hass): + """Changing autoreset_control_seconds to 0 must drop the old per-light delay.""" + switch, _ = await setup_lights_and_switch(hass, {CONF_AUTORESET_CONTROL: 3600}) + manager = switch.manager + assert manager.auto_reset_manual_control_times[ENTITY_LIGHT_1] == 3600 + + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE_SWITCH_SETTINGS, + {ATTR_ENTITY_ID: switch.entity_id, CONF_AUTORESET_CONTROL: 0}, + blocking=True, + ) + await hass.async_block_till_done() + + assert ENTITY_LIGHT_1 not in manager.auto_reset_manual_control_times + manager.set_manual_control_attributes(ENTITY_LIGHT_1) + assert ENTITY_LIGHT_1 not in manager.auto_reset_manual_control_timers + + +async def test_autoreset_zero_cancels_running_reset_timer(hass): + """Disabling auto-reset must also stop a timer that is already running.""" + switch, _ = await setup_lights_and_switch(hass, {CONF_AUTORESET_CONTROL: 0.2}) + manager = switch.manager + manager.set_manual_control_attributes(ENTITY_LIGHT_1) + timer = manager.auto_reset_manual_control_timers[ENTITY_LIGHT_1] + assert timer.is_running() + + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE_SWITCH_SETTINGS, + {ATTR_ENTITY_ID: switch.entity_id, CONF_AUTORESET_CONTROL: 0}, + blocking=True, + ) + await hass.async_block_till_done() + + assert not timer.is_running() + assert ENTITY_LIGHT_1 not in manager.auto_reset_manual_control_timers + await asyncio.sleep(0.4) # longer than the old delay + await hass.async_block_till_done() + # The light stays manually controlled instead of being reset and re-adapted. + assert manager.get_manual_control_attributes(ENTITY_LIGHT_1) + assert switch.extra_state_attributes["autoreset_time_remaining"] == {} + + +async def test_autoreset_zero_keeps_other_profiles_reset(hass): + """A profile without auto-reset must not clear another profile's delay.""" + switch, _ = await setup_lights_and_switch(hass, {CONF_AUTORESET_CONTROL: 3600}) + await setup_switch( + hass, + {CONF_NAME: "other", CONF_LIGHTS: [ENTITY_LIGHT_1]}, + ) + assert switch.manager.auto_reset_manual_control_times[ENTITY_LIGHT_1] == 3600 + + +async def test_proactive_adapting_is_scoped_per_light(hass): + """A proactive turn-on of one light must not hide manual changes to another.""" + switch, _ = await setup_lights_and_switch(hass) + manager = switch.manager + manager.set_proactively_adapting("ctx", ENTITY_LIGHT_1) + assert manager.is_proactively_adapting("ctx", ENTITY_LIGHT_1) + assert not manager.is_proactively_adapting("ctx", ENTITY_LIGHT_2) + + # Several lights may share one context, e.g. one intercepted multi-light call + manager.set_proactively_adapting("ctx", ENTITY_LIGHT_2) + assert manager.is_proactively_adapting("ctx", ENTITY_LIGHT_1) + assert manager.is_proactively_adapting("ctx", ENTITY_LIGHT_2) + + manager.clear_proactively_adapting(ENTITY_LIGHT_1) + assert not manager.is_proactively_adapting("ctx", ENTITY_LIGHT_1) + assert manager.is_proactively_adapting("ctx", ENTITY_LIGHT_2) + manager.clear_proactively_adapting(ENTITY_LIGHT_2) + assert "ctx" not in manager._proactively_adapting_contexts + + +async def test_switch_turned_off_during_adapt_delay(hass, monkeypatch, caplog): + """Turning the switch off while adapt_delay waits must skip quietly.""" + switch, _ = await setup_lights_and_switch( + hass, + {CONF_DETECT_NON_HA_CHANGES: True, CONF_ADAPT_DELAY: 0.1234}, + ) + state = hass.states.get(ENTITY_LIGHT_1) + hass.states.async_set(ENTITY_LIGHT_1, STATE_OFF, state.attributes) + await hass.async_block_till_done() + monkeypatch.setattr( + switch.manager, + "just_turned_off", + AsyncMock(return_value=False), + ) + entered, release = asyncio.Event(), asyncio.Event() + original_sleep = asyncio.sleep + + async def controlled_sleep(delay, *args, **kwargs): + if delay == 0.1234: + entered.set() + await release.wait() + else: + await original_sleep(delay, *args, **kwargs) + + monkeypatch.setattr(asyncio, "sleep", controlled_sleep) + calls = _track_adaptive_light_calls(hass) + hass.states.async_set(ENTITY_LIGHT_1, STATE_ON, state.attributes) + await asyncio.wait_for(entered.wait(), 2) + # Don't wait for pending work here: the delayed handler is still paused + await switch.async_turn_off() + calls.clear() + caplog.clear() + release.set() + await hass.async_block_till_done() + assert calls == [] + assert not [r for r in caplog.records if r.levelno >= logging.ERROR] + + +async def test_last_unload_disables_manager_after_yaml_import(hass): + """The manager must be disabled on the last unload even if YAML was used. + + The YAML marker must survive so a later reload keeps the YAML switches. + """ + entry, switch = await setup_switch(hass, {}) + data = hass.data[DOMAIN] + data.setdefault("__yaml__", set()).add("some_yaml_switch") + manager = data[ATTR_ADAPTIVE_LIGHTING_MANAGER] + with patch.object(manager, "disable", wraps=manager.disable) as disable: + assert await hass.config_entries.async_unload(entry.entry_id) + disable.assert_called_once() + assert ATTR_ADAPTIVE_LIGHTING_MANAGER not in hass.data[DOMAIN] + assert hass.data[DOMAIN]["__yaml__"] == {"some_yaml_switch"}