Compare commits

..

No commits in common. "main" and "v1.32.0" have entirely different histories.

24 changed files with 111 additions and 884 deletions

View file

@ -1568,37 +1568,6 @@
"contributions": [ "contributions": [
"ideas" "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"
]
},
{
"login": "SGXander",
"name": "SGXander",
"avatar_url": "https://avatars.githubusercontent.com/u/8619536?v=4",
"profile": "https://github.com/SGXander",
"contributions": [
"code",
"bug",
"test"
]
},
{
"login": "FezVrasta",
"name": "Federico Zivolo",
"avatar_url": "https://avatars.githubusercontent.com/u/5382443?v=4",
"profile": "https://fezvrasta.github.io",
"contributions": [
"translation"
]
} }
], ],
"contributorsPerLine": 7, "contributorsPerLine": 7,

View file

@ -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

View file

@ -22,8 +22,8 @@ jobs:
platform: [linux/amd64, linux/arm64] platform: [linux/amd64, linux/arm64]
steps: steps:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v7.0.1
- uses: docker/setup-qemu-action@v4.4.0 - uses: docker/setup-qemu-action@v4.3.0
- uses: docker/setup-buildx-action@v4.4.1 - uses: docker/setup-buildx-action@v4.3.0
- uses: docker/login-action@v4.6.0 - uses: docker/login-action@v4.6.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
@ -39,7 +39,7 @@ jobs:
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
- uses: docker/build-push-action@v7.4.0 - uses: docker/build-push-action@v7.3.0
with: with:
context: . context: .
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}

View file

@ -28,7 +28,7 @@ jobs:
python-version: '3.14.7' python-version: '3.14.7'
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v10.2.0 uses: astral-sh/setup-uv@v10.0.1
- name: Install dependencies - name: Install dependencies
run: uv sync --group docs run: uv sync --group docs

View file

@ -32,7 +32,7 @@ runs:
with: with:
python-version: ${{ inputs.python-version }} python-version: ${{ inputs.python-version }}
- name: Set up UV - name: Set up UV
uses: astral-sh/setup-uv@v10.2.0 uses: astral-sh/setup-uv@v10.0.1
- name: Install dependencies - name: Install dependencies
shell: bash shell: bash
run: | run: |

View file

@ -23,7 +23,7 @@ jobs:
python-version: "3.14.7" python-version: "3.14.7"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v10.2.0 uses: astral-sh/setup-uv@v10.0.1
- name: Update generated content - name: Update generated content
run: ./scripts/update-generated-content run: ./scripts/update-generated-content

12
.github/workflows/toc.yaml vendored Normal file
View file

@ -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: ""

View file

@ -7,14 +7,8 @@ repos:
- id: end-of-file-fixer - id: end-of-file-fixer
- id: mixed-line-ending - id: mixed-line-ending
args: ["--fix=lf"] 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 - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.8 rev: v0.16.5
hooks: hooks:
- id: ruff - id: ruff
args: ["--fix"] args: ["--fix"]

View file

@ -1,7 +1,7 @@
[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/hacs/integration) [![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) ![Version](https://img.shields.io/github/v/release/basnijholt/adaptive-lighting?style=for-the-badge)
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section --> <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-175-orange.svg?style=flat-square)](#contributors-) [![All Contributors](https://img.shields.io/badge/all_contributors-172-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END --> <!-- ALL-CONTRIBUTORS-BADGE:END -->
# 🌞 Adaptive Lighting: Enhance Your Home's Atmosphere with Smart, Sun-Synchronized Lighting 🌙 # 🌞 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) - [Additional Information](#additional-information)
- [:sos: Troubleshooting](#sos-troubleshooting) - [:sos: Troubleshooting](#sos-troubleshooting)
- [:exclamation: Common Problems & Solutions](#exclamation-common-problems--solutions) - [: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) - [: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) - [: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) - [: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.
<!-- SECTION:common-problems:START --> <!-- SECTION:common-problems:START -->
### :exclamation: Common Problems & Solutions ### :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 #### :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: Adaptive Lighting sends more commands to lights than a typical human user would. If your light control network is unhealthy, you may experience:
@ -1118,9 +1109,6 @@ Notice the values of `brightness_mode_time_light` and `brightness_mode_time_dark
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hesseleo"><img src="https://avatars.githubusercontent.com/u/44778508?v=4?s=100" width="100px;" alt="Leonhard Hesse"/><br /><sub><b>Leonhard Hesse</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=hesseleo" title="Code">💻</a></td> <td align="center" valign="top" width="14.28%"><a href="https://github.com/hesseleo"><img src="https://avatars.githubusercontent.com/u/44778508?v=4?s=100" width="100px;" alt="Leonhard Hesse"/><br /><sub><b>Leonhard Hesse</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=hesseleo" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.tim-maps.com"><img src="https://avatars.githubusercontent.com/u/6741938?v=4?s=100" width="100px;" alt="Tim Stallmann"/><br /><sub><b>Tim Stallmann</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=timstallmann" title="Code">💻</a></td> <td align="center" valign="top" width="14.28%"><a href="http://www.tim-maps.com"><img src="https://avatars.githubusercontent.com/u/6741938?v=4?s=100" width="100px;" alt="Tim Stallmann"/><br /><sub><b>Tim Stallmann</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=timstallmann" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lehneres"><img src="https://avatars.githubusercontent.com/u/7437288?v=4?s=100" width="100px;" alt="lehneres"/><br /><sub><b>lehneres</b></sub></a><br /><a href="#ideas-lehneres" title="Ideas, Planning, & Feedback">🤔</a></td> <td align="center" valign="top" width="14.28%"><a href="https://github.com/lehneres"><img src="https://avatars.githubusercontent.com/u/7437288?v=4?s=100" width="100px;" alt="lehneres"/><br /><sub><b>lehneres</b></sub></a><br /><a href="#ideas-lehneres" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ahmadtawakol"><img src="https://avatars.githubusercontent.com/u/2355493?v=4?s=100" width="100px;" alt="Ahmad Tawakol"/><br /><sub><b>Ahmad Tawakol</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=ahmadtawakol" title="Code">💻</a> <a href="https://github.com/basnijholt/adaptive-lighting/issues?q=author%3Aahmadtawakol" title="Bug reports">🐛</a> <a href="#maintenance-ahmadtawakol" title="Maintenance">🚧</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/SGXander"><img src="https://avatars.githubusercontent.com/u/8619536?v=4?s=100" width="100px;" alt="SGXander"/><br /><sub><b>SGXander</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=SGXander" title="Code">💻</a> <a href="https://github.com/basnijholt/adaptive-lighting/issues?q=author%3ASGXander" title="Bug reports">🐛</a> <a href="https://github.com/basnijholt/adaptive-lighting/commits?author=SGXander" title="Tests">⚠️</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://fezvrasta.github.io"><img src="https://avatars.githubusercontent.com/u/5382443?v=4?s=100" width="100px;" alt="Federico Zivolo"/><br /><sub><b>Federico Zivolo</b></sub></a><br /><a href="#translation-FezVrasta" title="Translation">🌍</a></td>
</tr> </tr>
</tbody> </tbody>
<tfoot> <tfoot>

View file

@ -193,12 +193,10 @@ class SunEvents:
middle = abs(sunset - sunrise) / 2 middle = abs(sunset - sunrise) / 2
if sunset > sunrise: if sunset > sunrise:
noon = sunrise + middle noon = sunrise + middle
next_sunrise = self.sunrise(dt + timedelta(days=1)) midnight = noon + timedelta(hours=12) * (1 if noon.hour < 12 else -1)
midnight = sunset + (next_sunrise - sunset) / 2
else: else:
midnight = sunset + middle midnight = sunset + middle
next_sunset = self.sunset(dt + timedelta(days=1)) noon = midnight + timedelta(hours=12) * (1 if midnight.hour < 12 else -1)
noon = sunrise + (next_sunset - sunrise) / 2
return noon, midnight return noon, midnight
def sun_events(self, dt: datetime.datetime) -> list[tuple[SunEvent, float]]: def sun_events(self, dt: datetime.datetime) -> list[tuple[SunEvent, float]]:

View file

@ -4,30 +4,31 @@ import logging
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from homeassistant.core import HomeAssistant, ServiceCall 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 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 from .adaptation_utils import ServiceData
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def target_entities( def area_entities(hass: HomeAssistant, area_id: str):
hass: HomeAssistant, """Get all entities linked to an area."""
service_data: ServiceData, ent_reg = entity_registry.async_get(hass)
) -> set[str]: entity_ids = [
"""Resolve all directly and indirectly targeted entities without groups.""" entry.entity_id
selected = async_extract_referenced_entity_ids( for entry in entity_registry.async_entries_for_area(ent_reg, area_id)
hass, ]
TargetSelection(service_data), dev_reg = device_registry.async_get(hass)
expand_group=False, 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( def setup_service_call_interceptor(

View file

@ -11,6 +11,7 @@ from copy import deepcopy
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import homeassistant.helpers.config_validation as cv
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
import ulid_transform import ulid_transform
from homeassistant.components.light import ( from homeassistant.components.light import (
@ -20,7 +21,6 @@ from homeassistant.components.light import (
ATTR_SUPPORTED_COLOR_MODES, ATTR_SUPPORTED_COLOR_MODES,
ATTR_TRANSITION, ATTR_TRANSITION,
ATTR_XY_COLOR, ATTR_XY_COLOR,
VALID_TRANSITION,
ColorMode, ColorMode,
LightEntityFeature, LightEntityFeature,
is_on, is_on,
@ -32,11 +32,8 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import ( from homeassistant.const import (
ATTR_AREA_ID, ATTR_AREA_ID,
ATTR_DEVICE_ID,
ATTR_DOMAIN, ATTR_DOMAIN,
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
ATTR_FLOOR_ID,
ATTR_LABEL_ID,
ATTR_SERVICE, ATTR_SERVICE,
ATTR_SERVICE_DATA, ATTR_SERVICE_DATA,
ATTR_SUPPORTED_FEATURES, ATTR_SUPPORTED_FEATURES,
@ -152,7 +149,7 @@ from .const import (
change_switch_settings_schema, change_switch_settings_schema,
replace_none_str, 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 ( from .helpers import (
clamp, clamp,
color_difference_redmean, 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( def _expand_light_groups(
hass: HomeAssistant, hass: HomeAssistant,
lights: list[str], lights: list[str],
@ -899,7 +884,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
assert hass is not None assert hass is not None
self.hass = hass self.hass = hass
self.manager = manager self.manager = manager
self._removed = False
self.sleep_mode_switch = sleep_mode_switch self.sleep_mode_switch = sleep_mode_switch
self.adapt_color_switch = adapt_color_switch self.adapt_color_switch = adapt_color_switch
self.adapt_brightness_switch = adapt_brightness_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: async def async_will_remove_from_hass(self) -> None:
"""Remove the listeners upon removing the component.""" """Remove the listeners upon removing the component."""
self._removed = True
self._remove_listeners() self._remove_listeners()
def _resolve_lights(self, lights: list[str] | None = None) -> list[str]: 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: if not is_first_call or data.initial_sleep:
await asyncio.sleep(data.sleep_time) await asyncio.sleep(data.sleep_time)
if self._removed:
return
# Instead of directly iterating the generator in the while-loop, we get # 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 # the next item here after the sleep to make sure it incorporates state
# changes which happened during the sleep. # 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., 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. to cancel an ongoing adaptation when a light is turned off.
""" """
if self._removed:
return
# Prevent overlap of multiple adaptation sequences # Prevent overlap of multiple adaptation sequences
self.manager.cancel_ongoing_adaptation_calls(data.entity_id) self.manager.cancel_ongoing_adaptation_calls(data.entity_id)
_LOGGER.debug( _LOGGER.debug(
@ -1712,7 +1689,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
await asyncio.sleep(self._adapt_delay) await asyncio.sleep(self._adapt_delay)
# Runtime settings may retire this profile's target while the event waits. # 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 return
await self._update_attrs_and_maybe_adapt_lights( await self._update_attrs_and_maybe_adapt_lights(
@ -1726,8 +1703,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
self, self,
event: Event[EventStateChangedData], event: Event[EventStateChangedData],
) -> None: ) -> None:
new_state = event.data.get("new_state") if not _is_state_event(event, (STATE_ON, STATE_OFF)):
if new_state is None or new_state.state not in (STATE_ON, STATE_OFF):
_LOGGER.debug("%s: Ignoring sleep event %s", self._name, event) _LOGGER.debug("%s: Ignoring sleep event %s", self._name, event)
return return
_LOGGER.debug( _LOGGER.debug(
@ -1994,6 +1970,13 @@ class AdaptiveLightingManager:
self._context_cnt += 1 self._context_cnt += 1
return context 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( def _separate_entity_ids(
self, self,
entity_ids: list[str], entity_ids: list[str],
@ -2177,14 +2160,8 @@ class AdaptiveLightingManager:
entity_ids: list[str], entity_ids: list[str],
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Modify the service data to contain the entity IDs.""" """Modify the service data to contain the entity IDs."""
for target_key in ( service_data.pop(ATTR_ENTITY_ID, None)
ATTR_ENTITY_ID, service_data.pop(ATTR_AREA_ID, None)
ATTR_AREA_ID,
ATTR_DEVICE_ID,
ATTR_FLOOR_ID,
ATTR_LABEL_ID,
):
service_data.pop(target_key, None)
service_data[ATTR_ENTITY_ID] = entity_ids service_data[ATTR_ENTITY_ID] = entity_ids
return service_data return service_data
@ -2656,11 +2633,31 @@ class AdaptiveLightingManager:
records.pop(light, None) records.pop(light, None)
def _get_entity_list(self, service_data: ServiceData) -> list[str]: def _get_entity_list(self, service_data: ServiceData) -> list[str]:
return sorted( 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 entity_id
for entity_id in target_entities(self.hass, service_data) for entity_id in area_entity_ids
if entity_id.startswith(f"{LIGHT_DOMAIN}.") 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: async def turn_on_off_event_listener(self, event: Event) -> None:
"""Track 'light.turn_off' and 'light.turn_on' service calls.""" """Track 'light.turn_off' and 'light.turn_on' service calls."""
@ -2752,7 +2749,7 @@ class AdaptiveLightingManager:
elif state.state == STATE_OFF: # is turning on elif state.state == STATE_OFF: # is turning on
await on(eid, event) await on(eid, event)
async def state_changed_event_listener( # noqa: PLR0912 async def state_changed_event_listener(
self, self,
event: Event[EventStateChangedData], event: Event[EventStateChangedData],
) -> None: ) -> None:
@ -2830,10 +2827,6 @@ class AdaptiveLightingManager:
new_on.context.id, 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: if old_on and new_off:
# Tracks 'on' → 'off' state changes # Tracks 'on' → 'off' state changes
self.on_to_off_event[entity_id] = event self.on_to_off_event[entity_id] = event
@ -3059,7 +3052,7 @@ class AdaptiveLightingManager:
def _member_turn_on_explains_group_turn_on( def _member_turn_on_explains_group_turn_on(
self, self,
entity_id: str, entity_id: str,
off_event: Event, on_to_off_event: Event[EventStateChangedData],
off_to_on_event: Event[EventStateChangedData], off_to_on_event: Event[EventStateChangedData],
) -> bool: ) -> bool:
"""Check if a light group's 'off' → 'on' is caused by a member's 'light.turn_on'. """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) member_turn_on = self.turn_on_event.get(member)
if ( if (
member_turn_on is not None member_turn_on is not None
and off_event.time_fired and on_to_off_event.time_fired
< member_turn_on.time_fired < member_turn_on.time_fired
<= off_to_on_event.time_fired <= off_to_on_event.time_fired
): ):
@ -3094,49 +3087,6 @@ class AdaptiveLightingManager:
return True return True
return False 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 async def just_turned_off( # noqa: PLR0911, PLR0912
self, self,
entity_id: str, entity_id: str,
@ -3155,8 +3105,6 @@ class AdaptiveLightingManager:
""" """
off_to_on_event = self.off_to_on_event[entity_id] off_to_on_event = self.off_to_on_event[entity_id]
on_to_off_event = self.on_to_off_event.get(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: if on_to_off_event is None:
_LOGGER.debug( _LOGGER.debug(
@ -3206,7 +3154,7 @@ class AdaptiveLightingManager:
turn_off_event = self.turn_off_event.get(entity_id) turn_off_event = self.turn_off_event.get(entity_id)
if turn_off_event is not None: 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: else:
transition = None transition = None

View file

@ -18,7 +18,7 @@
"step": { "step": {
"init": { "init": {
"title": "Kohanduva valguse suvandid", "title": "Kohanduva valguse suvandid",
"description": "Kohanduva valguse suvandid. Valikute nimetused ühtuvad YAML kirjes olevatega. Valikuid ei kuvata kui seadistus on tehtud YAML kirjes. Parameetrite mõju näitavate interaktiivsete graafikute vaatamiseks külasta [seda veebirakendust]({webapp_url}). Lisateabe saamiseks vaata [ametlikku dokumentatsiooni]({docs_url}).", "description": "Kohanduva valguse suvandid. Valikute nimetused ühtuvad YAML kirjes olevatega. Valikuid ei kuvata kui seadistus on tehtud YAML kirjes.",
"data": { "data": {
"lights": "valgustid", "lights": "valgustid",
"interval": "Intervall, aeg muutuste vahel sekundites", "interval": "Intervall, aeg muutuste vahel sekundites",

View file

@ -18,7 +18,7 @@
"step": { "step": {
"init": { "init": {
"title": "Opzioni Illuminazione Adattiva", "title": "Opzioni Illuminazione Adattiva",
"description": "Tutte le opzioni per il componente Illuminazione Adattiva. I nomi delle opzioni corrispondono con le impostazioni YAML. Non sono mostrate opzioni se hai la voce adaptive-lighting definita nella tua configurazione YAML. Per grafici interattivi che mostrano l'effetto dei parametri, visita [questa web app]({webapp_url}). Per ulteriori dettagli, consulta la [documentazione ufficiale]({docs_url}).", "description": "Tutte le opzioni per il componente Illuminazione Adattiva. I nomi delle opzioni corrispondono con le impostazioni YAML. Non sono mostrate opzioni se hai la voce adaptive-lighting definita nella tua configurazione YAML.",
"data": { "data": {
"lights": "luci", "lights": "luci",
"interval": "interval: Tempo tra i cambiamenti dello switch. (secondi)", "interval": "interval: Tempo tra i cambiamenti dello switch. (secondi)",

View file

@ -18,7 +18,7 @@
"step": { "step": {
"init": { "init": {
"title": "Adaptiv Belysning Innstillinger", "title": "Adaptiv Belysning Innstillinger",
"description": "Alle innstillinger for en adaptiv belysning konfigurasjon. Innstillingene er identiske med innstillingene for YAML konfigurasjon. Ingen innstillinger vises dersom du har definert adaptive_lighting i din YAML konfigurasjon. For interaktive grafer som viser effekten av parametrene, besøk [denne nettappen]({webapp_url}). For flere detaljer, se [den offisielle dokumentasjonen]({docs_url}).", "description": "Alle innstillinger for en adaptiv belysning konfigurasjon. Innstillingene er identiske med innstillingene for YAML konfigurasjon. Ingen innstillinger vises dersom du har definert adaptive_lighting i din YAML konfigurasjon.",
"data": { "data": {
"lights": "Lys / Lyskilder", "lights": "Lys / Lyskilder",
"interval": "'interval': tiden mellom oppdateringer (i sekunder)", "interval": "'interval': tiden mellom oppdateringer (i sekunder)",

View file

@ -25,7 +25,7 @@
"step": { "step": {
"init": { "init": {
"title": "Opções da iluminação adaptiva", "title": "Opções da iluminação adaptiva",
"description": "Todas as configurações de um componente de iluminação adaptativa. Os nomes das opções correspondem às configurações de YAML. Nenhuma opção será exibida se você tiver a entrada adaptive_lighting definida em sua configuração YAML. Para gráficos interativos que demonstram os efeitos dos parâmetros, acesse [este aplicativo web]({webapp_url}). Para mais detalhes, consulte a [documentação oficial]({docs_url}).", "description": "Todas as configurações de um componente de iluminação adaptativa. Os nomes das opções correspondem às configurações de YAML. Nenhuma opção será exibida se você tiver a entrada adaptive_lighting definida em sua configuração YAML.",
"data": { "data": {
"lights": "luzes", "lights": "luzes",
"interval": "interval: Tempo entre as atualizações do switch. (segundos)", "interval": "interval: Tempo entre as atualizações do switch. (segundos)",

View file

@ -18,7 +18,7 @@
"step": { "step": {
"init": { "init": {
"title": "Настройки Adaptive Lighting", "title": "Настройки Adaptive Lighting",
"description": "Все настройки компонента Adaptive Lighting. Названия опций соответствуют настройкам в YAML. Параметры не отображаются, если в конфигурации YAML определена запись adaptive_lighting. Интерактивные графики, демонстрирующие влияние параметров, доступны в [этом веб-приложении]({webapp_url}). Подробнее см. в [официальной документации]({docs_url}).", "description": "Все настройки компонента Adaptive Lighting. Названия опций соответствуют настройкам в YAML. Параметры не отображаются, если в конфигурации YAML определена запись adaptive_lighting.",
"data": { "data": {
"lights": "Осветительные приборы: список источников света, которыми нужно управлять (может быть пустым). 🌟", "lights": "Осветительные приборы: список источников света, которыми нужно управлять (может быть пустым). 🌟",
"interval": "interval: Интервал между обновлениями переключателя. (секунды)", "interval": "interval: Интервал между обновлениями переключателя. (секунды)",

View file

@ -25,7 +25,7 @@
"step": { "step": {
"init": { "init": {
"title": "Adaptiv Ljussättning Inställningar", "title": "Adaptiv Ljussättning Inställningar",
"description": "Alla inställningar för en Adaptiv Ljussättning komponent. Titeln på inställningarna är desamma som i YAML konfigurationen. Inga inställningar visas om enheten redan är konfigurerad i YAML. För interaktiva grafer som visar parametrarnas effekt, besök [den här webbappen]({webapp_url}). För mer information, se [den officiella dokumentationen]({docs_url}).", "description": "Alla inställningar för en Adaptiv Ljussättning komponent. Titeln på inställningarna är desamma som i YAML konfigurationen. Inga inställningar visas om enheten redan är konfigurerad i YAML.",
"data": { "data": {
"lights": "lights, ljuskällor", "lights": "lights, ljuskällor",
"interval": "interval, Tid mellan uppdateringar i sekunder", "interval": "interval, Tid mellan uppdateringar i sekunder",

View file

@ -25,7 +25,7 @@
"step": { "step": {
"init": { "init": {
"title": "Опції адаптивного освітлення", "title": "Опції адаптивного освітлення",
"description": "Всі налаштування компонента адаптивного освітлення. Назви опцій відповідають налаштуванням у YAML. Опції не відображаються, якщо ви вже визначили їх у компоненті adaptive_lighting вашої YAML-конфігурації. Інтерактивні графіки, що демонструють вплив параметрів, доступні у [цьому вебдодатку]({webapp_url}). Докладніше див. в [офіційній документації]({docs_url}).", "description": "Всі налаштування компонента адаптивного освітлення. Назви опцій відповідають налаштуванням у YAML. Опції не відображаються, якщо ви вже визначили їх у компоненті adaptive_lighting вашої YAML-конфігурації.",
"data": { "data": {
"lights": "прилади", "lights": "прилади",
"interval": "interval: Час між оновленнями перемикача. (секунди)", "interval": "interval: Час між оновленнями перемикача. (секунди)",

View file

@ -22,20 +22,6 @@ target:
entity_id: switch.adaptive_lighting_sleep_mode_living_room 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 ## 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: 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:

View file

@ -45,14 +45,6 @@ represent one sent command or the current desired state.
<!-- CODE:END --> <!-- CODE:END -->
<!-- OUTPUT:START --> <!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. --> <!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
#### :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 #### :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: Adaptive Lighting sends more commands to lights than a typical human user would. If your light control network is unhealthy, you may experience:

View file

@ -2,17 +2,12 @@
set -ex set -ex
cd "$(dirname "$0")/.." 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 # Link custom components
cd core/homeassistant/components/ cd core/homeassistant/components/
ln -fsn ../../../custom_components/adaptive_lighting adaptive_lighting ln -fs ../../../custom_components/adaptive_lighting adaptive_lighting
cd - cd -
# Link tests # Link tests
cd core/tests/components/ cd core/tests/components/
ln -fsn ../../../tests/ adaptive_lighting ln -fs ../../../tests/ adaptive_lighting
cd - cd -

View file

@ -153,41 +153,6 @@ def test_noon_and_midnight(tzinfo_and_location):
assert midnight == location.midnight(date) assert midnight == location.midnight(date)
@pytest.mark.parametrize("day", [dt.date(2026, 4, 17), dt.date(2026, 8, 30)])
def test_midnight_falls_in_the_night_it_belongs_to(day):
"""Derived midnight must lie between sunset and the next sunrise (#1599)."""
location = Location(
LocationInfo(
name="name",
region="region",
timezone="UTC",
latitude=51.5,
longitude=0.0, # on the prime meridian
),
)
sun_events = SunEvents(
name="test",
astral_observer=location.observer,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=dt.time(8, 0),
sunset_time=None,
min_sunset_time=dt.time(17, 0),
max_sunset_time=dt.time(20, 0),
timezone=zoneinfo.ZoneInfo("UTC"),
)
date = dt.datetime.combine(day, dt.time(12), tzinfo=dt.UTC)
sunset = sun_events.sunset(date)
next_sunrise = sun_events.sunrise(date + dt.timedelta(days=1))
_, midnight = sun_events.noon_and_midnight(date)
assert sunset < midnight < next_sunrise
just_after_sunset = sunset + dt.timedelta(minutes=40)
events = sun_events.prev_and_next_events(just_after_sunset)
assert [event for event, _ in events] == [SunEvent.SUNSET, SunEvent.MIDNIGHT]
assert sun_events.sun_position(just_after_sunset) > -0.5
def test_sun_events(tzinfo_and_location): def test_sun_events(tzinfo_and_location):
tzinfo, location = tzinfo_and_location tzinfo, location = tzinfo_and_location
sun_events = SunEvents( sun_events = SunEvents(

View file

@ -30,7 +30,6 @@ from homeassistant.components.adaptive_lighting.const import (
ATTR_ADAPT_BRIGHTNESS, ATTR_ADAPT_BRIGHTNESS,
ATTR_ADAPT_COLOR, ATTR_ADAPT_COLOR,
ATTR_ADAPTIVE_LIGHTING_MANAGER, ATTR_ADAPTIVE_LIGHTING_MANAGER,
CONF_ADAPT_DELAY,
CONF_ADAPT_ONLY_ON_BARE_TURN_ON, CONF_ADAPT_ONLY_ON_BARE_TURN_ON,
CONF_ADAPT_UNTIL_SLEEP, CONF_ADAPT_UNTIL_SLEEP,
CONF_AUTORESET_CONTROL, CONF_AUTORESET_CONTROL,
@ -47,7 +46,6 @@ from homeassistant.components.adaptive_lighting.const import (
CONF_MIN_BRIGHTNESS, CONF_MIN_BRIGHTNESS,
CONF_MIN_COLOR_TEMP, CONF_MIN_COLOR_TEMP,
CONF_MULTI_LIGHT_INTERCEPT, CONF_MULTI_LIGHT_INTERCEPT,
CONF_ONLY_ONCE,
CONF_PREFER_RGB_COLOR, CONF_PREFER_RGB_COLOR,
CONF_RESET_MANUAL_CONTROL_ON_SLEEP_MODE_CHANGE, CONF_RESET_MANUAL_CONTROL_ON_SLEEP_MODE_CHANGE,
CONF_SEND_SPLIT_DELAY, CONF_SEND_SPLIT_DELAY,
@ -83,7 +81,6 @@ from homeassistant.components.adaptive_lighting.switch import (
SimpleSwitch, SimpleSwitch,
_attributes_have_changed, _attributes_have_changed,
_expand_light_groups, _expand_light_groups,
_turn_off_transition,
color_difference_redmean, color_difference_redmean,
create_context, create_context,
is_our_context, is_our_context,
@ -111,9 +108,6 @@ from homeassistant.const import (
ATTR_AREA_ID, ATTR_AREA_ID,
ATTR_DEVICE_ID, ATTR_DEVICE_ID,
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
ATTR_FLOOR_ID,
ATTR_LABEL_ID,
ATTR_SERVICE_DATA,
ATTR_SUPPORTED_FEATURES, ATTR_SUPPORTED_FEATURES,
CONF_LIGHTS, CONF_LIGHTS,
CONF_NAME, CONF_NAME,
@ -123,7 +117,6 @@ from homeassistant.const import (
SERVICE_TURN_ON, SERVICE_TURN_ON,
STATE_OFF, STATE_OFF,
STATE_ON, STATE_ON,
STATE_UNAVAILABLE,
EntityCategory, EntityCategory,
) )
from homeassistant.core import Context, CoreState, Event, HomeAssistant, State 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 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 = ( small_change = (
15 if manual_attribute == LightControlAttributes.BRIGHTNESS else 70 15 if manual_attribute == LightControlAttributes.BRIGHTNESS else 60
) )
freezer.tick(90) freezer.tick(90)
set_physical_state(manual_value + direction * small_change) 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): async def test_just_turned_off_group_context_reuse(hass, cleanup):
"""Group 'off' → 'on' with a reused 'turn_off' context must still adapt. """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) 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): async def test_just_turned_off_same_automation_context(hass, cleanup):
"""'turn_off' and 'turn_on' from one automation share a context. """'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() now = dt_util.utcnow().timestamp()
automation_context = Context() 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( manager.on_to_off_event[ENTITY_LIGHT_1] = _state_changed_event(
ENTITY_LIGHT_1, ENTITY_LIGHT_1,
now - 2, 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) 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): 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).""" """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( async def test_detect_non_ha_changes_with_separate_turn_on_commands(hass):
("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,
):
"""Regression test for detect_non_ha_changes with separate_turn_on_commands. """Regression test for detect_non_ha_changes with separate_turn_on_commands.
With separate_turn_on_commands=True, each adaptation cycle makes two sequential 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 last_service_data instead of merging, brightness is dropped — and
_attributes_have_changed silently skips the brightness comparison, so a direct _attributes_have_changed silently skips the brightness comparison, so a direct
Zigbee brightness change is never detected as manual control. 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( switch, (light, *_) = await setup_lights_and_switch(
hass, hass,
@ -5448,21 +5114,10 @@ async def test_detect_non_ha_changes_with_separate_turn_on_commands(
CONF_SEPARATE_TURN_ON_COMMANDS: True, CONF_SEPARATE_TURN_ON_COMMANDS: True,
CONF_DETECT_NON_HA_CHANGES: True, CONF_DETECT_NON_HA_CHANGES: True,
CONF_TAKE_OVER_CONTROL: True, CONF_TAKE_OVER_CONTROL: True,
CONF_TAKE_OVER_CONTROL_MODE: mode,
CONF_INTERCEPT: intercept,
}, },
) )
_mock_sun_light_settings( context = switch.create_context("test")
switch,
{
ATTR_BRIGHTNESS_PCT: 50,
ATTR_COLOR_TEMP_KELVIN: 3000,
"force_rgb_color": False,
},
)
context = switch.create_context("interval")
async def update(force: bool = False): async def update(force: bool = False):
await switch._update_attrs_and_maybe_adapt_lights( 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) 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 al_brightness = light.brightness
assert al_brightness is not None 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 switch.manager.manual_control[ENTITY_LIGHT_1] = LightControlAttributes.NONE
manual_brightness = ( manual_brightness = (
@ -5485,24 +5147,6 @@ async def test_detect_non_ha_changes_with_separate_turn_on_commands(
) )
set_light_brightness(light, manual_brightness) 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): async def _flush_attr_state(hass, entity_id):
"""Mimic a ZHA attribute report: write current hardware state to HA.""" """Mimic a ZHA attribute report: write current hardware state to HA."""
light.async_write_ha_state() 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), new=AsyncMock(side_effect=_flush_attr_state),
): ):
await update(force=False) 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) await update(force=False)
assert ( assert (
light.brightness == manual_brightness light.brightness == manual_brightness
), f"AL overrode manual brightness {manual_brightness} with {light.brightness}" ), f"AL overrode manual brightness {manual_brightness} with {al_brightness}"
expected_color_temp = (
4000 if mode == TakeOverControlMode.PAUSE_CHANGED else al_color_temp
)
assert light.color_temp_kelvin == expected_color_temp
async def test_fresh_install_entity_ids(hass): 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) noon = hass.states.get(ENTITY_LIGHT_1)
assert noon.attributes[ATTR_BRIGHTNESS] == 77 assert noon.attributes[ATTR_BRIGHTNESS] == 77
assert noon.attributes[ATTR_COLOR_TEMP_KELVIN] > 2000 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]