mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-11 14:24:03 +02:00
Compare commits
11 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d0f4b610a |
||
|
|
da749bcf61 |
||
|
|
3e78ac7e21 |
||
|
|
51ea83dba3 |
||
|
|
7c445af63b |
||
|
|
2f37b6ea40 |
||
|
|
34356a6b56 |
||
|
|
a936519866 |
||
|
|
3a27c346b9 |
||
|
|
2299161690 |
||
|
|
77183ee3eb |
11 changed files with 810 additions and 96 deletions
|
|
@ -1568,6 +1568,17 @@
|
|||
"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,
|
||||
|
|
|
|||
28
.dockerignore
Normal file
28
.dockerignore
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# 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
|
||||
12
.github/workflows/toc.yaml
vendored
12
.github/workflows/toc.yaml
vendored
|
|
@ -1,12 +0,0 @@
|
|||
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: ""
|
||||
|
|
@ -7,8 +7,14 @@ 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.5
|
||||
rev: v0.16.6
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: ["--fix"]
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -1,7 +1,7 @@
|
|||
[](https://github.com/hacs/integration)
|
||||

|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
[](#contributors-)
|
||||
[](#contributors-)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
# 🌞 Adaptive Lighting: Enhance Your Home's Atmosphere with Smart, Sun-Synchronized Lighting 🌙
|
||||
|
|
@ -84,6 +84,7 @@ 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)
|
||||
|
|
@ -762,6 +763,14 @@ represent one sent command or the current desired state.
|
|||
<!-- SECTION:common-problems:START -->
|
||||
### :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:
|
||||
|
|
@ -1109,6 +1118,7 @@ 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="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/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>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
|
|
|
|||
|
|
@ -4,31 +4,30 @@ import logging
|
|||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import device_registry, entity_registry
|
||||
from homeassistant.helpers.target import async_extract_referenced_entity_ids
|
||||
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 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
|
||||
],
|
||||
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,
|
||||
)
|
||||
return entity_ids
|
||||
return selected.referenced | selected.indirectly_referenced
|
||||
|
||||
|
||||
def setup_service_call_interceptor(
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ 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 (
|
||||
|
|
@ -21,6 +20,7 @@ from homeassistant.components.light import (
|
|||
ATTR_SUPPORTED_COLOR_MODES,
|
||||
ATTR_TRANSITION,
|
||||
ATTR_XY_COLOR,
|
||||
VALID_TRANSITION,
|
||||
ColorMode,
|
||||
LightEntityFeature,
|
||||
is_on,
|
||||
|
|
@ -32,8 +32,11 @@ 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,
|
||||
|
|
@ -149,7 +152,7 @@ from .const import (
|
|||
change_switch_settings_schema,
|
||||
replace_none_str,
|
||||
)
|
||||
from .hass_utils import area_entities, setup_service_call_interceptor
|
||||
from .hass_utils import setup_service_call_interceptor, target_entities
|
||||
from .helpers import (
|
||||
clamp,
|
||||
color_difference_redmean,
|
||||
|
|
@ -620,6 +623,18 @@ 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],
|
||||
|
|
@ -884,6 +899,7 @@ 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
|
||||
|
|
@ -1076,6 +1092,7 @@ 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]:
|
||||
|
|
@ -1431,6 +1448,9 @@ 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.
|
||||
|
|
@ -1486,6 +1506,9 @@ 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(
|
||||
|
|
@ -1689,7 +1712,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
await asyncio.sleep(self._adapt_delay)
|
||||
|
||||
# Runtime settings may retire this profile's target while the event waits.
|
||||
if entity_id not in self.lights:
|
||||
if self._removed or entity_id not in self.lights:
|
||||
return
|
||||
|
||||
await self._update_attrs_and_maybe_adapt_lights(
|
||||
|
|
@ -1703,7 +1726,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
self,
|
||||
event: Event[EventStateChangedData],
|
||||
) -> None:
|
||||
if not _is_state_event(event, (STATE_ON, STATE_OFF)):
|
||||
new_state = event.data.get("new_state")
|
||||
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)
|
||||
return
|
||||
_LOGGER.debug(
|
||||
|
|
@ -1970,13 +1994,6 @@ 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],
|
||||
|
|
@ -2160,8 +2177,14 @@ class AdaptiveLightingManager:
|
|||
entity_ids: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Modify the service data to contain the entity IDs."""
|
||||
service_data.pop(ATTR_ENTITY_ID, None)
|
||||
service_data.pop(ATTR_AREA_ID, None)
|
||||
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[ATTR_ENTITY_ID] = entity_ids
|
||||
return service_data
|
||||
|
||||
|
|
@ -2633,31 +2656,11 @@ class AdaptiveLightingManager:
|
|||
records.pop(light, None)
|
||||
|
||||
def _get_entity_list(self, service_data: ServiceData) -> list[str]:
|
||||
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 sorted(
|
||||
entity_id
|
||||
for entity_id in target_entities(self.hass, service_data)
|
||||
if entity_id.startswith(f"{LIGHT_DOMAIN}.")
|
||||
)
|
||||
return []
|
||||
|
||||
async def turn_on_off_event_listener(self, event: Event) -> None:
|
||||
"""Track 'light.turn_off' and 'light.turn_on' service calls."""
|
||||
|
|
@ -2749,7 +2752,7 @@ class AdaptiveLightingManager:
|
|||
elif state.state == STATE_OFF: # is turning on
|
||||
await on(eid, event)
|
||||
|
||||
async def state_changed_event_listener(
|
||||
async def state_changed_event_listener( # noqa: PLR0912
|
||||
self,
|
||||
event: Event[EventStateChangedData],
|
||||
) -> None:
|
||||
|
|
@ -2827,6 +2830,10 @@ 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
|
||||
|
|
@ -3052,7 +3059,7 @@ class AdaptiveLightingManager:
|
|||
def _member_turn_on_explains_group_turn_on(
|
||||
self,
|
||||
entity_id: str,
|
||||
on_to_off_event: Event[EventStateChangedData],
|
||||
off_event: Event,
|
||||
off_to_on_event: Event[EventStateChangedData],
|
||||
) -> bool:
|
||||
"""Check if a light group's 'off' → 'on' is caused by a member's 'light.turn_on'.
|
||||
|
|
@ -3072,7 +3079,7 @@ class AdaptiveLightingManager:
|
|||
member_turn_on = self.turn_on_event.get(member)
|
||||
if (
|
||||
member_turn_on is not None
|
||||
and on_to_off_event.time_fired
|
||||
and off_event.time_fired
|
||||
< member_turn_on.time_fired
|
||||
<= off_to_on_event.time_fired
|
||||
):
|
||||
|
|
@ -3087,6 +3094,49 @@ 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,
|
||||
|
|
@ -3105,6 +3155,8 @@ 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(
|
||||
|
|
@ -3154,7 +3206,7 @@ class AdaptiveLightingManager:
|
|||
|
||||
turn_off_event = self.turn_off_event.get(entity_id)
|
||||
if turn_off_event is not None:
|
||||
transition = turn_off_event.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION)
|
||||
transition = _turn_off_transition(turn_off_event)
|
||||
else:
|
||||
transition = None
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,20 @@ 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:
|
||||
|
|
|
|||
|
|
@ -45,6 +45,14 @@ represent one sent command or the current desired state.
|
|||
<!-- CODE:END -->
|
||||
<!-- OUTPUT:START -->
|
||||
<!-- ⚠️ 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
|
||||
|
||||
Adaptive Lighting sends more commands to lights than a typical human user would. If your light control network is unhealthy, you may experience:
|
||||
|
|
|
|||
|
|
@ -2,12 +2,17 @@
|
|||
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 -fs ../../../custom_components/adaptive_lighting adaptive_lighting
|
||||
ln -fsn ../../../custom_components/adaptive_lighting adaptive_lighting
|
||||
cd -
|
||||
|
||||
# Link tests
|
||||
cd core/tests/components/
|
||||
ln -fs ../../../tests/ adaptive_lighting
|
||||
ln -fsn ../../../tests/ adaptive_lighting
|
||||
cd -
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ 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,
|
||||
|
|
@ -46,6 +47,7 @@ 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,
|
||||
|
|
@ -81,6 +83,7 @@ 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,
|
||||
|
|
@ -108,6 +111,9 @@ 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,
|
||||
|
|
@ -117,6 +123,7 @@ from homeassistant.const import (
|
|||
SERVICE_TURN_ON,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import Context, CoreState, Event, HomeAssistant, State
|
||||
|
|
@ -1532,8 +1539,10 @@ 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 60
|
||||
15 if manual_attribute == LightControlAttributes.BRIGHTNESS else 70
|
||||
)
|
||||
freezer.tick(90)
|
||||
set_physical_state(manual_value + direction * small_change)
|
||||
|
|
@ -4251,6 +4260,27 @@ 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.
|
||||
|
||||
|
|
@ -4309,6 +4339,154 @@ 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.
|
||||
|
||||
|
|
@ -4325,6 +4503,12 @@ 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,
|
||||
|
|
@ -4363,6 +4547,133 @@ 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)."""
|
||||
|
|
@ -5099,7 +5410,27 @@ async def test_adapt_only_on_bare_turn_on_respects_pause_changed_mode(hass, inte
|
|||
)
|
||||
|
||||
|
||||
async def test_detect_non_ha_changes_with_separate_turn_on_commands(hass):
|
||||
@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,
|
||||
):
|
||||
"""Regression test for detect_non_ha_changes with separate_turn_on_commands.
|
||||
|
||||
With separate_turn_on_commands=True, each adaptation cycle makes two sequential
|
||||
|
|
@ -5107,6 +5438,9 @@ async def test_detect_non_ha_changes_with_separate_turn_on_commands(hass):
|
|||
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,
|
||||
|
|
@ -5114,10 +5448,21 @@ async def test_detect_non_ha_changes_with_separate_turn_on_commands(hass):
|
|||
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,
|
||||
},
|
||||
)
|
||||
|
||||
context = switch.create_context("test")
|
||||
_mock_sun_light_settings(
|
||||
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):
|
||||
await switch._update_attrs_and_maybe_adapt_lights(
|
||||
|
|
@ -5129,17 +5474,10 @@ async def test_detect_non_ha_changes_with_separate_turn_on_commands(hass):
|
|||
|
||||
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 = (
|
||||
|
|
@ -5147,6 +5485,24 @@ async def test_detect_non_ha_changes_with_separate_turn_on_commands(hass):
|
|||
)
|
||||
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()
|
||||
|
|
@ -5156,20 +5512,15 @@ async def test_detect_non_ha_changes_with_separate_turn_on_commands(hass):
|
|||
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 {al_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
|
||||
|
||||
|
||||
async def test_fresh_install_entity_ids(hass):
|
||||
|
|
@ -5751,3 +6102,245 @@ 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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue