diff --git a/.all-contributorsrc b/.all-contributorsrc
index fbff14b8..6004155e 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -1568,17 +1568,6 @@
"contributions": [
"ideas"
]
- },
- {
- "login": "ahmadtawakol",
- "name": "Ahmad Tawakol",
- "avatar_url": "https://avatars.githubusercontent.com/u/2355493?v=4",
- "profile": "https://github.com/ahmadtawakol",
- "contributions": [
- "code",
- "bug",
- "maintenance"
- ]
}
],
"contributorsPerLine": 7,
diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index 767b6084..00000000
--- a/.dockerignore
+++ /dev/null
@@ -1,28 +0,0 @@
-# The Home Assistant core checkout. tests/README.md has you clone it to ./core,
-# but the Dockerfile clones its own copy to /core and links /app/core to it.
-# Without this entry `COPY . /app/` ships ~300MB into every build and leaves
-# /app/core as a real directory, so `ln -s /core /app/core` links *inside* it
-# rather than creating the intended symlink.
-core/
-
-# Local virtualenvs
-.venv/
-venv/
-env/
-ENV/
-
-# Not used by the build
-.git/
-.vscode/
-.idea/
-
-# Caches and test output
-__pycache__/
-*.py[cod]
-.pytest_cache/
-.ruff_cache/
-.mypy_cache/
-htmlcov/
-.coverage
-.coverage.*
-coverage.xml
diff --git a/.github/workflows/toc.yaml b/.github/workflows/toc.yaml
new file mode 100644
index 00000000..cde1b6d4
--- /dev/null
+++ b/.github/workflows/toc.yaml
@@ -0,0 +1,12 @@
+on:
+ push:
+ branches: [main]
+name: TOC Generator
+jobs:
+ generateTOC:
+ name: TOC Generator
+ runs-on: ubuntu-latest
+ steps:
+ - uses: technote-space/toc-generator@v4.3.1
+ with:
+ TOC_TITLE: ""
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 80893d1c..b5cd6d52 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -7,14 +7,8 @@ repos:
- id: end-of-file-fixer
- id: mixed-line-ending
args: ["--fix=lf"]
- - repo: https://github.com/thlorenz/doctoc
- rev: v2.5.0
- hooks:
- - id: doctoc
- files: ^README[^/]*\.md$
- args: ["--notitle"]
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.16.6
+ rev: v0.16.5
hooks:
- id: ruff
args: ["--fix"]
diff --git a/README.md b/README.md
index 89685620..1ea941fd 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
[](https://github.com/hacs/integration)

-[](#contributors-)
+[](#contributors-)
# 🌞 Adaptive Lighting: Enhance Your Home's Atmosphere with Smart, Sun-Synchronized Lighting 🌙
@@ -84,7 +84,6 @@ The attributes are absent when the Adaptive Lighting switch is off. Use a fallba
- [Additional Information](#additional-information)
- [:sos: Troubleshooting](#sos-troubleshooting)
- [:exclamation: Common Problems & Solutions](#exclamation-common-problems--solutions)
- - [:bulb: Lights Only Adapt After Reloading](#bulb-lights-only-adapt-after-reloading)
- [:bulb: Lights Not Responding or Turning On by Themselves](#bulb-lights-not-responding-or-turning-on-by-themselves)
- [:signal_strength: WiFi Networks](#signal_strength-wifi-networks)
- [:spider_web: Zigbee, Z-Wave, and Other Mesh Networks](#spider_web-zigbee-z-wave-and-other-mesh-networks)
@@ -763,14 +762,6 @@ represent one sent command or the current desired state.
### :exclamation: Common Problems & Solutions
-#### :bulb: Lights Only Adapt After Reloading
-
-If lights stop adapting after you turn them on with a physical switch or a Zigbee-bound remote, check the Adaptive Lighting switch's `manual_control` attribute. With `take_over_control: true` and `detect_non_ha_changes: false`, a turn-on without a matching Home Assistant `light.turn_on` call marks the light as manually controlled. Reloading clears that state, but the next physical turn-on can trigger it again.
-
-To adapt these turn-ons while still detecting later manual changes, enable `detect_non_ha_changes` and leave `manual_control_on_external_turn_on` disabled. This requires the light integration to report its state reliably. If you want Adaptive Lighting to keep adapting regardless of manual changes, disable `take_over_control` along with the options that require it: `detect_non_ha_changes`, `adapt_only_on_bare_turn_on`, and `manual_control_on_external_turn_on`.
-
-This explains the physical-switch case in [#1056](https://github.com/basnijholt/adaptive-lighting/issues/1056), but not every report in that thread. If the light is not listed in `manual_control`, include diagnostics and debug logs from the failed turn-on when reporting it. Lights returning from `unavailable` after a power cut are a separate case from an `off` to `on` state change.
-
#### :bulb: Lights Not Responding or Turning On by Themselves
Adaptive Lighting sends more commands to lights than a typical human user would. If your light control network is unhealthy, you may experience:
@@ -1118,7 +1109,6 @@ Notice the values of `brightness_mode_time_light` and `brightness_mode_time_dark
 Leonhard Hesse 💻 |
 Tim Stallmann 💻 |
 lehneres 🤔 |
-  Ahmad Tawakol 💻 🐛 🚧 |
diff --git a/custom_components/adaptive_lighting/hass_utils.py b/custom_components/adaptive_lighting/hass_utils.py
index 822e2e29..550ae350 100644
--- a/custom_components/adaptive_lighting/hass_utils.py
+++ b/custom_components/adaptive_lighting/hass_utils.py
@@ -4,30 +4,31 @@ import logging
from collections.abc import Awaitable, Callable
from homeassistant.core import HomeAssistant, ServiceCall
-from homeassistant.helpers.target import async_extract_referenced_entity_ids
+from homeassistant.helpers import device_registry, entity_registry
from homeassistant.util.read_only_dict import ReadOnlyDict
-try:
- from homeassistant.helpers.target import TargetSelection
-except ImportError: # Compatibility with older Home Assistant releases
- from homeassistant.helpers.target import TargetSelectorData as TargetSelection
-
from .adaptation_utils import ServiceData
_LOGGER = logging.getLogger(__name__)
-def target_entities(
- hass: HomeAssistant,
- service_data: ServiceData,
-) -> set[str]:
- """Resolve all directly and indirectly targeted entities without groups."""
- selected = async_extract_referenced_entity_ids(
- hass,
- TargetSelection(service_data),
- expand_group=False,
+def area_entities(hass: HomeAssistant, area_id: str):
+ """Get all entities linked to an area."""
+ ent_reg = entity_registry.async_get(hass)
+ entity_ids = [
+ entry.entity_id
+ for entry in entity_registry.async_entries_for_area(ent_reg, area_id)
+ ]
+ dev_reg = device_registry.async_get(hass)
+ entity_ids.extend(
+ [
+ entity.entity_id
+ for device in device_registry.async_entries_for_area(dev_reg, area_id)
+ for entity in entity_registry.async_entries_for_device(ent_reg, device.id)
+ if entity.area_id is None
+ ],
)
- return selected.referenced | selected.indirectly_referenced
+ return entity_ids
def setup_service_call_interceptor(
diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py
index db1c2d0b..d784f325 100644
--- a/custom_components/adaptive_lighting/switch.py
+++ b/custom_components/adaptive_lighting/switch.py
@@ -11,6 +11,7 @@ from copy import deepcopy
from datetime import timedelta
from typing import TYPE_CHECKING, Any
+import homeassistant.helpers.config_validation as cv
import homeassistant.util.dt as dt_util
import ulid_transform
from homeassistant.components.light import (
@@ -20,7 +21,6 @@ from homeassistant.components.light import (
ATTR_SUPPORTED_COLOR_MODES,
ATTR_TRANSITION,
ATTR_XY_COLOR,
- VALID_TRANSITION,
ColorMode,
LightEntityFeature,
is_on,
@@ -32,11 +32,8 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
ATTR_AREA_ID,
- ATTR_DEVICE_ID,
ATTR_DOMAIN,
ATTR_ENTITY_ID,
- ATTR_FLOOR_ID,
- ATTR_LABEL_ID,
ATTR_SERVICE,
ATTR_SERVICE_DATA,
ATTR_SUPPORTED_FEATURES,
@@ -152,7 +149,7 @@ from .const import (
change_switch_settings_schema,
replace_none_str,
)
-from .hass_utils import setup_service_call_interceptor, target_entities
+from .hass_utils import area_entities, setup_service_call_interceptor
from .helpers import (
clamp,
color_difference_redmean,
@@ -623,18 +620,6 @@ def _is_state_event(
)
-def _turn_off_transition(turn_off_event: Event) -> float | None:
- """Normalize the raw event transition using the light service's validator.
-
- Service-call events retain raw data after validation, so repeat the
- service's coercion and clamping before calculating transition windows.
- """
- transition = turn_off_event.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION)
- if transition is None:
- return None
- return VALID_TRANSITION(transition)
-
-
def _expand_light_groups(
hass: HomeAssistant,
lights: list[str],
@@ -899,7 +884,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
assert hass is not None
self.hass = hass
self.manager = manager
- self._removed = False
self.sleep_mode_switch = sleep_mode_switch
self.adapt_color_switch = adapt_color_switch
self.adapt_brightness_switch = adapt_brightness_switch
@@ -1092,7 +1076,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
async def async_will_remove_from_hass(self) -> None:
"""Remove the listeners upon removing the component."""
- self._removed = True
self._remove_listeners()
def _resolve_lights(self, lights: list[str] | None = None) -> list[str]:
@@ -1448,9 +1431,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
if not is_first_call or data.initial_sleep:
await asyncio.sleep(data.sleep_time)
- if self._removed:
- return
-
# Instead of directly iterating the generator in the while-loop, we get
# the next item here after the sleep to make sure it incorporates state
# changes which happened during the sleep.
@@ -1506,9 +1486,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
Wraps the sequence of service calls in a task that can be cancelled from elsewhere, e.g.,
to cancel an ongoing adaptation when a light is turned off.
"""
- if self._removed:
- return
-
# Prevent overlap of multiple adaptation sequences
self.manager.cancel_ongoing_adaptation_calls(data.entity_id)
_LOGGER.debug(
@@ -1712,7 +1689,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
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:
+ if entity_id not in self.lights:
return
await self._update_attrs_and_maybe_adapt_lights(
@@ -1726,8 +1703,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
self,
event: Event[EventStateChangedData],
) -> None:
- new_state = event.data.get("new_state")
- if new_state is None or new_state.state not in (STATE_ON, STATE_OFF):
+ if not _is_state_event(event, (STATE_ON, STATE_OFF)):
_LOGGER.debug("%s: Ignoring sleep event %s", self._name, event)
return
_LOGGER.debug(
@@ -1994,6 +1970,13 @@ class AdaptiveLightingManager:
self._context_cnt += 1
return context
+ def _is_excluded_from_area(self, entity_id: str) -> bool:
+ """Match Home Assistant's exclusions for indirect area targets."""
+ entry = entity_registry.async_get(self.hass).async_get(entity_id)
+ return entry is not None and (
+ entry.entity_category is not None or entry.hidden_by is not None
+ )
+
def _separate_entity_ids(
self,
entity_ids: list[str],
@@ -2177,14 +2160,8 @@ class AdaptiveLightingManager:
entity_ids: list[str],
) -> dict[str, Any]:
"""Modify the service data to contain the entity IDs."""
- for target_key in (
- ATTR_ENTITY_ID,
- ATTR_AREA_ID,
- ATTR_DEVICE_ID,
- ATTR_FLOOR_ID,
- ATTR_LABEL_ID,
- ):
- service_data.pop(target_key, None)
+ service_data.pop(ATTR_ENTITY_ID, None)
+ service_data.pop(ATTR_AREA_ID, None)
service_data[ATTR_ENTITY_ID] = entity_ids
return service_data
@@ -2656,11 +2633,31 @@ class AdaptiveLightingManager:
records.pop(light, None)
def _get_entity_list(self, service_data: ServiceData) -> list[str]:
- return sorted(
- entity_id
- for entity_id in target_entities(self.hass, service_data)
- if entity_id.startswith(f"{LIGHT_DOMAIN}.")
+ if ATTR_ENTITY_ID in service_data:
+ return cv.ensure_list_csv(service_data[ATTR_ENTITY_ID])
+ if ATTR_AREA_ID in service_data:
+ entity_ids: list[str] = []
+ area_ids: list[str] = cv.ensure_list_csv(service_data[ATTR_AREA_ID])
+ for area_id in area_ids:
+ area_entity_ids = area_entities(self.hass, area_id)
+ eids = [
+ entity_id
+ for entity_id in area_entity_ids
+ if entity_id.startswith(LIGHT_DOMAIN)
+ and not self._is_excluded_from_area(entity_id)
+ ]
+ entity_ids.extend(eids)
+ _LOGGER.debug(
+ "Found entity_ids '%s' for area_id '%s'",
+ entity_ids,
+ area_id,
+ )
+ return entity_ids
+ _LOGGER.debug(
+ "No entity_ids or area_ids found in service_data: %s",
+ service_data,
)
+ return []
async def turn_on_off_event_listener(self, event: Event) -> None:
"""Track 'light.turn_off' and 'light.turn_on' service calls."""
@@ -2752,7 +2749,7 @@ class AdaptiveLightingManager:
elif state.state == STATE_OFF: # is turning on
await on(eid, event)
- async def state_changed_event_listener( # noqa: PLR0912
+ async def state_changed_event_listener(
self,
event: Event[EventStateChangedData],
) -> None:
@@ -2830,10 +2827,6 @@ class AdaptiveLightingManager:
new_on.context.id,
)
- if old_on and not new_on:
- # Availability loss invalidates pending commands, not manual state.
- self.cancel_ongoing_adaptation_calls(entity_id)
-
if old_on and new_off:
# Tracks 'on' → 'off' state changes
self.on_to_off_event[entity_id] = event
@@ -3059,7 +3052,7 @@ class AdaptiveLightingManager:
def _member_turn_on_explains_group_turn_on(
self,
entity_id: str,
- off_event: Event,
+ on_to_off_event: Event[EventStateChangedData],
off_to_on_event: Event[EventStateChangedData],
) -> bool:
"""Check if a light group's 'off' → 'on' is caused by a member's 'light.turn_on'.
@@ -3079,7 +3072,7 @@ class AdaptiveLightingManager:
member_turn_on = self.turn_on_event.get(member)
if (
member_turn_on is not None
- and off_event.time_fired
+ and on_to_off_event.time_fired
< member_turn_on.time_fired
<= off_to_on_event.time_fired
):
@@ -3094,49 +3087,6 @@ class AdaptiveLightingManager:
return True
return False
- def _off_to_on_event_is_during_turn_off(
- self,
- entity_id: str,
- off_to_on_event: Event[EventStateChangedData],
- ) -> bool:
- """Check if a reported turn-on belongs to a recent turn-off window."""
- turn_off_event = self.turn_off_event.get(entity_id)
- if (
- turn_off_event is None
- or off_to_on_event.context.id != turn_off_event.context.id
- ):
- return False
-
- turn_on_event = self.turn_on_event.get(entity_id)
- if (
- turn_on_event is not None
- and turn_off_event.time_fired
- < turn_on_event.time_fired
- <= off_to_on_event.time_fired
- ):
- return False
- if self._member_turn_on_explains_group_turn_on(
- entity_id,
- turn_off_event,
- off_to_on_event,
- ):
- return False
-
- transition = _turn_off_transition(turn_off_event)
- delay = max(transition or 0, TURNING_OFF_DELAY)
- elapsed = (dt_util.utcnow() - turn_off_event.time_fired).total_seconds()
- if not 0 <= elapsed <= delay:
- return False
-
- _LOGGER.debug(
- "just_turned_off: Fresh 'light.turn_off' for '%s' shares the"
- " 'off' → 'on' context; ignoring the state during its %s second"
- " transition window.",
- entity_id,
- delay,
- )
- return True
-
async def just_turned_off( # noqa: PLR0911, PLR0912
self,
entity_id: str,
@@ -3155,8 +3105,6 @@ class AdaptiveLightingManager:
"""
off_to_on_event = self.off_to_on_event[entity_id]
on_to_off_event = self.on_to_off_event.get(entity_id)
- if self._off_to_on_event_is_during_turn_off(entity_id, off_to_on_event):
- return True
if on_to_off_event is None:
_LOGGER.debug(
@@ -3206,7 +3154,7 @@ class AdaptiveLightingManager:
turn_off_event = self.turn_off_event.get(entity_id)
if turn_off_event is not None:
- transition = _turn_off_transition(turn_off_event)
+ transition = turn_off_event.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION)
else:
transition = None
diff --git a/docs/advanced/sleep-mode.md b/docs/advanced/sleep-mode.md
index 9d90c854..40736340 100644
--- a/docs/advanced/sleep-mode.md
+++ b/docs/advanced/sleep-mode.md
@@ -22,20 +22,6 @@ target:
entity_id: switch.adaptive_lighting_sleep_mode_living_room
```
-Sleep mode stays active until this switch is turned off. It does not turn off
-automatically at sunrise, and Home Assistant restores its previous state after a
-restart. Use an automation, such as the sleep-mode blueprint linked under
-Automation Examples, when you want the switch to follow a schedule or helper.
-
-If lights unexpectedly use `sleep_brightness` or `sleep_color_temp` during the
-day, first check that the sleep-mode switch is off. While the main Adaptive
-Lighting switch is on, it reports the current calculated `brightness_pct` and
-`color_temp_kelvin` targets, including the sleep settings while sleep mode is on.
-You can compare these attributes with the physical light state. They are `None`
-when the main switch is off. In debug logs,
-`initial_sleep=True` describes an internal delay before sending a command; it does
-not mean that sleep mode is active.
-
## Configuration Options
Sleep mode is configured through the main Adaptive Lighting configuration. See the [Configuration](../configuration.md) page for the full options table. The sleep-related options are:
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index f0596563..f39c62bc 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -45,14 +45,6 @@ represent one sent command or the current desired state.
-#### :bulb: Lights Only Adapt After Reloading
-
-If lights stop adapting after you turn them on with a physical switch or a Zigbee-bound remote, check the Adaptive Lighting switch's `manual_control` attribute. With `take_over_control: true` and `detect_non_ha_changes: false`, a turn-on without a matching Home Assistant `light.turn_on` call marks the light as manually controlled. Reloading clears that state, but the next physical turn-on can trigger it again.
-
-To adapt these turn-ons while still detecting later manual changes, enable `detect_non_ha_changes` and leave `manual_control_on_external_turn_on` disabled. This requires the light integration to report its state reliably. If you want Adaptive Lighting to keep adapting regardless of manual changes, disable `take_over_control` along with the options that require it: `detect_non_ha_changes`, `adapt_only_on_bare_turn_on`, and `manual_control_on_external_turn_on`.
-
-This explains the physical-switch case in [#1056](https://github.com/basnijholt/adaptive-lighting/issues/1056), but not every report in that thread. If the light is not listed in `manual_control`, include diagnostics and debug logs from the failed turn-on when reporting it. Lights returning from `unavailable` after a power cut are a separate case from an `off` to `on` state change.
-
#### :bulb: Lights Not Responding or Turning On by Themselves
Adaptive Lighting sends more commands to lights than a typical human user would. If your light control network is unhealthy, you may experience:
diff --git a/scripts/setup-symlinks b/scripts/setup-symlinks
index af95b831..91026b3a 100755
--- a/scripts/setup-symlinks
+++ b/scripts/setup-symlinks
@@ -2,17 +2,12 @@
set -ex
cd "$(dirname "$0")/.."
-# '-n' keeps a re-run idempotent: without it 'ln -fs' follows an existing
-# symlink and creates the new link *inside* the target directory, leaving a
-# stray 'tests/tests' and 'custom_components/adaptive_lighting/adaptive_lighting'
-# in the working tree.
-
# Link custom components
cd core/homeassistant/components/
-ln -fsn ../../../custom_components/adaptive_lighting adaptive_lighting
+ln -fs ../../../custom_components/adaptive_lighting adaptive_lighting
cd -
# Link tests
cd core/tests/components/
-ln -fsn ../../../tests/ adaptive_lighting
+ln -fs ../../../tests/ adaptive_lighting
cd -
diff --git a/tests/test_switch.py b/tests/test_switch.py
index a3f5b689..d27aa5f6 100644
--- a/tests/test_switch.py
+++ b/tests/test_switch.py
@@ -30,7 +30,6 @@ from homeassistant.components.adaptive_lighting.const import (
ATTR_ADAPT_BRIGHTNESS,
ATTR_ADAPT_COLOR,
ATTR_ADAPTIVE_LIGHTING_MANAGER,
- CONF_ADAPT_DELAY,
CONF_ADAPT_ONLY_ON_BARE_TURN_ON,
CONF_ADAPT_UNTIL_SLEEP,
CONF_AUTORESET_CONTROL,
@@ -47,7 +46,6 @@ from homeassistant.components.adaptive_lighting.const import (
CONF_MIN_BRIGHTNESS,
CONF_MIN_COLOR_TEMP,
CONF_MULTI_LIGHT_INTERCEPT,
- CONF_ONLY_ONCE,
CONF_PREFER_RGB_COLOR,
CONF_RESET_MANUAL_CONTROL_ON_SLEEP_MODE_CHANGE,
CONF_SEND_SPLIT_DELAY,
@@ -83,7 +81,6 @@ from homeassistant.components.adaptive_lighting.switch import (
SimpleSwitch,
_attributes_have_changed,
_expand_light_groups,
- _turn_off_transition,
color_difference_redmean,
create_context,
is_our_context,
@@ -111,9 +108,6 @@ from homeassistant.const import (
ATTR_AREA_ID,
ATTR_DEVICE_ID,
ATTR_ENTITY_ID,
- ATTR_FLOOR_ID,
- ATTR_LABEL_ID,
- ATTR_SERVICE_DATA,
ATTR_SUPPORTED_FEATURES,
CONF_LIGHTS,
CONF_NAME,
@@ -123,7 +117,6 @@ from homeassistant.const import (
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
- STATE_UNAVAILABLE,
EntityCategory,
)
from homeassistant.core import Context, CoreState, Event, HomeAssistant, State
@@ -1539,10 +1532,8 @@ async def test_apply_updates_non_ha_change_baseline(
)
direction = 1 if manual_value < adaptive_value else -1
- # Legacy template lights round via mireds; 70 K keeps one reported step
- # below 100 K and two steps above it across the configured range.
small_change = (
- 15 if manual_attribute == LightControlAttributes.BRIGHTNESS else 70
+ 15 if manual_attribute == LightControlAttributes.BRIGHTNESS else 60
)
freezer.tick(90)
set_physical_state(manual_value + direction * small_change)
@@ -4260,27 +4251,6 @@ def _turn_on_service_event(entity_ids: list[str], ts: float, context: Context) -
)
-def _turn_off_service_event(
- entity_ids: list[str],
- ts: float,
- context: Context,
- transition: float | str | None,
-) -> Event:
- service_data = {ATTR_ENTITY_ID: entity_ids}
- if transition is not None:
- service_data[ATTR_TRANSITION] = transition
- return Event(
- EVENT_CALL_SERVICE,
- {
- "domain": LIGHT_DOMAIN,
- "service": SERVICE_TURN_OFF,
- "service_data": service_data,
- },
- time_fired_timestamp=ts,
- context=context,
- )
-
-
async def test_just_turned_off_group_context_reuse(hass, cleanup):
"""Group 'off' → 'on' with a reused 'turn_off' context must still adapt.
@@ -4339,154 +4309,6 @@ async def test_just_turned_off_group_context_reuse(hass, cleanup):
assert await manager.just_turned_off(group)
-def _register_mixed_target_lights(
- hass,
- device_registry,
- floor_registry,
- label_registry,
-):
- """Assign the three test lights to mixed indirect HA targets."""
- floor = floor_registry.async_create("Upstairs")
- area_registry = ar.async_get(hass)
- upstairs_area = area_registry.async_create(
- "Upstairs room",
- floor_id=floor.floor_id,
- )
- hall_area = area_registry.async_create("Hall")
-
- config_entry = MockConfigEntry(domain="test")
- config_entry.add_to_hass(hass)
- device = device_registry.async_get_or_create(
- config_entry_id=config_entry.entry_id,
- identifiers={("test", "device-target")},
- )
- label = label_registry.async_create("Skipped light")
-
- registry = entity_registry.async_get(hass)
- registry.async_update_entity(ENTITY_LIGHT_1, area_id=upstairs_area.id)
- registry.async_update_entity(ENTITY_LIGHT_2, area_id=hall_area.id)
- registry.async_update_entity(
- ENTITY_LIGHT_3,
- device_id=device.id,
- labels={label.label_id},
- )
- return {
- ATTR_FLOOR_ID: floor.floor_id,
- ATTR_AREA_ID: hall_area.id,
- ATTR_DEVICE_ID: device.id,
- ATTR_LABEL_ID: label.label_id,
- }
-
-
-async def test_mixed_turn_off_targets_do_not_readapt_off_device_light(
- hass,
- device_registry,
- floor_registry,
- label_registry,
- cleanup,
-):
- """A mixed-target turn-off must cover an already-off device light (#1069)."""
- await setup_lights(hass)
- targets = _register_mixed_target_lights(
- hass,
- device_registry,
- floor_registry,
- label_registry,
- )
- targets.pop(ATTR_LABEL_ID)
-
- _, switch = await setup_switch(
- hass,
- {
- CONF_LIGHTS: [ENTITY_LIGHT_1, ENTITY_LIGHT_2, ENTITY_LIGHT_3],
- CONF_DETECT_NON_HA_CHANGES: True,
- CONF_INTERCEPT: True,
- CONF_INITIAL_TRANSITION: 0,
- },
- )
- assert hass.states.is_state(ENTITY_LIGHT_3, STATE_OFF)
-
- turn_off_context = Context()
- await hass.services.async_call(
- LIGHT_DOMAIN,
- SERVICE_TURN_OFF,
- {
- **targets,
- ATTR_TRANSITION: 10,
- },
- blocking=True,
- context=turn_off_context,
- )
- await hass.async_block_till_done()
-
- calls = _track_adaptive_light_calls(hass)
- off_state = hass.states.get(ENTITY_LIGHT_3)
- assert off_state is not None
- hass.states.async_set(
- ENTITY_LIGHT_3,
- STATE_ON,
- off_state.attributes,
- context=turn_off_context,
- )
- await hass.async_block_till_done()
-
- assert not calls
-
-
-async def test_intercept_replaces_all_mixed_target_selectors(
- hass,
- device_registry,
- floor_registry,
- label_registry,
- cleanup,
-):
- """A narrowed intercepted call must not retain indirect target selectors."""
- lights = await setup_lights(hass)
- targets = _register_mixed_target_lights(
- hass,
- device_registry,
- floor_registry,
- label_registry,
- )
- await hass.services.async_call(
- LIGHT_DOMAIN,
- SERVICE_TURN_OFF,
- {
- ATTR_ENTITY_ID: [ENTITY_LIGHT_1, ENTITY_LIGHT_2, ENTITY_LIGHT_3],
- },
- blocking=True,
- )
- await setup_switch(
- hass,
- {
- CONF_LIGHTS: [ENTITY_LIGHT_1, ENTITY_LIGHT_2],
- CONF_INTERCEPT: True,
- CONF_MULTI_LIGHT_INTERCEPT: True,
- CONF_INITIAL_TRANSITION: 0,
- CONF_MIN_BRIGHTNESS: 50,
- CONF_MAX_BRIGHTNESS: 50,
- },
- )
-
- with patch.object(
- lights[2],
- "async_turn_on",
- wraps=lights[2].async_turn_on,
- ) as skipped_turn_on:
- await hass.services.async_call(
- LIGHT_DOMAIN,
- SERVICE_TURN_ON,
- {**targets, ATTR_BRIGHTNESS: 200},
- blocking=True,
- )
- await hass.async_block_till_done()
-
- assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 128
- assert hass.states.get(ENTITY_LIGHT_2).attributes[ATTR_BRIGHTNESS] == 128
- skipped_turn_on.assert_awaited_once()
- assert skipped_turn_on.call_args.kwargs[ATTR_BRIGHTNESS] == 200
-
-
async def test_just_turned_off_same_automation_context(hass, cleanup):
"""'turn_off' and 'turn_on' from one automation share a context.
@@ -4503,12 +4325,6 @@ async def test_just_turned_off_same_automation_context(hass, cleanup):
now = dt_util.utcnow().timestamp()
automation_context = Context()
- manager.turn_off_event[ENTITY_LIGHT_1] = _turn_off_service_event(
- [ENTITY_LIGHT_1],
- now - 2,
- automation_context,
- transition=10,
- )
manager.on_to_off_event[ENTITY_LIGHT_1] = _state_changed_event(
ENTITY_LIGHT_1,
now - 2,
@@ -4547,133 +4363,6 @@ async def test_just_turned_off_same_automation_context(hass, cleanup):
)
assert await manager.just_turned_off(ENTITY_LIGHT_1)
- # A later physical turn-on has a fresh context and must not remain blocked by
- # the old turn-off record after its transition window has elapsed.
- manager.on_to_off_event[ENTITY_LIGHT_1] = _state_changed_event(
- ENTITY_LIGHT_1,
- now - 20,
- automation_context,
- )
- manager.turn_off_event[ENTITY_LIGHT_1] = _turn_off_service_event(
- [ENTITY_LIGHT_1],
- now - 20,
- automation_context,
- transition=10,
- )
- manager.off_to_on_event[ENTITY_LIGHT_1] = _state_changed_event(
- ENTITY_LIGHT_1,
- now,
- Context(),
- )
- assert not await manager.just_turned_off(ENTITY_LIGHT_1)
-
-
-@pytest.mark.parametrize(
- ("transition", "window"),
- [(10, 10), (10.0, 10), ("10", 10), ("10000", 6553), ("inf", 6553), (None, 5)],
-)
-async def test_just_turned_off_normalized_transition(hass, cleanup, transition, window):
- """Both turn-off guards use coerced and clamped transition windows."""
- await setup_lights(hass)
- _, switch = await setup_switch(hass, {CONF_LIGHTS: [ENTITY_LIGHT_1]})
- await hass.async_block_till_done()
- manager = switch.manager
-
- now = dt_util.utcnow().timestamp()
- context = Context()
- other_context = Context()
-
- # Setting up the switch turns the light on, and that 'turn_on' would be read
- # as the legitimate explanation for the 'off' → 'on' state changes below.
- manager.turn_on_event.pop(ENTITY_LIGHT_1, None)
-
- def set_events(turn_off_ts: float, off_to_on_context: Context) -> None:
- manager.turn_off_event[ENTITY_LIGHT_1] = _turn_off_service_event(
- [ENTITY_LIGHT_1],
- turn_off_ts,
- context,
- transition=transition,
- )
- manager.on_to_off_event[ENTITY_LIGHT_1] = _state_changed_event(
- ENTITY_LIGHT_1,
- turn_off_ts,
- other_context,
- )
- manager.off_to_on_event[ENTITY_LIGHT_1] = _state_changed_event(
- ENTITY_LIGHT_1,
- now,
- off_to_on_context,
- )
-
- # A matching context is ignored within the normalized transition window.
- set_events(now - window + 1, context)
- assert await manager.just_turned_off(ENTITY_LIGHT_1)
-
- # Past that window the same shape must stop matching.
- set_events(now - window - 1, context)
- assert not await manager.just_turned_off(ENTITY_LIGHT_1)
-
- # `just_turned_off`'s own `max(transition, TURNING_OFF_DELAY)`: reached when
- # the 'off' → 'on' state change carries a fresh context, so the check above
- # returns early and the delay is computed from the 'on' → 'off' change.
- manager.turn_off_event[ENTITY_LIGHT_1] = _turn_off_service_event(
- [ENTITY_LIGHT_1],
- now - window - 1,
- context,
- transition=transition,
- )
- manager.on_to_off_event[ENTITY_LIGHT_1] = _state_changed_event(
- ENTITY_LIGHT_1,
- now - window - 1,
- context,
- )
- manager.off_to_on_event[ENTITY_LIGHT_1] = _state_changed_event(
- ENTITY_LIGHT_1,
- now,
- Context(),
- )
- assert not await manager.just_turned_off(ENTITY_LIGHT_1)
-
-
-@pytest.mark.parametrize(
- ("transition", "expected"),
- [("2", 2.0), ("10000", 6553), ("inf", 6553), ("-2", 0), (None, None)],
-)
-async def test_turn_off_event_keeps_raw_transition(hass, cleanup, transition, expected):
- """Normalize raw event data to the same transition used by the light service."""
- await setup_lights(hass)
- _, switch = await setup_switch(hass, {CONF_LIGHTS: [ENTITY_LIGHT_1]})
- await hass.async_block_till_done()
- manager = switch.manager
-
- service_data = {ATTR_ENTITY_ID: ENTITY_LIGHT_1}
- if transition is not None:
- service_data[ATTR_TRANSITION] = transition
- await hass.services.async_call(
- LIGHT_DOMAIN,
- SERVICE_TURN_OFF,
- service_data,
- blocking=True,
- )
- await hass.async_block_till_done()
-
- event = manager.turn_off_event[ENTITY_LIGHT_1]
- assert event.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION) == transition
- assert _turn_off_transition(event) == expected
-
- # A 'transition' that cannot be coerced is rejected by the schema, so it
- # never reaches the listener.
- manager.turn_off_event.pop(ENTITY_LIGHT_1)
- with pytest.raises(voluptuous.error.MultipleInvalid):
- await hass.services.async_call(
- LIGHT_DOMAIN,
- SERVICE_TURN_OFF,
- {ATTR_ENTITY_ID: ENTITY_LIGHT_1, ATTR_TRANSITION: "not-a-number"},
- blocking=True,
- )
- await hass.async_block_till_done()
- assert ENTITY_LIGHT_1 not in manager.turn_off_event
-
async def test_just_turned_off_group_context_reuse_end_to_end(hass, cleanup):
"""A tracked member turn-on explains a group's reused OFF context (#1378)."""
@@ -5410,27 +5099,7 @@ async def test_adapt_only_on_bare_turn_on_respects_pause_changed_mode(hass, inte
)
-@pytest.mark.parametrize(
- ("repeat_bare_turn_on", "mode", "intercept"),
- [
- (False, TakeOverControlMode.PAUSE_ALL, False),
- (False, TakeOverControlMode.PAUSE_CHANGED, False),
- (True, TakeOverControlMode.PAUSE_ALL, True),
- (True, TakeOverControlMode.PAUSE_CHANGED, True),
- ],
- ids=[
- "direct-pause-all-reactive",
- "direct-pause-changed-reactive",
- "bare-turn-on-pause-all-intercept",
- "bare-turn-on-pause-changed-intercept",
- ],
-)
-async def test_detect_non_ha_changes_with_separate_turn_on_commands(
- hass,
- repeat_bare_turn_on,
- mode,
- intercept,
-):
+async def test_detect_non_ha_changes_with_separate_turn_on_commands(hass):
"""Regression test for detect_non_ha_changes with separate_turn_on_commands.
With separate_turn_on_commands=True, each adaptation cycle makes two sequential
@@ -5438,9 +5107,6 @@ async def test_detect_non_ha_changes_with_separate_turn_on_commands(
last_service_data instead of merging, brightness is dropped — and
_attributes_have_changed silently skips the brightness comparison, so a direct
Zigbee brightness change is never detected as manual control.
-
- A repeated bare light.turn_on from an automation must not hide the physical
- change before the periodic adaptation path runs.
"""
switch, (light, *_) = await setup_lights_and_switch(
hass,
@@ -5448,21 +5114,10 @@ async def test_detect_non_ha_changes_with_separate_turn_on_commands(
CONF_SEPARATE_TURN_ON_COMMANDS: True,
CONF_DETECT_NON_HA_CHANGES: True,
CONF_TAKE_OVER_CONTROL: True,
- CONF_TAKE_OVER_CONTROL_MODE: mode,
- CONF_INTERCEPT: intercept,
},
)
- _mock_sun_light_settings(
- switch,
- {
- ATTR_BRIGHTNESS_PCT: 50,
- ATTR_COLOR_TEMP_KELVIN: 3000,
- "force_rgb_color": False,
- },
- )
-
- context = switch.create_context("interval")
+ context = switch.create_context("test")
async def update(force: bool = False):
await switch._update_attrs_and_maybe_adapt_lights(
@@ -5474,10 +5129,17 @@ async def test_detect_non_ha_changes_with_separate_turn_on_commands(
await update(force=True)
+ last_sd = switch.manager.last_service_data.get(ENTITY_LIGHT_1)
+ assert last_sd is not None, "last_service_data not set after force adapt"
+ assert (
+ ATTR_BRIGHTNESS in last_sd
+ ), f"brightness missing from last_service_data after split calls: {last_sd}"
+ assert (
+ ATTR_COLOR_TEMP_KELVIN in last_sd or ATTR_RGB_COLOR in last_sd
+ ), f"color missing from last_service_data after split calls: {last_sd}"
+
al_brightness = light.brightness
assert al_brightness is not None
- al_color_temp = light.color_temp_kelvin
- assert al_color_temp is not None
switch.manager.manual_control[ENTITY_LIGHT_1] = LightControlAttributes.NONE
manual_brightness = (
@@ -5485,24 +5147,6 @@ async def test_detect_non_ha_changes_with_separate_turn_on_commands(
)
set_light_brightness(light, manual_brightness)
- if repeat_bare_turn_on:
- await hass.services.async_call(
- LIGHT_DOMAIN,
- SERVICE_TURN_ON,
- {ATTR_ENTITY_ID: light.entity_id},
- blocking=True,
- )
- await hass.async_block_till_done()
-
- _mock_sun_light_settings(
- switch,
- {
- ATTR_BRIGHTNESS_PCT: 50,
- ATTR_COLOR_TEMP_KELVIN: 4000,
- "force_rgb_color": False,
- },
- )
-
async def _flush_attr_state(hass, entity_id):
"""Mimic a ZHA attribute report: write current hardware state to HA."""
light.async_write_ha_state()
@@ -5512,15 +5156,20 @@ async def test_detect_non_ha_changes_with_separate_turn_on_commands(
new=AsyncMock(side_effect=_flush_attr_state),
):
await update(force=False)
+
+ assert LightControlAttributes.BRIGHTNESS in switch.manager.manual_control.get(
+ ENTITY_LIGHT_1,
+ LightControlAttributes.NONE,
+ ), (
+ f"manual_control={switch.manager.manual_control.get(ENTITY_LIGHT_1)}, "
+ f"last_service_data={switch.manager.last_service_data.get(ENTITY_LIGHT_1)}"
+ )
+
await update(force=False)
assert (
light.brightness == manual_brightness
- ), f"AL overrode manual brightness {manual_brightness} with {light.brightness}"
- expected_color_temp = (
- 4000 if mode == TakeOverControlMode.PAUSE_CHANGED else al_color_temp
- )
- assert light.color_temp_kelvin == expected_color_temp
+ ), f"AL overrode manual brightness {manual_brightness} with {al_brightness}"
async def test_fresh_install_entity_ids(hass):
@@ -6102,245 +5751,3 @@ async def test_shared_profiles_keep_independent_sun_schedules(
noon = hass.states.get(ENTITY_LIGHT_1)
assert noon.attributes[ATTR_BRIGHTNESS] == 77
assert noon.attributes[ATTR_COLOR_TEMP_KELVIN] > 2000
-
-
-@pytest.mark.parametrize("via_unavailable", [False, True])
-async def test_split_adaptation_cancelled_after_physical_off(
- hass,
- monkeypatch,
- via_unavailable,
-):
- """Pending split commands must not resurrect a physically switched-off light."""
- switch, _ = await setup_lights_and_switch(
- hass,
- {
- CONF_DETECT_NON_HA_CHANGES: True,
- CONF_ONLY_ONCE: True,
- CONF_SEPARATE_TURN_ON_COMMANDS: True,
- CONF_SEND_SPLIT_DELAY: 1234,
- CONF_INITIAL_TRANSITION: 0,
- CONF_MIN_BRIGHTNESS: 50,
- CONF_MAX_BRIGHTNESS: 50,
- },
- )
- state = hass.states.get(ENTITY_LIGHT_1)
- hass.states.async_set(ENTITY_LIGHT_1, STATE_OFF, state.attributes)
- await hass.async_block_till_done()
- # Isolate the split-command lifetime from the separate turn-off debounce.
- 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 == 1.234:
- 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)
- assert len(calls) == 1
- if via_unavailable:
- hass.states.async_set(ENTITY_LIGHT_1, STATE_UNAVAILABLE, state.attributes)
- await original_sleep(0)
- hass.states.async_set(ENTITY_LIGHT_1, STATE_OFF, state.attributes)
- await original_sleep(0)
- release.set()
- await hass.async_block_till_done()
- assert len(calls) == 1, f"Physical OFF resurrected by split command: {calls}"
- assert hass.states.get(ENTITY_LIGHT_1).state == STATE_OFF
-
-
-@pytest.mark.parametrize("remaining_profile", [False, True])
-async def test_profile_unloaded_during_adapt_delay(
- hass,
- monkeypatch,
- remaining_profile,
-):
- """A removed profile must not send commands after its adaptation delay."""
- switch, _ = await setup_lights_and_switch(
- hass,
- {
- CONF_DETECT_NON_HA_CHANGES: True,
- CONF_ONLY_ONCE: True,
- CONF_ADAPT_DELAY: 0.1234,
- },
- )
- if remaining_profile:
- _, other = await setup_switch(
- hass,
- {
- CONF_NAME: "remaining",
- CONF_LIGHTS: [ENTITY_LIGHT_1],
- CONF_ONLY_ONCE: True,
- CONF_INITIAL_TRANSITION: 0,
- },
- )
- await other.async_turn_off()
- 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)
- entry = hass.config_entries.async_entries(DOMAIN)[0]
- await hass.config_entries.async_unload(entry.entry_id)
- calls.clear()
- release.set()
- await hass.async_block_till_done()
- assert calls == []
- if remaining_profile:
- await other.async_turn_on()
- await other._update_attrs_and_maybe_adapt_lights(
- context=other.create_context("test"),
- lights=[ENTITY_LIGHT_1],
- force=True,
- )
- await hass.async_block_till_done()
- assert calls
- assert hass.states.get(ENTITY_LIGHT_1).state == STATE_ON
-
-
-async def test_profile_unloaded_during_split_delay(hass, monkeypatch):
- """Removed profiles must not send remaining split commands."""
- switch, _ = await setup_lights_and_switch(
- hass,
- {
- CONF_DETECT_NON_HA_CHANGES: True,
- CONF_ONLY_ONCE: True,
- CONF_SEPARATE_TURN_ON_COMMANDS: True,
- CONF_SEND_SPLIT_DELAY: 1234,
- CONF_INITIAL_TRANSITION: 0,
- CONF_MIN_BRIGHTNESS: 50,
- CONF_MAX_BRIGHTNESS: 50,
- },
- )
- state = hass.states.get(ENTITY_LIGHT_1)
- hass.states.async_set(ENTITY_LIGHT_1, STATE_OFF, state.attributes)
- await hass.async_block_till_done()
- # Isolate the split-command lifetime from the separate turn-off debounce.
- 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 == 1.234:
- 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)
- assert len(calls) == 1
- entry = hass.config_entries.async_entries(DOMAIN)[0]
- assert await hass.config_entries.async_unload(entry.entry_id)
- release.set()
- await hass.async_block_till_done()
- assert len(calls) == 1
- assert hass.states.get(ENTITY_LIGHT_1).state == STATE_ON
-
-
-@pytest.mark.parametrize("unload_before_split", [False, True])
-async def test_unloaded_polling_profile_preserves_other_split_adaptation(
- hass,
- monkeypatch,
- unload_before_split,
-):
- """A removed profile resuming a poll must not cancel another profile's work."""
- switch, _ = await setup_lights_and_switch(hass, {CONF_ONLY_ONCE: True})
- _, other = await setup_switch(
- hass,
- {
- CONF_NAME: "remaining",
- CONF_LIGHTS: [ENTITY_LIGHT_1],
- CONF_ONLY_ONCE: True,
- CONF_SEPARATE_TURN_ON_COMMANDS: True,
- CONF_SEND_SPLIT_DELAY: 1234,
- CONF_INITIAL_TRANSITION: 0,
- },
- )
- poll_entered, poll_release = asyncio.Event(), asyncio.Event()
- split_entered, split_release = asyncio.Event(), asyncio.Event()
- original_update = switch.manager.update_manually_controlled_from_untracked_change
- original_sleep = asyncio.sleep
-
- async def delayed_update(profile, *args, **kwargs):
- if profile is switch:
- poll_entered.set()
- await poll_release.wait()
- await original_update(profile, *args, **kwargs)
-
- async def controlled_sleep(delay, *args, **kwargs):
- if delay == 1.234:
- split_entered.set()
- await split_release.wait()
- else:
- await original_sleep(delay, *args, **kwargs)
-
- monkeypatch.setattr(
- switch.manager,
- "update_manually_controlled_from_untracked_change",
- delayed_update,
- )
- monkeypatch.setattr(asyncio, "sleep", controlled_sleep)
- calls = _track_adaptive_light_calls(hass)
- polling = hass.async_create_task(
- switch._update_attrs_and_maybe_adapt_lights(
- context=switch.create_context("test"),
- lights=[ENTITY_LIGHT_1],
- force=True,
- ),
- )
- await asyncio.wait_for(poll_entered.wait(), 2)
- entry = hass.config_entries.async_entries(DOMAIN)[0]
- if unload_before_split:
- assert await hass.config_entries.async_unload(entry.entry_id)
- adapting = hass.async_create_task(
- other._adapt_light(
- ENTITY_LIGHT_1,
- other.create_context("test"),
- 0,
- force=True,
- ),
- )
- await asyncio.wait_for(split_entered.wait(), 2)
- assert len(calls) == 1
- if not unload_before_split:
- assert await hass.config_entries.async_unload(entry.entry_id)
- poll_release.set()
- await polling
- split_release.set()
- await adapting
- await hass.async_block_till_done()
- assert len(calls) == 2
- assert ATTR_COLOR_TEMP_KELVIN in calls[-1]