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 @@ [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/hacs/integration) ![Version](https://img.shields.io/github/v/release/basnijholt/adaptive-lighting?style=for-the-badge) -[![All Contributors](https://img.shields.io/badge/all_contributors-173-orange.svg?style=flat-square)](#contributors-) +[![All Contributors](https://img.shields.io/badge/all_contributors-172-orange.svg?style=flat-square)](#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
Leonhard Hesse

💻 Tim Stallmann
Tim Stallmann

💻 lehneres
lehneres

🤔 - Ahmad Tawakol
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]