mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-11 22:34:04 +02:00
Compare commits
24 commits
all-contri
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d0f4b610a |
||
|
|
da749bcf61 |
||
|
|
3e78ac7e21 |
||
|
|
51ea83dba3 |
||
|
|
7c445af63b |
||
|
|
2f37b6ea40 |
||
|
|
34356a6b56 |
||
|
|
a936519866 |
||
|
|
3a27c346b9 |
||
|
|
2299161690 |
||
|
|
77183ee3eb |
||
|
|
7f7fec4e34 |
||
|
|
c5d216ea6f |
||
|
|
aa84eda871 |
||
|
|
9e29a21197 |
||
|
|
11bd5cf2aa |
||
|
|
f7b50b12d9 |
||
|
|
e2a3aae416 |
||
|
|
51f2878b16 |
||
|
|
d09b15a138 |
||
|
|
46d07388ba |
||
|
|
3231a1ac27 |
||
|
|
ba2b7a3e44 |
||
|
|
cda1c2db33 |
31 changed files with 4028 additions and 369 deletions
|
|
@ -1525,12 +1525,59 @@
|
|||
]
|
||||
},
|
||||
{
|
||||
"login": "tests",
|
||||
"name": "tests",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/37722?v=4",
|
||||
"profile": "https://github.com/tests",
|
||||
"login": "jaynis",
|
||||
"name": "jaynis",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1553675?v=4",
|
||||
"profile": "https://github.com/jaynis",
|
||||
"contributions": [
|
||||
"test"
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "alistairg",
|
||||
"name": "Alistair Galbraith",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/272786?v=4",
|
||||
"profile": "https://github.com/alistairg",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "hesseleo",
|
||||
"name": "Leonhard Hesse",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/44778508?v=4",
|
||||
"profile": "https://github.com/hesseleo",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "timstallmann",
|
||||
"name": "Tim Stallmann",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/6741938?v=4",
|
||||
"profile": "http://www.tim-maps.com",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "lehneres",
|
||||
"name": "lehneres",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/7437288?v=4",
|
||||
"profile": "https://github.com/lehneres",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
|
|
|||
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"]
|
||||
|
|
|
|||
196
README.md
196
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 🌙
|
||||
|
|
@ -48,6 +48,10 @@ This feature is available when `take_over_control` is enabled.
|
|||
Additionally, enabling `detect_non_ha_changes` allows Adaptive Lighting to detect all state changes, including those made outside of Home Assistant, by comparing the light's state to its previously used settings.
|
||||
The `adaptive_lighting.manual_control` event is fired when a light is marked as "manually controlled," allowing for integration with automations 🤖.
|
||||
|
||||
With `expand_light_groups: false`, manual control belongs to the group. A direct member change cannot pause adaptation for only that member; use group-level manual control or enable expansion for individual tracking.
|
||||
Explicit member targets in Adaptive Lighting services stay individual targets and do not mark or command the whole group.
|
||||
Changing expansion at runtime discards tracking and pending adaptation for targets no longer used by any profile.
|
||||
|
||||
The Adaptive Lighting switch exposes these read-only attributes for its lights:
|
||||
|
||||
- `manual_control`: lights with any attribute marked as manually controlled.
|
||||
|
|
@ -80,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)
|
||||
|
|
@ -157,6 +162,7 @@ The YAML and frontend configuration methods support all of the options listed be
|
|||
| `autoreset_control_seconds` | Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️ | `0` | `int` 0-31536000 |
|
||||
| `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄 | `False` | `bool` |
|
||||
| `adapt_only_on_bare_turn_on` | When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene and marks the light as manually controlled. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. 🕵️ | `False` | `bool` |
|
||||
| `manual_control_on_external_turn_on` | Treat turn-ons without a matching Home Assistant `light.turn_on` context as manual control. Normal manual-control resets apply. Still allows `detect_non_ha_changes` for already-on lights. Needs `take_over_control` enabled. 🕵️ | `False` | `bool` |
|
||||
| `reset_manual_control_on_sleep_mode_change` | Reset manual control when the sleep mode switch is toggled. Set to `false` to preserve manual control across sleep mode changes. 😴 | `True` | `bool` |
|
||||
| `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀 | `False` | `bool` |
|
||||
| `send_split_delay` | Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️ | `0` | `int` 0-10000 |
|
||||
|
|
@ -165,6 +171,7 @@ The YAML and frontend configuration methods support all of the options listed be
|
|||
| `intercept` | Intercept and adapt `light.turn_on` calls to enabling instantaneous color and brightness adaptation. 🏎️ Disable for lights that do not support `light.turn_on` with color and brightness. | `True` | `bool` |
|
||||
| `multi_light_intercept` | Intercept and adapt `light.turn_on` calls that target multiple lights. ➗⚠️ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches. Requires `intercept` to be enabled. | `True` | `bool` |
|
||||
| `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 | `False` | `bool` |
|
||||
| `expand_light_groups` | Expand light groups to their members (`true`, default). Set `false` to send commands to the group and track manual control for the group. Explicit member targets in services stay individual targets. | `True` | `bool` |
|
||||
|
||||
<!-- OUTPUT:END -->
|
||||
|
||||
|
|
@ -272,6 +279,18 @@ Replace every entity ID below with the IDs from your Home Assistant instance. Fr
|
|||
|
||||
Blocks that begin with `- alias` are entries for `automations.yaml`. Blocks with a top-level `script:` or `adaptive_lighting:` key are complete `configuration.yaml` examples. If your configuration uses `script: !include scripts.yaml`, omit that outer key and place its contents in `scripts.yaml`.
|
||||
|
||||
Five examples also have blueprints with selectors, so you can configure them without editing YAML:
|
||||
|
||||
| Blueprint | Purpose | Import |
|
||||
| --- | --- | --- |
|
||||
| [Sleep mode](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/sleep_mode.yaml) | Synchronize several profiles with one sleep-mode helper. | [](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fbasnijholt%2Fadaptive-lighting%2Fblob%2Fmain%2Fblueprints%2Fautomation%2Fsleep_mode.yaml) |
|
||||
| [Minimum brightness](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/turn_off_at_minimum.yaml) | Turn one light off when its target crosses down to the minimum. | [](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fbasnijholt%2Fadaptive-lighting%2Fblob%2Fmain%2Fblueprints%2Fautomation%2Fturn_off_at_minimum.yaml) |
|
||||
| [Pause at minimum](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/manual_control_at_minimum.yaml) | Pause brightness through manual control, using its existing reset behavior. | [](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fbasnijholt%2Fadaptive-lighting%2Fblob%2Fmain%2Fblueprints%2Fautomation%2Fmanual_control_at_minimum.yaml) |
|
||||
| [Schedule profile](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/schedule_profile.yaml) | Apply brightness and color temperature from Schedule helper blocks. | [](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fbasnijholt%2Fadaptive-lighting%2Fblob%2Fmain%2Fblueprints%2Fautomation%2Fschedule_profile.yaml) |
|
||||
| [Daylight limit](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/daylight_limit.yaml) | Lower maximum brightness in strong daylight. | [](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fbasnijholt%2Fadaptive-lighting%2Fblob%2Fmain%2Fblueprints%2Fautomation%2Fdaylight_limit.yaml) |
|
||||
|
||||
Click a blueprint's import badge, confirm the import in Home Assistant, then create an automation and select your entities. You can also copy its source link into **Settings → Automations & scenes → Blueprints → Import Blueprint**. Read the matching example below for setup and behavior. Each blueprint is tested through Home Assistant alongside its YAML example. The built-in manual-control timeout needs no automation; the scripts below remain useful as actions in your own automations.
|
||||
|
||||
`change_switch_settings` updates a profile while its main switch is off, but lights are adapted only while that switch is on. It preserves manual-control flags, so manually controlled lights remain paused.
|
||||
|
||||
<details markdown="1">
|
||||
|
|
@ -294,8 +313,11 @@ This is a top-level `configuration.yaml` example. The timer clears manual contro
|
|||
<details markdown="1">
|
||||
<summary>Toggle multiple Adaptive Lighting switches to "sleep mode" using an <code>input_boolean.sleep_mode</code>.</summary>
|
||||
|
||||
Also available as a [blueprint](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/sleep_mode.yaml). Select an input boolean and the sleep-mode switches it should control.
|
||||
|
||||
```yaml
|
||||
- alias: "Adaptive lighting: toggle 'sleep mode'"
|
||||
mode: restart
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: input_boolean.sleep_mode
|
||||
|
|
@ -316,6 +338,145 @@ This is a top-level `configuration.yaml` example. The timer clears manual contro
|
|||
|
||||
</details>
|
||||
|
||||
<details markdown="1">
|
||||
<summary>Turn a light off when its adaptive brightness target reaches the minimum.</summary>
|
||||
|
||||
Prefer a form over editing YAML? Import the [blueprint](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/turn_off_at_minimum.yaml) in Home Assistant under **Settings → Automations & scenes → Blueprints → Import Blueprint**. Select your profile, its matching adapt brightness switch, one light managed by that profile, and its minimum brightness percentage. Create one automation per light. If you change the profile's minimum later, update the automation too. The blueprint and YAML example below have the same behavior.
|
||||
|
||||
The Adaptive Lighting switch already exposes its calculated `brightness_pct` target. Use its state changes to choose a power policy in an automation; no custom event is needed. This example assumes `min_brightness: 1`. Change `minimum_pct` to match your profile, and replace the switch and light entity IDs with your own.
|
||||
|
||||
The comparison uses the same rounded 0–255 brightness as an adaptation command. Comparing floating-point percentages for exact equality can miss the minimum between updates. This detects the calculated target reaching its minimum command, not the bulb finishing a transition or reaching its physical dimming limit.
|
||||
|
||||
```yaml
|
||||
- alias: "Adaptive lighting: turn off at minimum brightness"
|
||||
mode: single
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: switch.adaptive_lighting_living_room
|
||||
attribute: brightness_pct
|
||||
conditions:
|
||||
- condition: state
|
||||
entity_id:
|
||||
- switch.adaptive_lighting_living_room
|
||||
- switch.adaptive_lighting_living_room_adapt_brightness
|
||||
state: "on"
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{% set minimum_pct = 1 %}
|
||||
{% set minimum = (minimum_pct * 255 / 100) | round(0) %}
|
||||
{% set before = trigger.from_state.attributes.get('brightness_pct')
|
||||
if trigger.from_state else none %}
|
||||
{% set after = trigger.to_state.attributes.get('brightness_pct')
|
||||
if trigger.to_state else none %}
|
||||
{{ is_number(before) and is_number(after)
|
||||
and (before | float * 255 / 100) | round(0) > minimum
|
||||
and (after | float * 255 / 100) | round(0) <= minimum }}
|
||||
- condition: state
|
||||
entity_id: light.living_room
|
||||
state: "on"
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ 'light.living_room' not in
|
||||
(state_attr('switch.adaptive_lighting_living_room', 'manual_control') or []) }}
|
||||
actions:
|
||||
- action: light.turn_off
|
||||
target:
|
||||
entity_id: light.living_room
|
||||
```
|
||||
|
||||
This runs once when a valid target crosses down into the minimum range. It skips lights currently marked as manually controlled, does not repeatedly turn them off while the target remains low, and does not turn them back on later. Startup or re-enabling the profile while already at the minimum is not a new crossing. Sleep mode can also cause a crossing if its brightness is at or below the chosen minimum. Changing sleep mode clears manual control by default; set `reset_manual_control_on_sleep_mode_change: false` if you want to preserve it. For a bedtime-only policy, trigger directly on the sleep-mode switch changing to `on` instead.
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown="1">
|
||||
<summary>Pause brightness at the minimum using manual control.</summary>
|
||||
|
||||
Use the [blueprint](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/manual_control_at_minimum.yaml) to mark an individual light's brightness as manually controlled when the calculated target reaches its minimum. The light stays on and the adaptation switches stay enabled. Set `take_over_control_mode: pause_changed` on the profile to keep adapting color; the default `pause_all` pauses both attributes.
|
||||
|
||||
Select the profile, its adapt-brightness switch, and a light managed by it. Match the minimum percentage to the profile's `min_brightness`. This YAML example assumes `min_brightness: 1`; change `minimum_pct` and the entity IDs to match your setup.
|
||||
|
||||
```yaml
|
||||
- alias: "Adaptive lighting: pause brightness at minimum"
|
||||
mode: single
|
||||
variables:
|
||||
minimum_pct: 1
|
||||
minimum: "{{ (minimum_pct * 255 / 100) | round(0) }}"
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: switch.adaptive_lighting_living_room
|
||||
attribute: brightness_pct
|
||||
conditions:
|
||||
- condition: state
|
||||
entity_id:
|
||||
- switch.adaptive_lighting_living_room
|
||||
- switch.adaptive_lighting_living_room_adapt_brightness
|
||||
state: "on"
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{% set before = trigger.from_state.attributes.get('brightness_pct')
|
||||
if trigger.from_state else none %}
|
||||
{% set after = trigger.to_state.attributes.get('brightness_pct')
|
||||
if trigger.to_state else none %}
|
||||
{{ is_number(before) and is_number(after)
|
||||
and (before | float * 255 / 100) | round(0) > minimum
|
||||
and (after | float * 255 / 100) | round(0) <= minimum }}
|
||||
- condition: state
|
||||
entity_id: light.living_room
|
||||
state: "on"
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ 'light.living_room' not in
|
||||
(state_attr('switch.adaptive_lighting_living_room', 'manual_control_brightness') or []) }}
|
||||
actions:
|
||||
- variables:
|
||||
light_session: "{{ states.light.living_room.last_changed.isoformat() }}"
|
||||
profile_session: "{{ states.switch.adaptive_lighting_living_room.last_changed.isoformat() }}"
|
||||
brightness_session: "{{ states.switch.adaptive_lighting_living_room_adapt_brightness.last_changed.isoformat() }}"
|
||||
- wait_template: >-
|
||||
{% set target = state_attr('switch.adaptive_lighting_living_room', 'brightness_pct') %}
|
||||
{{ not is_state('light.living_room', 'on')
|
||||
or not is_state('switch.adaptive_lighting_living_room', 'on')
|
||||
or not is_state('switch.adaptive_lighting_living_room_adapt_brightness', 'on')
|
||||
or states.light.living_room.last_changed.isoformat() != light_session
|
||||
or states.switch.adaptive_lighting_living_room.last_changed.isoformat() != profile_session
|
||||
or states.switch.adaptive_lighting_living_room_adapt_brightness.last_changed.isoformat() != brightness_session
|
||||
or not is_number(target) or (target | float * 255 / 100) | round(0) > minimum
|
||||
or 'light.living_room' in
|
||||
(state_attr('switch.adaptive_lighting_living_room', 'manual_control_brightness') or [])
|
||||
or (state_attr('light.living_room', 'brightness') | float(256)) <= minimum }}
|
||||
timeout: "00:05:00"
|
||||
continue_on_timeout: false
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{% set target = state_attr('switch.adaptive_lighting_living_room', 'brightness_pct') %}
|
||||
{{ is_state('light.living_room', 'on')
|
||||
and is_state('switch.adaptive_lighting_living_room', 'on')
|
||||
and is_state('switch.adaptive_lighting_living_room_adapt_brightness', 'on')
|
||||
and states.light.living_room.last_changed.isoformat() == light_session
|
||||
and states.switch.adaptive_lighting_living_room.last_changed.isoformat() == profile_session
|
||||
and states.switch.adaptive_lighting_living_room_adapt_brightness.last_changed.isoformat() == brightness_session
|
||||
and is_number(target) and (target | float * 255 / 100) | round(0) <= minimum
|
||||
and (state_attr('light.living_room', 'brightness') | float(256)) <= minimum
|
||||
and 'light.living_room' not in
|
||||
(state_attr('switch.adaptive_lighting_living_room', 'manual_control_brightness') or []) }}
|
||||
- action: adaptive_lighting.set_manual_control
|
||||
data:
|
||||
entity_id: switch.adaptive_lighting_living_room
|
||||
lights: light.living_room
|
||||
manual_control: >-
|
||||
{{ true if 'light.living_room' in
|
||||
(state_attr('switch.adaptive_lighting_living_room', 'manual_control_color') or [])
|
||||
else 'brightness' }}
|
||||
```
|
||||
|
||||
The comparison uses the rounded 0–255 target, so it does not depend on sampling an exact floating-point minimum. It waits up to five minutes for the light to report that minimum before marking manual control, so the final dimming command can complete. Reported brightness does not prove physical fade completion. If the light, profile, or adapt-brightness switch is toggled, the target rises, brightness is marked manually controlled elsewhere, or brightness never reaches the minimum, the attempt is abandoned. Lights that cannot report the configured minimum will not be paused. Existing manual color flags are preserved, and lights whose brightness is already manually controlled are left alone. Manual-control state is shared for lights managed by multiple profiles, so their existing takeover policies still apply.
|
||||
|
||||
The usual resets apply: turning the light off, the configured `autoreset_control_seconds` timeout, clearing manual control through its service, and existing profile/sleep-switch reset behavior. After a reset, normal adaptation can increase brightness again. This runs once per downward crossing; resetting while the target remains at its minimum does not immediately mark the light again. Startup at the minimum is not a crossing either.
|
||||
|
||||
This pauses further dimming as well as brightening. To pause brightness immediately after a brightness change made through Home Assistant, use `take_over_control_mode: pause_changed` with `take_over_control: true`; that needs no additional automation.
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown="1">
|
||||
<summary>Set sunrise and sunset from an alarm.</summary>
|
||||
|
||||
|
|
@ -342,6 +503,8 @@ script:
|
|||
<details markdown="1">
|
||||
<summary>Use a Schedule helper as a step-based custom lighting profile.</summary>
|
||||
|
||||
Also available as a [blueprint](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/schedule_profile.yaml). Select the main profile switch and your Schedule helper.
|
||||
|
||||
Create a [Schedule helper](https://www.home-assistant.io/integrations/schedule/) named `Adaptive Lighting Profile`. Add time blocks with Additional data like this:
|
||||
|
||||
```yaml
|
||||
|
|
@ -391,6 +554,8 @@ This creates step changes at block boundaries. It does not interpolate between s
|
|||
<details markdown="1">
|
||||
<summary>Reduce daytime brightness when an illuminance sensor detects strong daylight.</summary>
|
||||
|
||||
Also available as a [blueprint](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/daylight_limit.yaml). Select the profile and sensor, then set the lux thresholds and brightness limits. The high lux threshold must exceed the low threshold; the blueprint does nothing if they are reversed or equal.
|
||||
|
||||
Keep a low configured `min_brightness` for late night and let an automation lower `max_brightness` while the room has ample daylight. Use a sensor that is not significantly affected by the controlled lights to avoid a feedback loop.
|
||||
|
||||
```yaml
|
||||
|
|
@ -583,11 +748,29 @@ logger:
|
|||
```
|
||||
|
||||
After the issue occurs, create a new issue report with the log (`/config/home-assistant.log`).
|
||||
|
||||
For support, use Home Assistant's **Download diagnostics** action on the
|
||||
Adaptive Lighting config entry. The download is an on-demand snapshot of the
|
||||
profile's current switches and currently tracked light targets. It does not
|
||||
refresh group membership or predict targets a disabled profile would use after
|
||||
being enabled. It does not create live sensors; existing switch attributes
|
||||
remain the interface for automations.
|
||||
The reported last adaptation values are the shared manager's latest retained
|
||||
value for each attribute. They can come from different commands and do not
|
||||
represent one sent command or the current desired state.
|
||||
<!-- SECTION:troubleshooting-intro:END -->
|
||||
|
||||
<!-- 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:
|
||||
|
|
@ -602,6 +785,8 @@ Addressing these issues will significantly improve your Home Assistant experienc
|
|||
In case lights are suddenly turning on by themselves, this is most likely due to the light incorrectly reporting an "on" state to Home Assistant, leading to an undesired Adaptive Lighting action.
|
||||
To prevent adapting in cases *where the state of the light is suddenly "on" and only adapt if there is an associated `light.turn_on` service call*, set `detect_non_ha_changes: false`.
|
||||
|
||||
To keep detecting manual changes to lights that are already on while leaving unmatched `off` to `on` state events unchanged, enable `manual_control_on_external_turn_on`. Matching uses the exact context of the most recently recorded `light.turn_on` call. Some integrations replace or omit that context, so Adaptive Lighting cannot distinguish every physical versus Home Assistant turn-on source.
|
||||
|
||||
#### :signal_strength: WiFi Networks
|
||||
|
||||
Ensure your light bulbs have a strong WiFi connection. If the signal strength is less than -70dBm, the connection may be weak and prone to dropping messages.
|
||||
|
|
@ -926,7 +1111,14 @@ 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/Mariuss811"><img src="https://avatars.githubusercontent.com/u/54115696?v=4?s=100" width="100px;" alt="Wosten"/><br /><sub><b>Wosten</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/issues?q=author%3AMariuss811" title="Bug reports">🐛</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://zpriddy.com"><img src="https://avatars.githubusercontent.com/u/1858679?v=4?s=100" width="100px;" alt="Zachary Priddy"/><br /><sub><b>Zachary Priddy</b></sub></a><br /><a href="#ideas-zpriddy" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://blakeslee.me"><img src="https://avatars.githubusercontent.com/u/60765958?v=4?s=100" width="100px;" alt="Andrew Blakeslee Moore"/><br /><sub><b>Andrew Blakeslee Moore</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/issues?q=author%3Aabkslm" title="Bug reports">🐛</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tests"><img src="https://avatars.githubusercontent.com/u/37722?v=4?s=100" width="100px;" alt="tests"/><br /><sub><b>tests</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=tests" title="Tests">⚠️</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jaynis"><img src="https://avatars.githubusercontent.com/u/1553675?v=4?s=100" width="100px;" alt="jaynis"/><br /><sub><b>jaynis</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=jaynis" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/alistairg"><img src="https://avatars.githubusercontent.com/u/272786?v=4?s=100" width="100px;" alt="Alistair Galbraith"/><br /><sub><b>Alistair Galbraith</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=alistairg" 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="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>
|
||||
|
|
|
|||
112
blueprints/automation/daylight_limit.yaml
Normal file
112
blueprints/automation/daylight_limit.yaml
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
blueprint:
|
||||
name: "Adaptive Lighting: limit brightness in daylight"
|
||||
description: >-
|
||||
Lower a profile's maximum brightness in strong daylight and restore the
|
||||
chosen normal maximum when daylight falls. Separate lux thresholds prevent
|
||||
repeated changes near a single threshold. Use a sensor not significantly
|
||||
affected by the controlled lights. Keep the daylight maximum at or above
|
||||
the profile's minimum unless you want an inverted brightness curve.
|
||||
Startup waits up to five minutes for a numeric sensor reading; a reading
|
||||
between the thresholds leaves the configured maximum unchanged.
|
||||
domain: automation
|
||||
homeassistant:
|
||||
min_version: "2025.9.0"
|
||||
input:
|
||||
adaptive_switch:
|
||||
name: Adaptive Lighting profile
|
||||
description: Select the main profile switch, not a sleep or adaptation switch.
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain: switch
|
||||
integration: adaptive_lighting
|
||||
illuminance_sensor:
|
||||
name: Illuminance sensor
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain: sensor
|
||||
device_class: illuminance
|
||||
high_lux:
|
||||
name: Strong daylight threshold
|
||||
description: Must be greater than the low daylight threshold.
|
||||
default: 300
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 200000
|
||||
mode: box
|
||||
unit_of_measurement: lx
|
||||
low_lux:
|
||||
name: Low daylight threshold
|
||||
default: 200
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 200000
|
||||
mode: box
|
||||
unit_of_measurement: lx
|
||||
daylight_maximum:
|
||||
name: Maximum brightness in strong daylight
|
||||
default: 30
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 100
|
||||
mode: box
|
||||
unit_of_measurement: "%"
|
||||
normal_maximum:
|
||||
name: Normal maximum brightness
|
||||
default: 100
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 100
|
||||
mode: box
|
||||
unit_of_measurement: "%"
|
||||
|
||||
mode: restart
|
||||
variables:
|
||||
illuminance_sensor: !input illuminance_sensor
|
||||
high_lux: !input high_lux
|
||||
low_lux: !input low_lux
|
||||
triggers:
|
||||
- trigger: numeric_state
|
||||
entity_id: !input illuminance_sensor
|
||||
above: !input high_lux
|
||||
- trigger: numeric_state
|
||||
entity_id: !input illuminance_sensor
|
||||
below: !input low_lux
|
||||
- trigger: homeassistant
|
||||
event: start
|
||||
id: startup
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ high_lux > low_lux }}"
|
||||
actions:
|
||||
- if:
|
||||
- condition: trigger
|
||||
id: startup
|
||||
then:
|
||||
- wait_template: "{{ is_number(states(illuminance_sensor)) }}"
|
||||
timeout: "00:05:00"
|
||||
continue_on_timeout: false
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: numeric_state
|
||||
entity_id: !input illuminance_sensor
|
||||
above: !input high_lux
|
||||
sequence:
|
||||
- action: adaptive_lighting.change_switch_settings
|
||||
data:
|
||||
entity_id: !input adaptive_switch
|
||||
max_brightness: !input daylight_maximum
|
||||
- conditions:
|
||||
- condition: numeric_state
|
||||
entity_id: !input illuminance_sensor
|
||||
below: !input low_lux
|
||||
sequence:
|
||||
- action: adaptive_lighting.change_switch_settings
|
||||
data:
|
||||
entity_id: !input adaptive_switch
|
||||
max_brightness: !input normal_maximum
|
||||
123
blueprints/automation/manual_control_at_minimum.yaml
Normal file
123
blueprints/automation/manual_control_at_minimum.yaml
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
blueprint:
|
||||
name: "Adaptive Lighting: pause brightness at minimum"
|
||||
description: >-
|
||||
Mark one light's brightness as manually controlled when its calculated
|
||||
brightness target crosses down to the configured minimum. Set the profile's
|
||||
take_over_control_mode to pause_changed to keep adapting color; pause_all
|
||||
pauses both. Existing manual color control is preserved. Uses the normal
|
||||
manual-control resets, including turning the light off and any configured
|
||||
autoreset_control_seconds timeout. Runs once per downward crossing, so a
|
||||
reset while the target remains low does not immediately pause it again.
|
||||
Waits up to five minutes for reported brightness to reach the rounded
|
||||
0–255 minimum before pausing. This does not prove physical fade completion. Does not
|
||||
change power or adaptation switches. Select an individual light managed by
|
||||
the profile. Shared profiles retain their usual manual-control behavior.
|
||||
domain: automation
|
||||
homeassistant:
|
||||
min_version: "2025.9.0"
|
||||
input:
|
||||
adaptive_switch:
|
||||
name: Adaptive Lighting profile
|
||||
description: Select the main Adaptive Lighting switch, not a sleep or adaptation switch.
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain: switch
|
||||
integration: adaptive_lighting
|
||||
brightness_switch:
|
||||
name: Adapt brightness switch
|
||||
description: Select the adapt brightness switch belonging to the same profile.
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain: switch
|
||||
integration: adaptive_lighting
|
||||
light_entity:
|
||||
name: Light
|
||||
description: Select one light managed by this profile. Create an automation for each light.
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain: light
|
||||
minimum_pct:
|
||||
name: Minimum brightness
|
||||
description: Match the profile's min_brightness setting. Update this if that setting changes.
|
||||
default: 1
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 100
|
||||
step: 1
|
||||
unit_of_measurement: "%"
|
||||
mode: box
|
||||
|
||||
mode: single
|
||||
variables:
|
||||
adaptive_switch: !input adaptive_switch
|
||||
light_entity: !input light_entity
|
||||
minimum_pct: !input minimum_pct
|
||||
minimum: "{{ (minimum_pct * 255 / 100) | round(0) }}"
|
||||
brightness_switch: !input brightness_switch
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: !input adaptive_switch
|
||||
attribute: brightness_pct
|
||||
conditions:
|
||||
- condition: state
|
||||
entity_id: !input adaptive_switch
|
||||
state: "on"
|
||||
- condition: state
|
||||
entity_id: !input brightness_switch
|
||||
state: "on"
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{% set before = trigger.from_state.attributes.get('brightness_pct')
|
||||
if trigger.from_state else none %}
|
||||
{% set after = trigger.to_state.attributes.get('brightness_pct')
|
||||
if trigger.to_state else none %}
|
||||
{{ is_number(before) and is_number(after)
|
||||
and (before | float * 255 / 100) | round(0) > minimum
|
||||
and (after | float * 255 / 100) | round(0) <= minimum }}
|
||||
- condition: state
|
||||
entity_id: !input light_entity
|
||||
state: "on"
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ light_entity not in (state_attr(adaptive_switch, 'manual_control_brightness') or []) }}
|
||||
actions:
|
||||
- variables:
|
||||
light_session: "{{ states[light_entity].last_changed.isoformat() }}"
|
||||
profile_session: "{{ states[adaptive_switch].last_changed.isoformat() }}"
|
||||
brightness_session: "{{ states[brightness_switch].last_changed.isoformat() }}"
|
||||
- wait_template: >-
|
||||
{% set target = state_attr(adaptive_switch, 'brightness_pct') %}
|
||||
{{ not is_state(light_entity, 'on') or not is_state(adaptive_switch, 'on')
|
||||
or not is_state(brightness_switch, 'on')
|
||||
or states[light_entity].last_changed.isoformat() != light_session
|
||||
or states[adaptive_switch].last_changed.isoformat() != profile_session
|
||||
or states[brightness_switch].last_changed.isoformat() != brightness_session
|
||||
or not is_number(target) or (target | float * 255 / 100) | round(0) > minimum
|
||||
or light_entity in (state_attr(adaptive_switch, 'manual_control_brightness') or [])
|
||||
or (state_attr(light_entity, 'brightness') | float(256)) <= minimum }}
|
||||
timeout: "00:05:00"
|
||||
continue_on_timeout: false
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{% set target = state_attr(adaptive_switch, 'brightness_pct') %}
|
||||
{{ is_state(light_entity, 'on') and is_state(adaptive_switch, 'on')
|
||||
and is_state(brightness_switch, 'on')
|
||||
and states[light_entity].last_changed.isoformat() == light_session
|
||||
and states[adaptive_switch].last_changed.isoformat() == profile_session
|
||||
and states[brightness_switch].last_changed.isoformat() == brightness_session
|
||||
and is_number(target) and (target | float * 255 / 100) | round(0) <= minimum
|
||||
and (state_attr(light_entity, 'brightness') | float(256)) <= minimum
|
||||
and light_entity not in
|
||||
(state_attr(adaptive_switch, 'manual_control_brightness') or []) }}
|
||||
- action: adaptive_lighting.set_manual_control
|
||||
data:
|
||||
entity_id: !input adaptive_switch
|
||||
lights: !input light_entity
|
||||
manual_control: >-
|
||||
{{ true if light_entity in
|
||||
(state_attr(adaptive_switch, 'manual_control_color') or [])
|
||||
else 'brightness' }}
|
||||
57
blueprints/automation/schedule_profile.yaml
Normal file
57
blueprints/automation/schedule_profile.yaml
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
blueprint:
|
||||
name: "Adaptive Lighting: scheduled profile"
|
||||
description: >-
|
||||
Apply fixed brightness and color temperature from a Schedule helper's
|
||||
brightness_pct and color_temp_kelvin attributes. Uses step changes, not
|
||||
interpolation. Missing attributes fall back to 1% and 2000 K. Outside an
|
||||
active block, restores ALL configured profile settings. Use this only if
|
||||
other automations do not also change runtime settings on this profile.
|
||||
Reapplies the active block on Home Assistant startup. Updates settings
|
||||
while the profile is off without enabling it; preserves manual control.
|
||||
domain: automation
|
||||
homeassistant:
|
||||
min_version: "2025.9.0"
|
||||
input:
|
||||
adaptive_switch:
|
||||
name: Adaptive Lighting profile
|
||||
description: Select the main profile switch, not a sleep or adaptation switch.
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain: switch
|
||||
integration: adaptive_lighting
|
||||
schedule_entity:
|
||||
name: Schedule helper
|
||||
description: Add brightness_pct (1–100) and color_temp_kelvin to each block's Additional data.
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain: schedule
|
||||
|
||||
mode: restart
|
||||
variables:
|
||||
schedule_entity: !input schedule_entity
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: !input schedule_entity
|
||||
- trigger: homeassistant
|
||||
event: start
|
||||
actions:
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: state
|
||||
entity_id: !input schedule_entity
|
||||
state: "on"
|
||||
sequence:
|
||||
- action: adaptive_lighting.change_switch_settings
|
||||
data:
|
||||
entity_id: !input adaptive_switch
|
||||
min_brightness: "{{ state_attr(schedule_entity, 'brightness_pct') | int(1) }}"
|
||||
max_brightness: "{{ state_attr(schedule_entity, 'brightness_pct') | int(1) }}"
|
||||
min_color_temp: "{{ state_attr(schedule_entity, 'color_temp_kelvin') | int(2000) }}"
|
||||
max_color_temp: "{{ state_attr(schedule_entity, 'color_temp_kelvin') | int(2000) }}"
|
||||
default:
|
||||
- action: adaptive_lighting.change_switch_settings
|
||||
data:
|
||||
entity_id: !input adaptive_switch
|
||||
use_defaults: configuration
|
||||
42
blueprints/automation/sleep_mode.yaml
Normal file
42
blueprints/automation/sleep_mode.yaml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
blueprint:
|
||||
name: "Adaptive Lighting: synchronize sleep mode"
|
||||
description: >-
|
||||
Keep the selected Adaptive Lighting sleep-mode switches in sync with an
|
||||
input boolean, including its restored state at Home Assistant startup.
|
||||
Unknown or unavailable helper states are ignored. Select only sleep-mode
|
||||
switches, not the main profile or adaptation switches.
|
||||
domain: automation
|
||||
homeassistant:
|
||||
min_version: "2025.9.0"
|
||||
input:
|
||||
sleep_helper:
|
||||
name: Sleep-mode helper
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain: input_boolean
|
||||
sleep_switches:
|
||||
name: Adaptive Lighting sleep-mode switches
|
||||
selector:
|
||||
entity:
|
||||
multiple: true
|
||||
filter:
|
||||
domain: switch
|
||||
integration: adaptive_lighting
|
||||
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: !input sleep_helper
|
||||
- trigger: homeassistant
|
||||
event: start
|
||||
variables:
|
||||
sleep_helper: !input sleep_helper
|
||||
sleep_mode: "{{ states(sleep_helper) }}"
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ sleep_mode in ['on', 'off'] }}"
|
||||
actions:
|
||||
- action: "switch.turn_{{ sleep_mode }}"
|
||||
target:
|
||||
entity_id: !input sleep_switches
|
||||
mode: restart
|
||||
87
blueprints/automation/turn_off_at_minimum.yaml
Normal file
87
blueprints/automation/turn_off_at_minimum.yaml
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
blueprint:
|
||||
name: "Adaptive Lighting: turn off at minimum brightness"
|
||||
description: >-
|
||||
Turn one light off when its Adaptive Lighting target crosses down into the
|
||||
chosen minimum brightness range. Compares rounded 0–255 commands, not the
|
||||
bulb's physical dimming limit or transition completion. Skips lights currently
|
||||
marked as manually controlled. Changing sleep mode clears manual control by
|
||||
default; set reset_manual_control_on_sleep_mode_change to false in your
|
||||
profile to preserve it. Does not turn lights back on or repeatedly turn them off
|
||||
while the target stays low. Startup at the minimum does not trigger it.
|
||||
Sleep mode can trigger it if its target crosses the chosen minimum.
|
||||
Select a light managed by the chosen profile and its matching brightness switch.
|
||||
domain: automation
|
||||
homeassistant:
|
||||
min_version: "2025.9.0"
|
||||
input:
|
||||
adaptive_switch:
|
||||
name: Adaptive Lighting profile
|
||||
description: Select the main Adaptive Lighting switch, not a sleep or adaptation switch.
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain: switch
|
||||
integration: adaptive_lighting
|
||||
brightness_switch:
|
||||
name: Adapt brightness switch
|
||||
description: Select the adapt brightness switch belonging to the same profile.
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain: switch
|
||||
integration: adaptive_lighting
|
||||
light_entity:
|
||||
name: Light
|
||||
description: Select one light managed by this profile. Create an automation for each light.
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain: light
|
||||
minimum_pct:
|
||||
name: Minimum brightness
|
||||
description: Match the profile's min_brightness setting. Update this if that setting changes.
|
||||
default: 1
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 100
|
||||
step: 1
|
||||
unit_of_measurement: "%"
|
||||
mode: box
|
||||
|
||||
mode: single
|
||||
variables:
|
||||
adaptive_switch: !input adaptive_switch
|
||||
light_entity: !input light_entity
|
||||
minimum_pct: !input minimum_pct
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: !input adaptive_switch
|
||||
attribute: brightness_pct
|
||||
conditions:
|
||||
- condition: state
|
||||
entity_id: !input adaptive_switch
|
||||
state: "on"
|
||||
- condition: state
|
||||
entity_id: !input brightness_switch
|
||||
state: "on"
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{% set minimum = (minimum_pct * 255 / 100) | round(0) %}
|
||||
{% set before = trigger.from_state.attributes.get('brightness_pct')
|
||||
if trigger.from_state else none %}
|
||||
{% set after = trigger.to_state.attributes.get('brightness_pct')
|
||||
if trigger.to_state else none %}
|
||||
{{ is_number(before) and is_number(after)
|
||||
and (before | float * 255 / 100) | round(0) > minimum
|
||||
and (after | float * 255 / 100) | round(0) <= minimum }}
|
||||
- condition: state
|
||||
entity_id: !input light_entity
|
||||
state: "on"
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ light_entity not in (state_attr(adaptive_switch, 'manual_control') or []) }}
|
||||
actions:
|
||||
- action: light.turn_off
|
||||
target:
|
||||
entity_id: !input light_entity
|
||||
|
|
@ -100,6 +100,16 @@ DOCS[CONF_ADAPT_ONLY_ON_BARE_TURN_ON] = (
|
|||
"Needs `take_over_control` enabled. 🕵️"
|
||||
)
|
||||
|
||||
CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON, DEFAULT_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON = (
|
||||
"manual_control_on_external_turn_on",
|
||||
False,
|
||||
)
|
||||
DOCS[CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON] = (
|
||||
"Treat turn-ons without a matching Home Assistant `light.turn_on` context as "
|
||||
"manual control. Normal manual-control resets apply. Still allows "
|
||||
"`detect_non_ha_changes` for already-on lights. Needs `take_over_control` enabled. 🕵️"
|
||||
)
|
||||
|
||||
CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR = "prefer_rgb_color", False
|
||||
DOCS[CONF_PREFER_RGB_COLOR] = (
|
||||
"Whether to prefer RGB color adjustment over "
|
||||
|
|
@ -281,6 +291,14 @@ DOCS[CONF_MULTI_LIGHT_INTERCEPT] = (
|
|||
"Requires `intercept` to be enabled."
|
||||
)
|
||||
|
||||
CONF_EXPAND_LIGHT_GROUPS, DEFAULT_EXPAND_LIGHT_GROUPS = "expand_light_groups", True
|
||||
DOCS[CONF_EXPAND_LIGHT_GROUPS] = (
|
||||
"Expand light groups to their members (`true`, default). Set `false` to send "
|
||||
"commands to the group and track manual control for the group. Explicit member "
|
||||
"targets in services stay individual targets."
|
||||
)
|
||||
|
||||
|
||||
SLEEP_MODE_SWITCH = "sleep_mode_switch"
|
||||
ADAPT_COLOR_SWITCH = "adapt_color_switch"
|
||||
ADAPT_BRIGHTNESS_SWITCH = "adapt_brightness_switch"
|
||||
|
|
@ -416,6 +434,11 @@ VALIDATION_TUPLES: list[tuple[str, Any, Any]] = [
|
|||
),
|
||||
(CONF_ONLY_ONCE, DEFAULT_ONLY_ONCE, bool),
|
||||
(CONF_ADAPT_ONLY_ON_BARE_TURN_ON, DEFAULT_ADAPT_ONLY_ON_BARE_TURN_ON, bool),
|
||||
(
|
||||
CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON,
|
||||
DEFAULT_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON,
|
||||
bool,
|
||||
),
|
||||
(
|
||||
CONF_RESET_MANUAL_CONTROL_ON_SLEEP_MODE_CHANGE,
|
||||
DEFAULT_RESET_MANUAL_CONTROL_ON_SLEEP_MODE_CHANGE,
|
||||
|
|
@ -432,6 +455,7 @@ VALIDATION_TUPLES: list[tuple[str, Any, Any]] = [
|
|||
(CONF_INTERCEPT, DEFAULT_INTERCEPT, bool),
|
||||
(CONF_MULTI_LIGHT_INTERCEPT, DEFAULT_MULTI_LIGHT_INTERCEPT, bool),
|
||||
(CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, DEFAULT_INCLUDE_CONFIG_IN_ATTRIBUTES, bool),
|
||||
(CONF_EXPAND_LIGHT_GROUPS, DEFAULT_EXPAND_LIGHT_GROUPS, bool),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
131
custom_components/adaptive_lighting/diagnostics.py
Normal file
131
custom_components/adaptive_lighting/diagnostics.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""Diagnostics support for Adaptive Lighting."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_RGB_COLOR,
|
||||
ATTR_TRANSITION,
|
||||
)
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .adaptation_utils import LightControlAttributes
|
||||
from .const import (
|
||||
ADAPT_BRIGHTNESS_SWITCH,
|
||||
ADAPT_COLOR_SWITCH,
|
||||
ATTR_ADAPTIVE_LIGHTING_MANAGER,
|
||||
DOMAIN,
|
||||
SLEEP_MODE_SWITCH,
|
||||
)
|
||||
from .switch import AdaptiveLightingManager, AdaptiveSwitch
|
||||
|
||||
_REPORTABLE_LIGHT_STATES = {
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
}
|
||||
_TARGET_ATTRIBUTES = (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_RGB_COLOR,
|
||||
ATTR_TRANSITION,
|
||||
)
|
||||
|
||||
|
||||
def _last_adaptation_values(
|
||||
manager: AdaptiveLightingManager,
|
||||
light: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return latest retained value for each allowlisted adaptation attribute.
|
||||
|
||||
Values may come from different commands because the manager merges partial
|
||||
service data per attribute.
|
||||
"""
|
||||
service_data = manager.last_service_data.get(light)
|
||||
if service_data is None:
|
||||
return None
|
||||
target = {
|
||||
attribute: (
|
||||
list(service_data[attribute])
|
||||
if attribute == ATTR_RGB_COLOR
|
||||
else service_data[attribute]
|
||||
)
|
||||
for attribute in _TARGET_ATTRIBUTES
|
||||
if attribute in service_data
|
||||
}
|
||||
return target or None
|
||||
|
||||
|
||||
def _autoreset_seconds(
|
||||
manager: AdaptiveLightingManager,
|
||||
light: str,
|
||||
) -> float | None:
|
||||
"""Return remaining time for a running global manual-control reset."""
|
||||
timer = manager.auto_reset_manual_control_timers.get(light)
|
||||
if timer is None or not timer.is_running():
|
||||
return None
|
||||
remaining = timer.remaining_time()
|
||||
return round(remaining, 3) if remaining > 0 else None
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
) -> dict[str, Any]:
|
||||
"""Return an allowlisted, on-demand snapshot for one config entry."""
|
||||
domain_data = hass.data.get(DOMAIN)
|
||||
if not isinstance(domain_data, dict):
|
||||
return {"loaded": False}
|
||||
entry_data = domain_data.get(config_entry.entry_id)
|
||||
manager = domain_data.get(ATTR_ADAPTIVE_LIGHTING_MANAGER)
|
||||
if not isinstance(entry_data, dict) or not isinstance(
|
||||
manager,
|
||||
AdaptiveLightingManager,
|
||||
):
|
||||
return {"loaded": False}
|
||||
switch = entry_data.get(SWITCH_DOMAIN)
|
||||
if not isinstance(switch, AdaptiveSwitch):
|
||||
return {"loaded": False}
|
||||
|
||||
lights: dict[str, Any] = {}
|
||||
for index, light in enumerate(sorted(switch.lights), start=1):
|
||||
state = hass.states.get(light)
|
||||
state_value = "missing"
|
||||
if state is not None:
|
||||
state_value = (
|
||||
state.state
|
||||
if state.state in _REPORTABLE_LIGHT_STATES
|
||||
else STATE_UNKNOWN
|
||||
)
|
||||
manual_control = manager.get_manual_control_attributes(light)
|
||||
lights[f"light_{index}"] = {
|
||||
"state": state_value,
|
||||
"global_manager_manual_control": {
|
||||
"brightness": bool(
|
||||
manual_control & LightControlAttributes.BRIGHTNESS,
|
||||
),
|
||||
"color": bool(manual_control & LightControlAttributes.COLOR),
|
||||
},
|
||||
"global_manager_autoreset_seconds": _autoreset_seconds(manager, light),
|
||||
"global_manager_last_adaptation_values": _last_adaptation_values(
|
||||
manager,
|
||||
light,
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"loaded": True,
|
||||
"profile_switches": {
|
||||
"profile": switch.is_on,
|
||||
"adapt_brightness": entry_data[ADAPT_BRIGHTNESS_SWITCH].is_on,
|
||||
"adapt_color": entry_data[ADAPT_COLOR_SWITCH].is_on,
|
||||
"sleep_mode": entry_data[SLEEP_MODE_SWITCH].is_on,
|
||||
},
|
||||
"manager_fact_scope": "global_shared_across_profiles",
|
||||
"lights": lights,
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -8,5 +8,5 @@
|
|||
"iot_class": "calculated",
|
||||
"issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues",
|
||||
"requirements": ["ulid-transform"],
|
||||
"version": "1.31.0"
|
||||
"version": "1.32.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,6 +139,12 @@ change_switch_settings:
|
|||
example: false
|
||||
selector:
|
||||
boolean: null
|
||||
expand_light_groups:
|
||||
description: Expand light groups to their members (`true`, default). Set `false` to send commands to the group and track manual control for the group. Explicit member targets in services stay individual targets.
|
||||
required: false
|
||||
example: true
|
||||
selector:
|
||||
boolean: null
|
||||
separate_turn_on_commands:
|
||||
description: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀
|
||||
required: false
|
||||
|
|
@ -238,6 +244,12 @@ change_switch_settings:
|
|||
example: false
|
||||
selector:
|
||||
boolean: null
|
||||
manual_control_on_external_turn_on:
|
||||
description: Treat turn-ons without a matching Home Assistant `light.turn_on` context as manual control. Normal manual-control resets apply. Still allows `detect_non_ha_changes` for already-on lights. Needs `take_over_control` enabled. 🕵️
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean: null
|
||||
transition:
|
||||
description: Duration of transition when lights change, in seconds. 🕑
|
||||
required: false
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@
|
|||
"autoreset_control_seconds": "autoreset_control_seconds",
|
||||
"only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄",
|
||||
"adapt_only_on_bare_turn_on": "adapt_only_on_bare_turn_on: When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene and marks the light as manually controlled. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. 🕵️",
|
||||
"manual_control_on_external_turn_on": "manual_control_on_external_turn_on: Treat turn-ons without a matching Home Assistant `light.turn_on` context as manual control. Normal manual-control resets apply. Still allows `detect_non_ha_changes` for already-on lights. Needs `take_over_control` enabled. 🕵️",
|
||||
"reset_manual_control_on_sleep_mode_change": "reset_manual_control_on_sleep_mode_change: Reset manual control when the sleep mode switch is toggled. Set to `false` to preserve manual control across sleep mode changes. 😴",
|
||||
"separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀",
|
||||
"send_split_delay": "send_split_delay",
|
||||
|
|
@ -77,7 +78,8 @@
|
|||
"skip_redundant_commands": "skip_redundant_commands: Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. 📉Disable if physical light states get out of sync with HA's recorded state.",
|
||||
"intercept": "intercept: Intercept and adapt `light.turn_on` calls to enabling instantaneous color and brightness adaptation. 🏎️ Disable for lights that do not support `light.turn_on` with color and brightness.",
|
||||
"multi_light_intercept": "multi_light_intercept: Intercept and adapt `light.turn_on` calls that target multiple lights. ➗⚠️ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches. Requires `intercept` to be enabled.",
|
||||
"include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝"
|
||||
"include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝",
|
||||
"expand_light_groups": "expand_light_groups: Expand light groups to their members (`true`, default). Set `false` to send commands to the group and track manual control for the group. Explicit member targets in services stay individual targets."
|
||||
},
|
||||
"data_description": {
|
||||
"initial_transition": "Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️",
|
||||
|
|
@ -210,6 +212,10 @@
|
|||
"description": "Whether to prefer RGB color adjustment over light color temperature when possible. 🌈",
|
||||
"name": "prefer_rgb_color"
|
||||
},
|
||||
"expand_light_groups": {
|
||||
"description": "Expand light groups to their members (`true`, default). Set `false` to send commands to the group and track manual control for the group. Explicit member targets in services stay individual targets.",
|
||||
"name": "expand_light_groups"
|
||||
},
|
||||
"separate_turn_on_commands": {
|
||||
"description": "Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀",
|
||||
"name": "separate_turn_on_commands"
|
||||
|
|
@ -270,6 +276,10 @@
|
|||
"description": "Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues.",
|
||||
"name": "detect_non_ha_changes"
|
||||
},
|
||||
"manual_control_on_external_turn_on": {
|
||||
"description": "Treat turn-ons without a matching Home Assistant `light.turn_on` context as manual control. Normal manual-control resets apply. Still allows `detect_non_ha_changes` for already-on lights. Needs `take_over_control` enabled. 🕵️",
|
||||
"name": "manual_control_on_external_turn_on"
|
||||
},
|
||||
"transition": {
|
||||
"description": "Duration of transition when lights change, in seconds. 🕑",
|
||||
"name": "transition"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -98,12 +101,14 @@ from .const import (
|
|||
CONF_BRIGHTNESS_MODE_TIME_DARK,
|
||||
CONF_BRIGHTNESS_MODE_TIME_LIGHT,
|
||||
CONF_DETECT_NON_HA_CHANGES,
|
||||
CONF_EXPAND_LIGHT_GROUPS,
|
||||
CONF_INCLUDE_CONFIG_IN_ATTRIBUTES,
|
||||
CONF_INITIAL_TRANSITION,
|
||||
CONF_INTERCEPT,
|
||||
CONF_INTERVAL,
|
||||
CONF_LIGHTS,
|
||||
CONF_MANUAL_CONTROL,
|
||||
CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON,
|
||||
CONF_MAX_BRIGHTNESS,
|
||||
CONF_MAX_COLOR_TEMP,
|
||||
CONF_MAX_SUNRISE_TIME,
|
||||
|
|
@ -147,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,
|
||||
|
|
@ -251,14 +256,11 @@ def _switches_with_lights(
|
|||
if not loaded_switches:
|
||||
return []
|
||||
|
||||
all_check_lights = (
|
||||
_expand_light_groups(hass, lights) if expand_light_groups else set(lights)
|
||||
)
|
||||
switches: AdaptiveSwitches = []
|
||||
for switch in loaded_switches:
|
||||
switch._expand_light_groups(hass=hass)
|
||||
# Check if any of the lights are in the switch's lights
|
||||
if set(switch.lights) & set(all_check_lights):
|
||||
switch._expand_light_groups()
|
||||
check_lights = switch._resolve_lights(lights) if expand_light_groups else lights
|
||||
if set(switch.lights) & set(check_lights):
|
||||
switches.append(switch)
|
||||
return switches
|
||||
|
||||
|
|
@ -421,7 +423,7 @@ async def handle_apply_service(hass: HomeAssistant, service_call: ServiceCall) -
|
|||
switches = _switches_from_service_call(hass, service_call)
|
||||
lights = data[CONF_LIGHTS]
|
||||
for switch in switches:
|
||||
all_lights = switch.lights if not lights else _expand_light_groups(hass, lights)
|
||||
all_lights = switch._resolve_lights(lights or None)
|
||||
switch.manager.lights.update(all_lights)
|
||||
for light in all_lights:
|
||||
if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light):
|
||||
|
|
@ -456,7 +458,7 @@ async def handle_set_manual_control_service(
|
|||
switches = _switches_from_service_call(hass, service_call)
|
||||
lights = data[CONF_LIGHTS]
|
||||
for switch in switches:
|
||||
all_lights = switch.lights if not lights else _expand_light_groups(hass, lights)
|
||||
all_lights = switch._resolve_lights(lights or None)
|
||||
manual_attributes = manual_control_event_attribute_to_flags(
|
||||
data[CONF_MANUAL_CONTROL],
|
||||
)
|
||||
|
|
@ -621,21 +623,38 @@ 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],
|
||||
) -> list[str]:
|
||||
"""Resolve nested groups without changing another profile's tracked targets."""
|
||||
all_lights: set[str] = set()
|
||||
manager = hass.data[DOMAIN][ATTR_ADAPTIVE_LIGHTING_MANAGER]
|
||||
for light in lights:
|
||||
pending = list(lights)
|
||||
visited: set[str] = set()
|
||||
while pending:
|
||||
light = pending.pop()
|
||||
if light in visited:
|
||||
continue
|
||||
visited.add(light)
|
||||
state = hass.states.get(light)
|
||||
if state is None:
|
||||
_LOGGER.debug("State of %s is None", light)
|
||||
all_lights.add(light)
|
||||
elif _is_light_group(state):
|
||||
group = state.attributes["entity_id"]
|
||||
manager.lights.discard(light)
|
||||
all_lights.update(group)
|
||||
pending.extend(group)
|
||||
_LOGGER.debug("Expanded %s to %s", light, group)
|
||||
else:
|
||||
all_lights.add(light)
|
||||
|
|
@ -880,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
|
||||
|
|
@ -888,7 +908,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
|
||||
self._name = data[CONF_NAME]
|
||||
self._interval: timedelta = data[CONF_INTERVAL]
|
||||
self.lights: list[str] = data[CONF_LIGHTS]
|
||||
self._configured_lights: list[str] = list(data[CONF_LIGHTS])
|
||||
self.lights: list[str] = []
|
||||
|
||||
# backup data for use in change_switch_settings "configuration" CONF_USE_DEFAULTS
|
||||
self._config_backup = deepcopy(data)
|
||||
|
|
@ -955,12 +976,14 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
self._send_split_delay = data[CONF_SEND_SPLIT_DELAY]
|
||||
self._take_over_control = data[CONF_TAKE_OVER_CONTROL]
|
||||
if not data[CONF_TAKE_OVER_CONTROL] and (
|
||||
data[CONF_DETECT_NON_HA_CHANGES] or data[CONF_ADAPT_ONLY_ON_BARE_TURN_ON]
|
||||
data[CONF_DETECT_NON_HA_CHANGES]
|
||||
or data[CONF_ADAPT_ONLY_ON_BARE_TURN_ON]
|
||||
or data[CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON]
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"%s: Config mismatch: `detect_non_ha_changes` or `adapt_only_on_bare_turn_on` "
|
||||
"set to `true` requires `take_over_control` to be enabled. Adjusting config "
|
||||
"and continuing setup with `take_over_control: true`.",
|
||||
"%s: Config mismatch: `detect_non_ha_changes`, `adapt_only_on_bare_turn_on`, "
|
||||
"or `manual_control_on_external_turn_on` set to `true` requires `take_over_control` to be "
|
||||
"enabled. Adjusting config and continuing setup with `take_over_control: true`.",
|
||||
self._name,
|
||||
)
|
||||
self._take_over_control = True
|
||||
|
|
@ -969,6 +992,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
)
|
||||
self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES]
|
||||
self._adapt_only_on_bare_turn_on = data[CONF_ADAPT_ONLY_ON_BARE_TURN_ON]
|
||||
self._manual_control_on_external_turn_on = data[
|
||||
CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON
|
||||
]
|
||||
self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL]
|
||||
self._reset_manual_control_on_sleep_mode_change = data[
|
||||
CONF_RESET_MANUAL_CONTROL_ON_SLEEP_MODE_CHANGE
|
||||
|
|
@ -984,6 +1010,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
self._name,
|
||||
)
|
||||
self._multi_light_intercept = False
|
||||
self._expand_light_groups_flag = data[CONF_EXPAND_LIGHT_GROUPS]
|
||||
self._expand_light_groups() # updates manual control timers
|
||||
observer = get_astral_observer(self.hass)
|
||||
|
||||
|
|
@ -1065,17 +1092,32 @@ 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 _expand_light_groups(self, hass: HomeAssistant | None = None) -> None:
|
||||
hass = hass or self.hass
|
||||
all_lights = _expand_light_groups(hass, self.lights)
|
||||
def _resolve_lights(self, lights: list[str] | None = None) -> list[str]:
|
||||
"""Apply this profile's group policy, preserving explicit member targets."""
|
||||
if lights is None:
|
||||
lights = self._configured_lights
|
||||
if self._expand_light_groups_flag:
|
||||
return _expand_light_groups(self.hass, lights)
|
||||
return sorted(set(lights))
|
||||
|
||||
def _expand_light_groups(self) -> None:
|
||||
all_lights = self._resolve_lights()
|
||||
removed = set(self.lights) - set(all_lights)
|
||||
self.lights = all_lights
|
||||
if removed:
|
||||
# Other profiles may still own a retired member or group, even when off.
|
||||
for entry in self.hass.data[DOMAIN].values():
|
||||
if isinstance(entry, dict) and (switch := entry.get(SWITCH_DOMAIN)):
|
||||
removed.difference_update(switch._resolve_lights())
|
||||
self.manager.remove_lights(*removed)
|
||||
self.manager.lights.update(all_lights)
|
||||
self.manager.set_auto_reset_manual_control_times(
|
||||
all_lights,
|
||||
self._auto_reset_manual_control_time,
|
||||
)
|
||||
self.lights = list(all_lights)
|
||||
|
||||
async def _setup_listeners(self, _: Event[NoEventData] | None = None) -> None:
|
||||
_LOGGER.debug("%s: Called '_setup_listeners'", self._name)
|
||||
|
|
@ -1406,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.
|
||||
|
|
@ -1461,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(
|
||||
|
|
@ -1515,6 +1563,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
return
|
||||
|
||||
if lights is None:
|
||||
self._expand_light_groups()
|
||||
lights = self.lights
|
||||
|
||||
on_lights = [light for light in lights if is_on(self.hass, light)]
|
||||
|
|
@ -1603,16 +1652,26 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
)
|
||||
if (
|
||||
self._take_over_control
|
||||
and not self._detect_non_ha_changes
|
||||
and (
|
||||
not self._detect_non_ha_changes
|
||||
or self._manual_control_on_external_turn_on
|
||||
)
|
||||
and not from_turn_on
|
||||
):
|
||||
# There is an edge case where 2 switches control the same light, e.g.,
|
||||
# one for brightness and one for color. Now we will mark both switches
|
||||
# as manually controlled, which is not 100% correct.
|
||||
#
|
||||
# This 'off' → 'on' event does not exactly match the most recently tracked
|
||||
# `light.turn_on` context for the entity. Hand control over when either:
|
||||
# - `detect_non_ha_changes` is False (we can't reliably track manual changes
|
||||
# to already-on lights anyway), or
|
||||
# - `manual_control_on_external_turn_on` is True (the user explicitly wants external
|
||||
# turn-ons left untouched, even while `detect_non_ha_changes` is enabled).
|
||||
_LOGGER.debug(
|
||||
"%s: Ignoring 'off' → 'on' event for '%s' with context.id='%s'"
|
||||
" because 'light.turn_on' was not called by HA and"
|
||||
" 'detect_non_ha_changes' is False",
|
||||
" because it does not match a tracked 'light.turn_on' context and"
|
||||
" ('detect_non_ha_changes' is False or 'manual_control_on_external_turn_on' is True)",
|
||||
self._name,
|
||||
entity_id,
|
||||
event.context.id,
|
||||
|
|
@ -1652,6 +1711,10 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
if self._adapt_delay > 0:
|
||||
await asyncio.sleep(self._adapt_delay)
|
||||
|
||||
# Runtime settings may retire this profile's target while the event waits.
|
||||
if self._removed or entity_id not in self.lights:
|
||||
return
|
||||
|
||||
await self._update_attrs_and_maybe_adapt_lights(
|
||||
context=self.create_context("light_event", parent=event.context),
|
||||
lights=[entity_id],
|
||||
|
|
@ -1663,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(
|
||||
|
|
@ -1930,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],
|
||||
|
|
@ -1967,8 +2024,12 @@ class AdaptiveLightingManager:
|
|||
if (
|
||||
not switch.is_on
|
||||
or not switch._intercept
|
||||
# Never adapt on light groups, because HA will make a separate light.turn_on
|
||||
or ((e := self.hass.states.get(entity_id)) and _is_light_group(e))
|
||||
# Never adapt on light groups when expanding, because HA will make a separate light.turn_on
|
||||
or (
|
||||
switch._expand_light_groups_flag
|
||||
and (e := self.hass.states.get(entity_id))
|
||||
and _is_light_group(e)
|
||||
)
|
||||
# Prevent adaptation of TURN_ON calls when light is already on,
|
||||
# and of TOGGLE calls when toggling off.
|
||||
or self.hass.states.is_state(entity_id, STATE_ON)
|
||||
|
|
@ -2116,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
|
||||
|
||||
|
|
@ -2233,6 +2300,7 @@ class AdaptiveLightingManager:
|
|||
# state can change until the next call), so we just schedule it and let
|
||||
# it sort out by itself.
|
||||
already_applied = get_light_control_attributes(first_service_data)
|
||||
shared_sleep_time = adaptation_data.sleep_time
|
||||
for index, entity_id in enumerate(entity_ids):
|
||||
self.set_proactively_adapting(call.context.id, entity_id)
|
||||
if index:
|
||||
|
|
@ -2248,6 +2316,9 @@ class AdaptiveLightingManager:
|
|||
if adaptation_data is None or not adaptation_data.max_length:
|
||||
continue
|
||||
self.set_proactively_adapting(adaptation_data.context.id, entity_id)
|
||||
# Every follow-up waits for the shared first command, even when a
|
||||
# member's capabilities give it a different number of split phases.
|
||||
adaptation_data.sleep_time = shared_sleep_time
|
||||
adaptation_data.initial_sleep = True
|
||||
|
||||
# Don't await to avoid blocking the service call.
|
||||
|
|
@ -2365,7 +2436,11 @@ class AdaptiveLightingManager:
|
|||
delay,
|
||||
)
|
||||
self.reset(light)
|
||||
switches = _switches_with_lights(self.hass, [light])
|
||||
switches = _switches_with_lights(
|
||||
self.hass,
|
||||
[light],
|
||||
expand_light_groups=False,
|
||||
)
|
||||
for switch in switches:
|
||||
if not switch.is_on:
|
||||
continue
|
||||
|
|
@ -2556,32 +2631,36 @@ class AdaptiveLightingManager:
|
|||
if reset_manual_control:
|
||||
self._schedule_manual_control_state_update(*lights)
|
||||
|
||||
def remove_lights(self, *lights: str) -> None:
|
||||
"""Retire tracking and pending work for targets no profile owns anymore."""
|
||||
self.reset(*lights)
|
||||
for light in lights:
|
||||
self.lights.discard(light)
|
||||
self.clear_proactively_adapting(light)
|
||||
if timer := self.transition_timers.pop(light, None):
|
||||
timer.cancel()
|
||||
if task := self.sleep_tasks.pop(light, None):
|
||||
task.cancel()
|
||||
for records in (
|
||||
self.manual_control,
|
||||
self.auto_reset_manual_control_times,
|
||||
self.turn_on_event,
|
||||
self.turn_off_event,
|
||||
self.toggle_event,
|
||||
self.on_to_off_event,
|
||||
self.off_to_on_event,
|
||||
self.turn_off_locks,
|
||||
self.adaptation_tasks_brightness,
|
||||
self.adaptation_tasks_color,
|
||||
):
|
||||
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."""
|
||||
|
|
@ -2612,22 +2691,20 @@ class AdaptiveLightingManager:
|
|||
# Fix for https://github.com/basnijholt/adaptive-lighting/issues/1378
|
||||
state = self.hass.states.get(eid)
|
||||
if state is not None and state.state == STATE_ON:
|
||||
try:
|
||||
switch = _switch_with_lights(
|
||||
self.hass,
|
||||
[eid],
|
||||
expand_light_groups=False,
|
||||
)
|
||||
await self.update_manually_controlled_from_event(
|
||||
switch,
|
||||
eid,
|
||||
force=False,
|
||||
)
|
||||
except NoSwitchFoundError:
|
||||
_LOGGER.debug(
|
||||
"No switch found for entity_id='%s' in 'on' event listener",
|
||||
eid,
|
||||
)
|
||||
switches = _switches_with_lights(
|
||||
self.hass,
|
||||
[eid],
|
||||
expand_light_groups=False,
|
||||
)
|
||||
for switch in switches:
|
||||
# Preserve tracking for a lone profile, including when off.
|
||||
# Shared lights notify each enabled owner using its takeover policy.
|
||||
if switch.is_on or len(switches) == 1:
|
||||
await self.update_manually_controlled_from_event(
|
||||
switch,
|
||||
eid,
|
||||
force=False,
|
||||
)
|
||||
|
||||
timer = self.auto_reset_manual_control_timers.get(eid)
|
||||
if (
|
||||
|
|
@ -2675,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:
|
||||
|
|
@ -2753,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
|
||||
|
|
@ -2792,7 +2873,11 @@ class AdaptiveLightingManager:
|
|||
)
|
||||
return
|
||||
|
||||
switches = _switches_with_lights(self.hass, [entity_id])
|
||||
switches = _switches_with_lights(
|
||||
self.hass,
|
||||
[entity_id],
|
||||
expand_light_groups=False,
|
||||
)
|
||||
for switch in switches:
|
||||
if switch.is_on:
|
||||
await switch._respond_to_off_to_on_event(
|
||||
|
|
@ -2974,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'.
|
||||
|
|
@ -2994,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
|
||||
):
|
||||
|
|
@ -3009,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,
|
||||
|
|
@ -3027,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(
|
||||
|
|
@ -3076,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
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,8 @@
|
|||
"skip_redundant_commands": "skip_redundant_commands: Überspringt das Senden von Anpassungsbefehlen, deren Zielzustand bereits mit dem bekannten Zustand der Leuchte übereinstimmt. Minimiert den Netzwerkverkehr und verbessert die Anpassungsreaktion in einigen Situationen. 📉 Deaktivieren, falls der physikalische Zustand der Lichter nicht mehr mit dem Zustand in HA übereinstimmt.",
|
||||
"intercept": "intercept: Abfangen und Anpassen von `light.turn_on`-Aufrufen, um eine sofortige Anpassung von Farbe und Helligkeit zu ermöglichen. 🏎️ Deaktivieren für Leuchten, die `light.turn_on` mit Farbe und Helligkeit nicht unterstützen.",
|
||||
"multi_light_intercept": "multi_light_intercept: Abfangen und Anpassen von `light.turn_on`-Aufrufen, die auf mehrere Lichter aufrufen. ➗⚠️ Dies kann dazu führen, dass ein einzelner `light.turn_on`-Aufruf in mehrere Aufrufe aufgeteilt wird, z.B. wenn Lichter in verschiedenen Schaltern sind. Erfordert, dass `intercept` aktiviert ist.",
|
||||
"include_config_in_attributes": "include_config_in_attributes: Alle Optionen als Attribute auf dem Schalter im Home Assistant anzeigen, wenn auf `true` gesetzt. 📝"
|
||||
"include_config_in_attributes": "include_config_in_attributes: Alle Optionen als Attribute auf dem Schalter im Home Assistant anzeigen, wenn auf `true` gesetzt. 📝",
|
||||
"expand_light_groups": "expand_light_groups: Lichtgruppen auf einzelne Mitgliedsentitäten erweitern (`true`) oder die Gruppenentität direkt steuern (`false`)."
|
||||
},
|
||||
"data_description": {
|
||||
"initial_transition": "Dauer des ersten Übergangs, wenn das Licht von `off` auf `on` schaltet, in Sekunden. ⏲️",
|
||||
|
|
@ -88,7 +89,8 @@
|
|||
"brightness_mode_time_light": "(Wird ignoriert, wenn `brightness_mode='default'`) Die Dauer in Sekunden, um die Helligkeit nach/vor Sonnenaufgang/Sonnenuntergang hoch/runter zu fahren. 📈📉.",
|
||||
"autoreset_control_seconds": "Setzt die manuelle Steuerung nach einer bestimmten Anzahl von Sekunden automatisch zurück. Zum Deaktivieren auf 0 setzen. ⏲️",
|
||||
"send_split_delay": "Verzögerung (ms) zwischen `separate_turn_on_commands` für Leuchten, die keine gleichzeitige Einstellung von Helligkeit und Farbe unterstützen. ⏲️",
|
||||
"adapt_delay": "Wartezeit (Sekunden) zwischen dem Einschalten des Lichts und der Anwendung der adaptiven Beleuchtung. Könnte helfen, Flackern zu vermeiden. ⏲️"
|
||||
"adapt_delay": "Wartezeit (Sekunden) zwischen dem Einschalten des Lichts und der Anwendung der adaptiven Beleuchtung. Könnte helfen, Flackern zu vermeiden. ⏲️",
|
||||
"expand_light_groups": "Wenn auf `false` gesetzt, werden Anpassungsbefehle direkt an die Gruppenentität gesendet und nicht an die einzelnen Mitglieder."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@
|
|||
"autoreset_control_seconds": "autoreset_control_seconds",
|
||||
"only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄",
|
||||
"adapt_only_on_bare_turn_on": "adapt_only_on_bare_turn_on: When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene and marks the light as manually controlled. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. 🕵️",
|
||||
"manual_control_on_external_turn_on": "manual_control_on_external_turn_on: Treat turn-ons without a matching Home Assistant `light.turn_on` context as manual control. Normal manual-control resets apply. Still allows `detect_non_ha_changes` for already-on lights. Needs `take_over_control` enabled. 🕵️",
|
||||
"reset_manual_control_on_sleep_mode_change": "reset_manual_control_on_sleep_mode_change: Reset manual control when the sleep mode switch is toggled. Set to `false` to preserve manual control across sleep mode changes. 😴",
|
||||
"separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀",
|
||||
"send_split_delay": "send_split_delay",
|
||||
|
|
@ -78,7 +79,8 @@
|
|||
"skip_redundant_commands": "skip_redundant_commands: Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. 📉Disable if physical light states get out of sync with HA's recorded state.",
|
||||
"intercept": "intercept: Intercept and adapt `light.turn_on` calls to enabling instantaneous color and brightness adaptation. 🏎️ Disable for lights that do not support `light.turn_on` with color and brightness.",
|
||||
"multi_light_intercept": "multi_light_intercept: Intercept and adapt `light.turn_on` calls that target multiple lights. ➗⚠️ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches. Requires `intercept` to be enabled.",
|
||||
"include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝"
|
||||
"include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝",
|
||||
"expand_light_groups": "expand_light_groups: Expand light groups to their members (`true`, default). Set `false` to send commands to the group and track manual control for the group. Explicit member targets in services stay individual targets."
|
||||
},
|
||||
"data_description": {
|
||||
"initial_transition": "Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️",
|
||||
|
|
@ -211,6 +213,10 @@
|
|||
"description": "Whether to prefer RGB color adjustment over light color temperature when possible. 🌈",
|
||||
"name": "prefer_rgb_color"
|
||||
},
|
||||
"expand_light_groups": {
|
||||
"description": "Expand light groups to their members (`true`, default). Set `false` to send commands to the group and track manual control for the group. Explicit member targets in services stay individual targets.",
|
||||
"name": "expand_light_groups"
|
||||
},
|
||||
"separate_turn_on_commands": {
|
||||
"description": "Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀",
|
||||
"name": "separate_turn_on_commands"
|
||||
|
|
@ -271,6 +277,10 @@
|
|||
"description": "Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues.",
|
||||
"name": "detect_non_ha_changes"
|
||||
},
|
||||
"manual_control_on_external_turn_on": {
|
||||
"description": "Treat turn-ons without a matching Home Assistant `light.turn_on` context as manual control. Normal manual-control resets apply. Still allows `detect_non_ha_changes` for already-on lights. Needs `take_over_control` enabled. 🕵️",
|
||||
"name": "manual_control_on_external_turn_on"
|
||||
},
|
||||
"transition": {
|
||||
"description": "Duration of transition when lights change, in seconds. 🕑",
|
||||
"name": "transition"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ This feature is available when `take_over_control` is enabled.
|
|||
Additionally, enabling `detect_non_ha_changes` allows Adaptive Lighting to detect all state changes, including those made outside of Home Assistant, by comparing the light's state to its previously used settings.
|
||||
The `adaptive_lighting.manual_control` event is fired when a light is marked as "manually controlled," allowing for integration with automations 🤖.
|
||||
|
||||
With `expand_light_groups: false`, manual control belongs to the group. A direct member change cannot pause adaptation for only that member; use group-level manual control or enable expansion for individual tracking.
|
||||
Explicit member targets in Adaptive Lighting services stay individual targets and do not mark or command the whole group.
|
||||
Changing expansion at runtime discards tracking and pending adaptation for targets no longer used by any profile.
|
||||
|
||||
The Adaptive Lighting switch exposes these read-only attributes for its lights:
|
||||
|
||||
- `manual_control`: lights with any attribute marked as manually controlled.
|
||||
|
|
@ -112,6 +116,28 @@ adaptive_lighting:
|
|||
adapt_only_on_bare_turn_on: true
|
||||
```
|
||||
|
||||
### manual_control_on_external_turn_on
|
||||
|
||||
When enabled, a turn-on without a state-change context matching the latest recorded Home Assistant `light.turn_on` is treated as manual control. This pauses brightness and color adaptation until manual control resets, rather than skipping just the first adjustment. The usual off/on, explicit reset, and configured timeout rules apply. A later unmatched turn-on marks the light manually controlled again.
|
||||
|
||||
Manual-control flags are shared by profiles controlling the same light. Use the same turn-on policy on those profiles; mixed policies can allow an earlier profile to adapt before another marks the light manually controlled.
|
||||
|
||||
Enable this if you want turn-ons from physical controls or native scenes to preserve their brightness and color. To adapt unmatched turn-ons, leave this disabled and enable `detect_non_ha_changes`.
|
||||
|
||||
Its advantage over simply disabling `detect_non_ha_changes` is that the two behaviors are decoupled: you can keep `detect_non_ha_changes: true` to catch manual dimming of lights that are *already on*, while leaving unmatched turn-ons untouched.
|
||||
|
||||
Adaptive Lighting cannot identify every physical versus Home Assistant source. Some integrations replace or omit the service context when they publish device state. In that case, even a Home Assistant turn-on does not match and this option treats it as external.
|
||||
|
||||
```yaml
|
||||
adaptive_lighting:
|
||||
- name: "Respect physical switches and Lutron scenes"
|
||||
lights:
|
||||
- light.living_room
|
||||
take_over_control: true
|
||||
detect_non_ha_changes: true # still catch manual changes to already-on lights
|
||||
manual_control_on_external_turn_on: true # leave unmatched off→on events unchanged
|
||||
```
|
||||
|
||||
## Checking Manual Control Status
|
||||
|
||||
You can see which lights are marked as manually controlled by checking the switch attributes:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,18 @@ Replace every entity ID below with the IDs from your Home Assistant instance. Fr
|
|||
|
||||
Blocks that begin with `- alias` are entries for `automations.yaml`. Blocks with a top-level `script:` or `adaptive_lighting:` key are complete `configuration.yaml` examples. If your configuration uses `script: !include scripts.yaml`, omit that outer key and place its contents in `scripts.yaml`.
|
||||
|
||||
Five examples also have blueprints with selectors, so you can configure them without editing YAML:
|
||||
|
||||
| Blueprint | Purpose | Import |
|
||||
| --- | --- | --- |
|
||||
| [Sleep mode](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/sleep_mode.yaml) | Synchronize several profiles with one sleep-mode helper. | [](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fbasnijholt%2Fadaptive-lighting%2Fblob%2Fmain%2Fblueprints%2Fautomation%2Fsleep_mode.yaml) |
|
||||
| [Minimum brightness](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/turn_off_at_minimum.yaml) | Turn one light off when its target crosses down to the minimum. | [](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fbasnijholt%2Fadaptive-lighting%2Fblob%2Fmain%2Fblueprints%2Fautomation%2Fturn_off_at_minimum.yaml) |
|
||||
| [Pause at minimum](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/manual_control_at_minimum.yaml) | Pause brightness through manual control, using its existing reset behavior. | [](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fbasnijholt%2Fadaptive-lighting%2Fblob%2Fmain%2Fblueprints%2Fautomation%2Fmanual_control_at_minimum.yaml) |
|
||||
| [Schedule profile](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/schedule_profile.yaml) | Apply brightness and color temperature from Schedule helper blocks. | [](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fbasnijholt%2Fadaptive-lighting%2Fblob%2Fmain%2Fblueprints%2Fautomation%2Fschedule_profile.yaml) |
|
||||
| [Daylight limit](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/daylight_limit.yaml) | Lower maximum brightness in strong daylight. | [](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fbasnijholt%2Fadaptive-lighting%2Fblob%2Fmain%2Fblueprints%2Fautomation%2Fdaylight_limit.yaml) |
|
||||
|
||||
Click a blueprint's import badge, confirm the import in Home Assistant, then create an automation and select your entities. You can also copy its source link into **Settings → Automations & scenes → Blueprints → Import Blueprint**. Read the matching example below for setup and behavior. Each blueprint is tested through Home Assistant alongside its YAML example. The built-in manual-control timeout needs no automation; the scripts below remain useful as actions in your own automations.
|
||||
|
||||
`change_switch_settings` updates a profile while its main switch is off, but lights are adapted only while that switch is on. It preserves manual-control flags, so manually controlled lights remain paused.
|
||||
|
||||
<details markdown="1">
|
||||
|
|
@ -38,8 +50,11 @@ This is a top-level `configuration.yaml` example. The timer clears manual contro
|
|||
<details markdown="1">
|
||||
<summary>Toggle multiple Adaptive Lighting switches to "sleep mode" using an <code>input_boolean.sleep_mode</code>.</summary>
|
||||
|
||||
Also available as a [blueprint](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/sleep_mode.yaml). Select an input boolean and the sleep-mode switches it should control.
|
||||
|
||||
```yaml
|
||||
- alias: "Adaptive lighting: toggle 'sleep mode'"
|
||||
mode: restart
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: input_boolean.sleep_mode
|
||||
|
|
@ -60,6 +75,145 @@ This is a top-level `configuration.yaml` example. The timer clears manual contro
|
|||
|
||||
</details>
|
||||
|
||||
<details markdown="1">
|
||||
<summary>Turn a light off when its adaptive brightness target reaches the minimum.</summary>
|
||||
|
||||
Prefer a form over editing YAML? Import the [blueprint](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/turn_off_at_minimum.yaml) in Home Assistant under **Settings → Automations & scenes → Blueprints → Import Blueprint**. Select your profile, its matching adapt brightness switch, one light managed by that profile, and its minimum brightness percentage. Create one automation per light. If you change the profile's minimum later, update the automation too. The blueprint and YAML example below have the same behavior.
|
||||
|
||||
The Adaptive Lighting switch already exposes its calculated `brightness_pct` target. Use its state changes to choose a power policy in an automation; no custom event is needed. This example assumes `min_brightness: 1`. Change `minimum_pct` to match your profile, and replace the switch and light entity IDs with your own.
|
||||
|
||||
The comparison uses the same rounded 0–255 brightness as an adaptation command. Comparing floating-point percentages for exact equality can miss the minimum between updates. This detects the calculated target reaching its minimum command, not the bulb finishing a transition or reaching its physical dimming limit.
|
||||
|
||||
```yaml
|
||||
- alias: "Adaptive lighting: turn off at minimum brightness"
|
||||
mode: single
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: switch.adaptive_lighting_living_room
|
||||
attribute: brightness_pct
|
||||
conditions:
|
||||
- condition: state
|
||||
entity_id:
|
||||
- switch.adaptive_lighting_living_room
|
||||
- switch.adaptive_lighting_living_room_adapt_brightness
|
||||
state: "on"
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{% set minimum_pct = 1 %}
|
||||
{% set minimum = (minimum_pct * 255 / 100) | round(0) %}
|
||||
{% set before = trigger.from_state.attributes.get('brightness_pct')
|
||||
if trigger.from_state else none %}
|
||||
{% set after = trigger.to_state.attributes.get('brightness_pct')
|
||||
if trigger.to_state else none %}
|
||||
{{ is_number(before) and is_number(after)
|
||||
and (before | float * 255 / 100) | round(0) > minimum
|
||||
and (after | float * 255 / 100) | round(0) <= minimum }}
|
||||
- condition: state
|
||||
entity_id: light.living_room
|
||||
state: "on"
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ 'light.living_room' not in
|
||||
(state_attr('switch.adaptive_lighting_living_room', 'manual_control') or []) }}
|
||||
actions:
|
||||
- action: light.turn_off
|
||||
target:
|
||||
entity_id: light.living_room
|
||||
```
|
||||
|
||||
This runs once when a valid target crosses down into the minimum range. It skips lights currently marked as manually controlled, does not repeatedly turn them off while the target remains low, and does not turn them back on later. Startup or re-enabling the profile while already at the minimum is not a new crossing. Sleep mode can also cause a crossing if its brightness is at or below the chosen minimum. Changing sleep mode clears manual control by default; set `reset_manual_control_on_sleep_mode_change: false` if you want to preserve it. For a bedtime-only policy, trigger directly on the sleep-mode switch changing to `on` instead.
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown="1">
|
||||
<summary>Pause brightness at the minimum using manual control.</summary>
|
||||
|
||||
Use the [blueprint](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/manual_control_at_minimum.yaml) to mark an individual light's brightness as manually controlled when the calculated target reaches its minimum. The light stays on and the adaptation switches stay enabled. Set `take_over_control_mode: pause_changed` on the profile to keep adapting color; the default `pause_all` pauses both attributes.
|
||||
|
||||
Select the profile, its adapt-brightness switch, and a light managed by it. Match the minimum percentage to the profile's `min_brightness`. This YAML example assumes `min_brightness: 1`; change `minimum_pct` and the entity IDs to match your setup.
|
||||
|
||||
```yaml
|
||||
- alias: "Adaptive lighting: pause brightness at minimum"
|
||||
mode: single
|
||||
variables:
|
||||
minimum_pct: 1
|
||||
minimum: "{{ (minimum_pct * 255 / 100) | round(0) }}"
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: switch.adaptive_lighting_living_room
|
||||
attribute: brightness_pct
|
||||
conditions:
|
||||
- condition: state
|
||||
entity_id:
|
||||
- switch.adaptive_lighting_living_room
|
||||
- switch.adaptive_lighting_living_room_adapt_brightness
|
||||
state: "on"
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{% set before = trigger.from_state.attributes.get('brightness_pct')
|
||||
if trigger.from_state else none %}
|
||||
{% set after = trigger.to_state.attributes.get('brightness_pct')
|
||||
if trigger.to_state else none %}
|
||||
{{ is_number(before) and is_number(after)
|
||||
and (before | float * 255 / 100) | round(0) > minimum
|
||||
and (after | float * 255 / 100) | round(0) <= minimum }}
|
||||
- condition: state
|
||||
entity_id: light.living_room
|
||||
state: "on"
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ 'light.living_room' not in
|
||||
(state_attr('switch.adaptive_lighting_living_room', 'manual_control_brightness') or []) }}
|
||||
actions:
|
||||
- variables:
|
||||
light_session: "{{ states.light.living_room.last_changed.isoformat() }}"
|
||||
profile_session: "{{ states.switch.adaptive_lighting_living_room.last_changed.isoformat() }}"
|
||||
brightness_session: "{{ states.switch.adaptive_lighting_living_room_adapt_brightness.last_changed.isoformat() }}"
|
||||
- wait_template: >-
|
||||
{% set target = state_attr('switch.adaptive_lighting_living_room', 'brightness_pct') %}
|
||||
{{ not is_state('light.living_room', 'on')
|
||||
or not is_state('switch.adaptive_lighting_living_room', 'on')
|
||||
or not is_state('switch.adaptive_lighting_living_room_adapt_brightness', 'on')
|
||||
or states.light.living_room.last_changed.isoformat() != light_session
|
||||
or states.switch.adaptive_lighting_living_room.last_changed.isoformat() != profile_session
|
||||
or states.switch.adaptive_lighting_living_room_adapt_brightness.last_changed.isoformat() != brightness_session
|
||||
or not is_number(target) or (target | float * 255 / 100) | round(0) > minimum
|
||||
or 'light.living_room' in
|
||||
(state_attr('switch.adaptive_lighting_living_room', 'manual_control_brightness') or [])
|
||||
or (state_attr('light.living_room', 'brightness') | float(256)) <= minimum }}
|
||||
timeout: "00:05:00"
|
||||
continue_on_timeout: false
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{% set target = state_attr('switch.adaptive_lighting_living_room', 'brightness_pct') %}
|
||||
{{ is_state('light.living_room', 'on')
|
||||
and is_state('switch.adaptive_lighting_living_room', 'on')
|
||||
and is_state('switch.adaptive_lighting_living_room_adapt_brightness', 'on')
|
||||
and states.light.living_room.last_changed.isoformat() == light_session
|
||||
and states.switch.adaptive_lighting_living_room.last_changed.isoformat() == profile_session
|
||||
and states.switch.adaptive_lighting_living_room_adapt_brightness.last_changed.isoformat() == brightness_session
|
||||
and is_number(target) and (target | float * 255 / 100) | round(0) <= minimum
|
||||
and (state_attr('light.living_room', 'brightness') | float(256)) <= minimum
|
||||
and 'light.living_room' not in
|
||||
(state_attr('switch.adaptive_lighting_living_room', 'manual_control_brightness') or []) }}
|
||||
- action: adaptive_lighting.set_manual_control
|
||||
data:
|
||||
entity_id: switch.adaptive_lighting_living_room
|
||||
lights: light.living_room
|
||||
manual_control: >-
|
||||
{{ true if 'light.living_room' in
|
||||
(state_attr('switch.adaptive_lighting_living_room', 'manual_control_color') or [])
|
||||
else 'brightness' }}
|
||||
```
|
||||
|
||||
The comparison uses the rounded 0–255 target, so it does not depend on sampling an exact floating-point minimum. It waits up to five minutes for the light to report that minimum before marking manual control, so the final dimming command can complete. Reported brightness does not prove physical fade completion. If the light, profile, or adapt-brightness switch is toggled, the target rises, brightness is marked manually controlled elsewhere, or brightness never reaches the minimum, the attempt is abandoned. Lights that cannot report the configured minimum will not be paused. Existing manual color flags are preserved, and lights whose brightness is already manually controlled are left alone. Manual-control state is shared for lights managed by multiple profiles, so their existing takeover policies still apply.
|
||||
|
||||
The usual resets apply: turning the light off, the configured `autoreset_control_seconds` timeout, clearing manual control through its service, and existing profile/sleep-switch reset behavior. After a reset, normal adaptation can increase brightness again. This runs once per downward crossing; resetting while the target remains at its minimum does not immediately mark the light again. Startup at the minimum is not a crossing either.
|
||||
|
||||
This pauses further dimming as well as brightening. To pause brightness immediately after a brightness change made through Home Assistant, use `take_over_control_mode: pause_changed` with `take_over_control: true`; that needs no additional automation.
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown="1">
|
||||
<summary>Set sunrise and sunset from an alarm.</summary>
|
||||
|
||||
|
|
@ -86,6 +240,8 @@ script:
|
|||
<details markdown="1">
|
||||
<summary>Use a Schedule helper as a step-based custom lighting profile.</summary>
|
||||
|
||||
Also available as a [blueprint](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/schedule_profile.yaml). Select the main profile switch and your Schedule helper.
|
||||
|
||||
Create a [Schedule helper](https://www.home-assistant.io/integrations/schedule/) named `Adaptive Lighting Profile`. Add time blocks with Additional data like this:
|
||||
|
||||
```yaml
|
||||
|
|
@ -135,6 +291,8 @@ This creates step changes at block boundaries. It does not interpolate between s
|
|||
<details markdown="1">
|
||||
<summary>Reduce daytime brightness when an illuminance sensor detects strong daylight.</summary>
|
||||
|
||||
Also available as a [blueprint](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/daylight_limit.yaml). Select the profile and sensor, then set the lux thresholds and brightness limits. The high lux threshold must exceed the low threshold; the blueprint does nothing if they are reversed or equal.
|
||||
|
||||
Keep a low configured `min_brightness` for late night and let an automation lower `max_brightness` while the room has ample daylight. Use a sensor that is not significantly affected by the controlled lights to avoid a feedback loop.
|
||||
|
||||
```yaml
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ All configuration options are listed below with their default values. These opti
|
|||
| `autoreset_control_seconds` | Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️ | `0` | `int` 0-31536000 |
|
||||
| `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄 | `False` | `bool` |
|
||||
| `adapt_only_on_bare_turn_on` | When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene and marks the light as manually controlled. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. 🕵️ | `False` | `bool` |
|
||||
| `manual_control_on_external_turn_on` | Treat turn-ons without a matching Home Assistant `light.turn_on` context as manual control. Normal manual-control resets apply. Still allows `detect_non_ha_changes` for already-on lights. Needs `take_over_control` enabled. 🕵️ | `False` | `bool` |
|
||||
| `reset_manual_control_on_sleep_mode_change` | Reset manual control when the sleep mode switch is toggled. Set to `false` to preserve manual control across sleep mode changes. 😴 | `True` | `bool` |
|
||||
| `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀 | `False` | `bool` |
|
||||
| `send_split_delay` | Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️ | `0` | `int` 0-10000 |
|
||||
|
|
@ -74,6 +75,7 @@ All configuration options are listed below with their default values. These opti
|
|||
| `intercept` | Intercept and adapt `light.turn_on` calls to enabling instantaneous color and brightness adaptation. 🏎️ Disable for lights that do not support `light.turn_on` with color and brightness. | `True` | `bool` |
|
||||
| `multi_light_intercept` | Intercept and adapt `light.turn_on` calls that target multiple lights. ➗⚠️ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches. Requires `intercept` to be enabled. | `True` | `bool` |
|
||||
| `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 | `False` | `bool` |
|
||||
| `expand_light_groups` | Expand light groups to their members (`true`, default). Set `false` to send commands to the group and track manual control for the group. Explicit member targets in services stay individual targets. | `True` | `bool` |
|
||||
|
||||
<!-- OUTPUT:END -->
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,16 @@ logger:
|
|||
|
||||
After the issue occurs, create a new issue report with the log (`/config/home-assistant.log`).
|
||||
|
||||
For support, use Home Assistant's **Download diagnostics** action on the
|
||||
Adaptive Lighting config entry. The download is an on-demand snapshot of the
|
||||
profile's current switches and currently tracked light targets. It does not
|
||||
refresh group membership or predict targets a disabled profile would use after
|
||||
being enabled. It does not create live sensors; existing switch attributes
|
||||
remain the interface for automations.
|
||||
The reported last adaptation values are the shared manager's latest retained
|
||||
value for each attribute. They can come from different commands and do not
|
||||
represent one sent command or the current desired state.
|
||||
|
||||
<!-- OUTPUT:END -->
|
||||
|
||||
## Common Problems & Solutions
|
||||
|
|
@ -35,6 +45,14 @@ After the issue occurs, create a new issue report with the log (`/config/home-as
|
|||
<!-- 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:
|
||||
|
|
@ -49,6 +67,8 @@ Addressing these issues will significantly improve your Home Assistant experienc
|
|||
In case lights are suddenly turning on by themselves, this is most likely due to the light incorrectly reporting an "on" state to Home Assistant, leading to an undesired Adaptive Lighting action.
|
||||
To prevent adapting in cases *where the state of the light is suddenly "on" and only adapt if there is an associated `light.turn_on` service call*, set `detect_non_ha_changes: false`.
|
||||
|
||||
To keep detecting manual changes to lights that are already on while leaving unmatched `off` to `on` state events unchanged, enable `manual_control_on_external_turn_on`. Matching uses the exact context of the most recently recorded `light.turn_on` call. Some integrations replace or omit that context, so Adaptive Lighting cannot distinguish every physical versus Home Assistant turn-on source.
|
||||
|
||||
#### :signal_strength: WiFi Networks
|
||||
|
||||
Ensure your light bulbs have a strong WiFi connection. If the signal strength is less than -70dBm, the connection may be weak and prone to dropping messages.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "adaptive-lighting"
|
||||
version = "1.30.1"
|
||||
version = "1.32.0"
|
||||
description = "Automatically adjust brightness and color of lights based on the sun position"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
|
|
|||
|
|
@ -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 -
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import re
|
||||
import shutil
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
|
@ -16,6 +17,10 @@ from homeassistant.components.adaptive_lighting.adaptation_utils import (
|
|||
LightControlAttributes,
|
||||
)
|
||||
from homeassistant.components.adaptive_lighting.const import (
|
||||
CONF_AUTORESET_CONTROL,
|
||||
CONF_BRIGHTNESS_MODE,
|
||||
CONF_BRIGHTNESS_MODE_TIME_DARK,
|
||||
CONF_BRIGHTNESS_MODE_TIME_LIGHT,
|
||||
CONF_INITIAL_TRANSITION,
|
||||
CONF_LIGHTS,
|
||||
CONF_MAX_BRIGHTNESS,
|
||||
|
|
@ -28,9 +33,11 @@ from homeassistant.components.adaptive_lighting.const import (
|
|||
CONF_SLEEP_RGB_OR_COLOR_TEMP,
|
||||
CONF_SUNRISE_TIME,
|
||||
CONF_SUNSET_TIME,
|
||||
CONF_TAKE_OVER_CONTROL_MODE,
|
||||
CONF_TRANSITION,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.components.blueprint.models import Blueprint
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
|
|
@ -52,6 +59,7 @@ from homeassistant.const import (
|
|||
from homeassistant.core import CoreState, Event, HomeAssistant, State, callback
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util import yaml as yaml_util
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
|
|
@ -146,12 +154,543 @@ def _prepare_hass_startup(hass: HomeAssistant) -> None:
|
|||
hass.set_state(CoreState.not_running)
|
||||
|
||||
|
||||
def _blueprint_config(hass, tmp_path, filename, inputs, alias):
|
||||
"""Install an actual published blueprint for Home Assistant to load."""
|
||||
relative_path = f"adaptive_lighting/{filename}"
|
||||
hass.config.config_dir = str(tmp_path)
|
||||
destination = tmp_path / "blueprints" / "automation" / relative_path
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(README.parent / "blueprints" / "automation" / filename, destination)
|
||||
return {
|
||||
"alias": alias,
|
||||
"use_blueprint": {"path": relative_path, "input": inputs},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(params=["yaml", "blueprint"])
|
||||
def published_automation(hass: HomeAssistant, tmp_path: Path, request):
|
||||
"""Use the published YAML or blueprint with the same behavioral assertions."""
|
||||
|
||||
def config(summary, filename, inputs):
|
||||
yaml_config = _yaml_documents(summary)[-1]
|
||||
if request.param == "yaml":
|
||||
return yaml_config
|
||||
return _blueprint_config(
|
||||
hass,
|
||||
tmp_path,
|
||||
filename,
|
||||
inputs,
|
||||
yaml_config[0]["alias"],
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
sorted((README.parent / "blueprints" / "automation").glob("*.yaml")),
|
||||
ids=lambda path: path.stem,
|
||||
)
|
||||
def test_published_blueprint_schema(path: Path) -> None:
|
||||
"""Validate every published blueprint with Home Assistant's own schema."""
|
||||
blueprint = Blueprint(
|
||||
yaml_util.load_yaml(str(path)),
|
||||
expected_domain=automation.DOMAIN,
|
||||
schema=automation.config.AUTOMATION_BLUEPRINT_SCHEMA,
|
||||
)
|
||||
assert blueprint.validate() is None
|
||||
|
||||
|
||||
@pytest.fixture(params=["yaml", "blueprint", "blueprint-custom-minimum"])
|
||||
def minimum_automation_config(hass: HomeAssistant, tmp_path: Path, request):
|
||||
"""Run the same behavior checks against both published formats."""
|
||||
if request.param == "yaml":
|
||||
return _yaml_documents(
|
||||
"Turn a light off when its adaptive brightness target reaches the minimum.",
|
||||
)[0]
|
||||
inputs = {
|
||||
"adaptive_switch": "switch.adaptive_lighting_living_room",
|
||||
"brightness_switch": "switch.adaptive_lighting_living_room_adapt_brightness",
|
||||
"light_entity": "light.living_room",
|
||||
}
|
||||
if request.param == "blueprint-custom-minimum":
|
||||
inputs["minimum_pct"] = 10
|
||||
return _blueprint_config(
|
||||
hass,
|
||||
tmp_path,
|
||||
"turn_off_at_minimum.yaml",
|
||||
inputs,
|
||||
"Turn off at minimum",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("manual_control", [False, True])
|
||||
@pytest.mark.parametrize("trigger_kind", ["interval", "sleep"])
|
||||
@patch(
|
||||
"homeassistant.components.adaptive_lighting.color_and_brightness.utcnow",
|
||||
new=dt_util.utcnow,
|
||||
)
|
||||
async def test_minimum_brightness_power_automation(
|
||||
hass: HomeAssistant,
|
||||
freezer,
|
||||
manual_control: bool,
|
||||
trigger_kind: str,
|
||||
minimum_automation_config,
|
||||
) -> None:
|
||||
"""Catch exact-float comparisons, repeated power actions, or lost manual control."""
|
||||
minimum = (
|
||||
minimum_automation_config.get("use_blueprint", {})
|
||||
.get("input", {})
|
||||
.get("minimum_pct", 1)
|
||||
if isinstance(minimum_automation_config, dict)
|
||||
else 1
|
||||
)
|
||||
freezer.move_to(datetime(2026, 9, 6, 18, 58, tzinfo=dt_util.DEFAULT_TIME_ZONE))
|
||||
await _setup_template_lights(hass, ["Living Room"])
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "light.living_room", ATTR_BRIGHTNESS: 77},
|
||||
blocking=True,
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_NAME: "Living Room",
|
||||
CONF_LIGHTS: ["light.living_room"],
|
||||
CONF_MIN_BRIGHTNESS: minimum,
|
||||
CONF_MAX_BRIGHTNESS: 100,
|
||||
CONF_BRIGHTNESS_MODE: "linear",
|
||||
CONF_BRIGHTNESS_MODE_TIME_DARK: timedelta(hours=1),
|
||||
CONF_BRIGHTNESS_MODE_TIME_LIGHT: timedelta(hours=1),
|
||||
CONF_SUNRISE_TIME: "06:00:00",
|
||||
CONF_SUNSET_TIME: "18:00:00",
|
||||
CONF_TRANSITION: 0,
|
||||
CONF_INITIAL_TRANSITION: 0,
|
||||
},
|
||||
)
|
||||
if manual_control:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"set_manual_control",
|
||||
{ATTR_ENTITY_ID: adaptive_switch.entity_id, "manual_control": True},
|
||||
blocking=True,
|
||||
)
|
||||
await _setup_automation(hass, minimum_automation_config)
|
||||
off_calls = []
|
||||
|
||||
@callback
|
||||
def record_off(event: Event) -> None:
|
||||
if (
|
||||
event.data["domain"] == LIGHT_DOMAIN
|
||||
and event.data["service"] == SERVICE_TURN_OFF
|
||||
):
|
||||
off_calls.append(event.data["service_data"])
|
||||
|
||||
hass.bus.async_listen(EVENT_CALL_SERVICE, record_off)
|
||||
assert hass.states.get("light.living_room").state == STATE_ON
|
||||
assert adaptive_switch.extra_state_attributes["brightness_pct"] > minimum + 1
|
||||
|
||||
# The curve is above the minimum, but rounds to the same brightness command.
|
||||
freezer.move_to(datetime(2026, 9, 6, 18, 59, 50, tzinfo=dt_util.DEFAULT_TIME_ZONE))
|
||||
if trigger_kind == "sleep":
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "switch.adaptive_lighting_living_room_sleep_mode"},
|
||||
blocking=True,
|
||||
)
|
||||
else:
|
||||
await adaptive_switch._async_update_at_interval_action()
|
||||
await hass.async_block_till_done()
|
||||
if trigger_kind == "sleep":
|
||||
assert adaptive_switch.extra_state_attributes["brightness_pct"] == 1
|
||||
else:
|
||||
assert (
|
||||
minimum
|
||||
< adaptive_switch.extra_state_attributes["brightness_pct"]
|
||||
< minimum + 0.2
|
||||
)
|
||||
# The default sleep-mode policy clears manual control before publishing its target.
|
||||
should_turn_off = not manual_control or trigger_kind == "sleep"
|
||||
assert hass.states.get("light.living_room").state == (
|
||||
STATE_OFF if should_turn_off else STATE_ON
|
||||
)
|
||||
assert len(off_calls) == int(should_turn_off)
|
||||
|
||||
# Further target changes inside the minimum command range do not retrigger.
|
||||
freezer.move_to(datetime(2026, 9, 6, 19, 1, tzinfo=dt_util.DEFAULT_TIME_ZONE))
|
||||
await adaptive_switch._async_update_at_interval_action()
|
||||
await hass.async_block_till_done()
|
||||
assert adaptive_switch.extra_state_attributes["brightness_pct"] == (
|
||||
1 if trigger_kind == "sleep" else minimum
|
||||
)
|
||||
assert len(off_calls) == int(should_turn_off)
|
||||
|
||||
if should_turn_off:
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "light.living_room"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("light.living_room").state == STATE_ON
|
||||
assert len(off_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("previous", [None, "unknown", "unavailable"])
|
||||
async def test_minimum_brightness_ignores_missing_previous_target(
|
||||
hass: HomeAssistant,
|
||||
previous: str | None,
|
||||
minimum_automation_config,
|
||||
) -> None:
|
||||
"""A missing target must not become a numeric crossing during recovery."""
|
||||
await _setup_template_lights(hass, ["Living Room"])
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "light.living_room"},
|
||||
blocking=True,
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_NAME: "Living Room",
|
||||
CONF_LIGHTS: ["light.living_room"],
|
||||
CONF_MIN_BRIGHTNESS: 1,
|
||||
CONF_MAX_BRIGHTNESS: 1,
|
||||
CONF_TRANSITION: 0,
|
||||
CONF_INITIAL_TRANSITION: 0,
|
||||
},
|
||||
)
|
||||
await _setup_automation(hass, minimum_automation_config)
|
||||
attributes = dict(hass.states.get(adaptive_switch.entity_id).attributes)
|
||||
assert attributes["brightness_pct"] == 1
|
||||
if previous is None:
|
||||
hass.states.async_remove(adaptive_switch.entity_id)
|
||||
else:
|
||||
hass.states.async_set(
|
||||
adaptive_switch.entity_id,
|
||||
previous,
|
||||
{**attributes, "brightness_pct": previous},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(adaptive_switch.entity_id, STATE_ON, attributes)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("light.living_room").state == STATE_ON
|
||||
|
||||
|
||||
@pytest.mark.parametrize("previous_manual", [None, "color", "brightness"])
|
||||
@patch(
|
||||
"homeassistant.components.adaptive_lighting.color_and_brightness.utcnow",
|
||||
new=dt_util.utcnow,
|
||||
)
|
||||
async def test_minimum_manual_control_lifecycle(
|
||||
hass: HomeAssistant,
|
||||
freezer,
|
||||
published_automation,
|
||||
previous_manual: str | None,
|
||||
) -> None:
|
||||
"""Pause at the calculated floor, preserve other flags, and reset on off/on."""
|
||||
freezer.move_to(datetime(2026, 9, 6, 18, 58, tzinfo=dt_util.DEFAULT_TIME_ZONE))
|
||||
config = published_automation(
|
||||
"Pause brightness at the minimum using manual control.",
|
||||
"manual_control_at_minimum.yaml",
|
||||
{
|
||||
"adaptive_switch": "switch.adaptive_lighting_living_room",
|
||||
"brightness_switch": "switch.adaptive_lighting_living_room_adapt_brightness",
|
||||
"light_entity": "light.living_room",
|
||||
},
|
||||
)
|
||||
await _setup_template_lights(hass, ["Living Room"])
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "light.living_room", ATTR_BRIGHTNESS: 77},
|
||||
blocking=True,
|
||||
)
|
||||
_, profile = await setup_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_NAME: "Living Room",
|
||||
CONF_LIGHTS: ["light.living_room"],
|
||||
CONF_MIN_BRIGHTNESS: 1,
|
||||
CONF_MAX_BRIGHTNESS: 100,
|
||||
CONF_MIN_COLOR_TEMP: 3000,
|
||||
CONF_MAX_COLOR_TEMP: 3000,
|
||||
CONF_BRIGHTNESS_MODE: "linear",
|
||||
CONF_BRIGHTNESS_MODE_TIME_DARK: timedelta(hours=1),
|
||||
CONF_BRIGHTNESS_MODE_TIME_LIGHT: timedelta(hours=1),
|
||||
CONF_SUNRISE_TIME: "06:00:00",
|
||||
CONF_SUNSET_TIME: "18:00:00",
|
||||
CONF_TRANSITION: 0,
|
||||
CONF_INITIAL_TRANSITION: 0,
|
||||
CONF_TAKE_OVER_CONTROL_MODE: "pause_changed",
|
||||
},
|
||||
)
|
||||
if previous_manual:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"set_manual_control",
|
||||
{ATTR_ENTITY_ID: profile.entity_id, "manual_control": previous_manual},
|
||||
blocking=True,
|
||||
)
|
||||
await _setup_automation(hass, config)
|
||||
manual_calls = []
|
||||
|
||||
@callback
|
||||
def record_manual_call(event: Event) -> None:
|
||||
if (
|
||||
event.data["domain"] == DOMAIN
|
||||
and event.data["service"] == "set_manual_control"
|
||||
):
|
||||
manual_calls.append(event.data["service_data"])
|
||||
|
||||
hass.bus.async_listen(EVENT_CALL_SERVICE, record_manual_call)
|
||||
freezer.move_to(datetime(2026, 9, 6, 18, 59, 50, tzinfo=dt_util.DEFAULT_TIME_ZONE))
|
||||
await profile._async_update_at_interval_action()
|
||||
await hass.async_block_till_done()
|
||||
flags = profile.manager.get_manual_control_attributes("light.living_room")
|
||||
assert LightControlAttributes.BRIGHTNESS in flags
|
||||
assert (LightControlAttributes.COLOR in flags) is (previous_manual == "color")
|
||||
assert len(manual_calls) == (0 if previous_manual == "brightness" else 1)
|
||||
paused_brightness = hass.states.get("light.living_room").attributes[ATTR_BRIGHTNESS]
|
||||
assert profile.is_on
|
||||
assert profile.adapt_brightness_switch.is_on
|
||||
if previous_manual != "brightness":
|
||||
assert paused_brightness == 3
|
||||
|
||||
freezer.move_to(datetime(2026, 9, 6, 19, 1, tzinfo=dt_util.DEFAULT_TIME_ZONE))
|
||||
await profile._async_update_at_interval_action()
|
||||
await hass.async_block_till_done()
|
||||
assert len(manual_calls) == (0 if previous_manual == "brightness" else 1)
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"change_switch_settings",
|
||||
{
|
||||
ATTR_ENTITY_ID: profile.entity_id,
|
||||
CONF_MIN_BRIGHTNESS: 100,
|
||||
CONF_MAX_BRIGHTNESS: 100,
|
||||
CONF_MIN_COLOR_TEMP: 5000,
|
||||
CONF_MAX_COLOR_TEMP: 5000,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get("light.living_room")
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[ATTR_BRIGHTNESS] == paused_brightness
|
||||
assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == pytest.approx(
|
||||
3000 if previous_manual == "color" else 5000,
|
||||
abs=5,
|
||||
)
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: "light.living_room"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert not profile.manager.get_manual_control_attributes("light.living_room")
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "light.living_room"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("light.living_room").attributes[ATTR_BRIGHTNESS] == 255
|
||||
|
||||
# Another crossing can pause again, but clearing it at the floor must stick.
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"change_switch_settings",
|
||||
{
|
||||
ATTR_ENTITY_ID: profile.entity_id,
|
||||
CONF_MIN_BRIGHTNESS: 1,
|
||||
CONF_MAX_BRIGHTNESS: 1,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert profile.manager.get_manual_control_attributes("light.living_room")
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"set_manual_control",
|
||||
{ATTR_ENTITY_ID: profile.entity_id, "manual_control": False},
|
||||
blocking=True,
|
||||
)
|
||||
await profile._async_update_at_interval_action()
|
||||
await hass.async_block_till_done()
|
||||
assert not profile.manager.get_manual_control_attributes("light.living_room")
|
||||
|
||||
if previous_manual is None:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"change_switch_settings",
|
||||
{
|
||||
ATTR_ENTITY_ID: profile.entity_id,
|
||||
CONF_MIN_BRIGHTNESS: 50,
|
||||
CONF_MAX_BRIGHTNESS: 50,
|
||||
CONF_AUTORESET_CONTROL: 1,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"change_switch_settings",
|
||||
{
|
||||
ATTR_ENTITY_ID: profile.entity_id,
|
||||
CONF_MIN_BRIGHTNESS: 1,
|
||||
CONF_MAX_BRIGHTNESS: 1,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert profile.manager.get_manual_control_attributes("light.living_room")
|
||||
cleared, remove_listener = _state_waiter(
|
||||
hass,
|
||||
profile.entity_id,
|
||||
lambda state: state.attributes.get("manual_control_brightness") == [],
|
||||
)
|
||||
freezer.tick(timedelta(seconds=1))
|
||||
async_fire_time_changed(hass, dt_util.utcnow())
|
||||
await asyncio.wait_for(cleared, timeout=2)
|
||||
remove_listener()
|
||||
await hass.async_block_till_done()
|
||||
assert not profile.manager.get_manual_control_attributes("light.living_room")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"abort_entity",
|
||||
[
|
||||
"light.living_room",
|
||||
"switch.adaptive_lighting_living_room",
|
||||
"switch.adaptive_lighting_living_room_adapt_brightness",
|
||||
],
|
||||
)
|
||||
async def test_minimum_manual_control_aborts_when_disabled(
|
||||
hass: HomeAssistant,
|
||||
published_automation,
|
||||
abort_entity: str,
|
||||
) -> None:
|
||||
"""Abandon a pending wait immediately when the light or profile is disabled."""
|
||||
config = published_automation(
|
||||
"Pause brightness at the minimum using manual control.",
|
||||
"manual_control_at_minimum.yaml",
|
||||
{
|
||||
"adaptive_switch": "switch.adaptive_lighting_living_room",
|
||||
"brightness_switch": "switch.adaptive_lighting_living_room_adapt_brightness",
|
||||
"light_entity": "light.living_room",
|
||||
},
|
||||
)
|
||||
await _setup_template_lights(hass, ["Living Room"])
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "light.living_room", ATTR_BRIGHTNESS: 128},
|
||||
blocking=True,
|
||||
)
|
||||
_, profile = await setup_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_NAME: "Living Room",
|
||||
CONF_LIGHTS: ["light.living_room"],
|
||||
CONF_MIN_BRIGHTNESS: 50,
|
||||
CONF_MAX_BRIGHTNESS: 50,
|
||||
CONF_INITIAL_TRANSITION: 0,
|
||||
CONF_TRANSITION: 0,
|
||||
CONF_TAKE_OVER_CONTROL_MODE: "pause_changed",
|
||||
},
|
||||
)
|
||||
await _setup_automation(hass, config)
|
||||
waiting, remove_listener = _state_waiter(
|
||||
hass,
|
||||
"automation.adaptive_lighting_pause_brightness_at_minimum",
|
||||
lambda state: state.attributes.get("current") == 1,
|
||||
)
|
||||
state = hass.states.get(profile.entity_id)
|
||||
hass.states.async_set(
|
||||
profile.entity_id,
|
||||
STATE_ON,
|
||||
{**state.attributes, "brightness_pct": 1},
|
||||
)
|
||||
await asyncio.wait_for(waiting, timeout=1)
|
||||
remove_listener()
|
||||
assert not profile.manager.get_manual_control_attributes("light.living_room")
|
||||
stopped, remove_stopped = _state_waiter(
|
||||
hass,
|
||||
"automation.adaptive_lighting_pause_brightness_at_minimum",
|
||||
lambda state: state.attributes.get("current") == 0,
|
||||
)
|
||||
await hass.services.async_call(
|
||||
abort_entity.split(".", maxsplit=1)[0],
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: abort_entity},
|
||||
blocking=True,
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(stopped, timeout=1)
|
||||
finally:
|
||||
remove_stopped()
|
||||
if stopped.cancelled():
|
||||
await hass.services.async_call(
|
||||
automation.DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{
|
||||
ATTR_ENTITY_ID: "automation.adaptive_lighting_pause_brightness_at_minimum",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert not profile.manager.get_manual_control_attributes("light.living_room")
|
||||
assert (
|
||||
hass.states.get(
|
||||
"automation.adaptive_lighting_pause_brightness_at_minimum",
|
||||
).attributes["current"]
|
||||
== 0
|
||||
)
|
||||
|
||||
await hass.services.async_call(
|
||||
abort_entity.split(".", maxsplit=1)[0],
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: abort_entity},
|
||||
blocking=True,
|
||||
)
|
||||
await profile._async_update_at_interval_action()
|
||||
await hass.async_block_till_done()
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"change_switch_settings",
|
||||
{
|
||||
ATTR_ENTITY_ID: profile.entity_id,
|
||||
CONF_MIN_BRIGHTNESS: 1,
|
||||
CONF_MAX_BRIGHTNESS: 1,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
profile.manager.get_manual_control_attributes("light.living_room")
|
||||
== LightControlAttributes.BRIGHTNESS
|
||||
)
|
||||
|
||||
|
||||
async def test_schedule_profile_executes_blocks_and_restore(
|
||||
hass: HomeAssistant,
|
||||
published_automation,
|
||||
) -> None:
|
||||
"""Catch ignored attribute changes, incomplete restore, or switch coupling."""
|
||||
summary = "Use a Schedule helper as a step-based custom lighting profile."
|
||||
automation_config = _yaml_documents(summary)[-1]
|
||||
automation_config = published_automation(
|
||||
summary,
|
||||
"schedule_profile.yaml",
|
||||
{
|
||||
"adaptive_switch": "switch.adaptive_lighting_living_room",
|
||||
"schedule_entity": "schedule.adaptive_lighting_profile",
|
||||
},
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{
|
||||
|
|
@ -220,11 +759,21 @@ async def test_schedule_profile_executes_blocks_and_restore(
|
|||
assert adaptive_switch._sun_light_settings.max_color_temp == 2750
|
||||
|
||||
|
||||
async def test_schedule_profile_reapplies_at_startup(hass: HomeAssistant) -> None:
|
||||
async def test_schedule_profile_reapplies_at_startup(
|
||||
hass: HomeAssistant,
|
||||
published_automation,
|
||||
) -> None:
|
||||
"""Verify startup applies the already-active schedule block."""
|
||||
_prepare_hass_startup(hass)
|
||||
summary = "Use a Schedule helper as a step-based custom lighting profile."
|
||||
automation_config = _yaml_documents(summary)[-1]
|
||||
automation_config = published_automation(
|
||||
summary,
|
||||
"schedule_profile.yaml",
|
||||
{
|
||||
"adaptive_switch": "switch.adaptive_lighting_living_room",
|
||||
"schedule_entity": "schedule.adaptive_lighting_profile",
|
||||
},
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(hass, {CONF_NAME: "Living Room"})
|
||||
hass.states.async_set(
|
||||
"schedule.adaptive_lighting_profile",
|
||||
|
|
@ -241,12 +790,22 @@ async def test_schedule_profile_reapplies_at_startup(hass: HomeAssistant) -> Non
|
|||
assert adaptive_switch._sun_light_settings.max_color_temp == 2500
|
||||
|
||||
|
||||
async def test_lux_profile_executes_hysteresis(hass: HomeAssistant) -> None:
|
||||
async def test_lux_profile_executes_hysteresis(
|
||||
hass: HomeAssistant,
|
||||
published_automation,
|
||||
) -> None:
|
||||
"""Catch missing threshold actions or changes inside the dead band."""
|
||||
summary = (
|
||||
"Reduce daytime brightness when an illuminance sensor detects strong daylight."
|
||||
)
|
||||
automation_config = _yaml_documents(summary)[0]
|
||||
automation_config = published_automation(
|
||||
summary,
|
||||
"daylight_limit.yaml",
|
||||
{
|
||||
"adaptive_switch": "switch.adaptive_lighting_living_room",
|
||||
"illuminance_sensor": "sensor.living_room_illuminance",
|
||||
},
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{CONF_NAME: "Living Room", CONF_MAX_BRIGHTNESS: 80},
|
||||
|
|
@ -273,13 +832,21 @@ async def test_lux_profile_executes_hysteresis(hass: HomeAssistant) -> None:
|
|||
|
||||
async def test_lux_profile_executes_unknown_recovery_at_startup(
|
||||
hass: HomeAssistant,
|
||||
published_automation,
|
||||
) -> None:
|
||||
"""Catch a startup hang or failure to recover from an unknown sensor."""
|
||||
_prepare_hass_startup(hass)
|
||||
summary = (
|
||||
"Reduce daytime brightness when an illuminance sensor detects strong daylight."
|
||||
)
|
||||
automation_config = _yaml_documents(summary)[0]
|
||||
automation_config = published_automation(
|
||||
summary,
|
||||
"daylight_limit.yaml",
|
||||
{
|
||||
"adaptive_switch": "switch.adaptive_lighting_living_room",
|
||||
"illuminance_sensor": "sensor.living_room_illuminance",
|
||||
},
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{CONF_NAME: "Living Room", CONF_MAX_BRIGHTNESS: 80},
|
||||
|
|
@ -302,6 +869,44 @@ async def test_lux_profile_executes_unknown_recovery_at_startup(
|
|||
assert adaptive_switch._sun_light_settings.max_brightness == 30
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("high_lux", "low_lux"), [(400, 250), (200, 300), (200, 200)])
|
||||
async def test_daylight_blueprint_custom_inputs(
|
||||
hass: HomeAssistant,
|
||||
tmp_path: Path,
|
||||
high_lux: int,
|
||||
low_lux: int,
|
||||
) -> None:
|
||||
"""Use selected entities and limits; invalid threshold order must do nothing."""
|
||||
config = _blueprint_config(
|
||||
hass,
|
||||
tmp_path,
|
||||
"daylight_limit.yaml",
|
||||
{
|
||||
"adaptive_switch": "switch.adaptive_lighting_office",
|
||||
"illuminance_sensor": "sensor.office_illuminance",
|
||||
"high_lux": high_lux,
|
||||
"low_lux": low_lux,
|
||||
"daylight_maximum": 20,
|
||||
"normal_maximum": 70,
|
||||
},
|
||||
"Custom daylight",
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{CONF_NAME: "Office", CONF_MAX_BRIGHTNESS: 80},
|
||||
)
|
||||
hass.states.async_set("sensor.office_illuminance", "300")
|
||||
await _setup_automation(hass, config)
|
||||
assert hass.states.get("automation.custom_daylight") is not None
|
||||
valid_thresholds = high_lux > low_lux
|
||||
for lux, expected in [(500, 20), (300, 20), (100, 70)]:
|
||||
hass.states.async_set("sensor.office_illuminance", str(lux))
|
||||
await hass.async_block_till_done()
|
||||
assert adaptive_switch._sun_light_settings.max_brightness == (
|
||||
expected if valid_thresholds else 80
|
||||
)
|
||||
|
||||
|
||||
async def test_hue_script_applies_current_values_to_fresh_profile_targets(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
|
|
@ -574,13 +1179,24 @@ async def test_autoreset_manual_control_uses_one_renewable_timer(
|
|||
|
||||
async def test_sleep_toggle_uses_fresh_profile_entity_ids(
|
||||
hass: HomeAssistant,
|
||||
published_automation,
|
||||
) -> None:
|
||||
"""Execute state triggers against fresh child entity IDs."""
|
||||
summary = (
|
||||
'Toggle multiple Adaptive Lighting switches to "sleep mode" using an '
|
||||
"<code>input_boolean.sleep_mode</code>."
|
||||
)
|
||||
automation_config = _yaml_documents(summary)[0]
|
||||
automation_config = published_automation(
|
||||
summary,
|
||||
"sleep_mode.yaml",
|
||||
{
|
||||
"sleep_helper": "input_boolean.sleep_mode",
|
||||
"sleep_switches": [
|
||||
"switch.adaptive_lighting_living_room_sleep_mode",
|
||||
"switch.adaptive_lighting_bedroom_sleep_mode",
|
||||
],
|
||||
},
|
||||
)
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"input_boolean",
|
||||
|
|
@ -621,8 +1237,60 @@ async def test_sleep_toggle_uses_fresh_profile_entity_ids(
|
|||
assert state.state == STATE_OFF
|
||||
|
||||
|
||||
async def test_sleep_toggle_tracks_rapid_helper_changes(
|
||||
hass: HomeAssistant,
|
||||
published_automation,
|
||||
) -> None:
|
||||
"""Keep every sleep switch synchronized when the helper changes rapidly."""
|
||||
summary = (
|
||||
'Toggle multiple Adaptive Lighting switches to "sleep mode" using an '
|
||||
"<code>input_boolean.sleep_mode</code>."
|
||||
)
|
||||
sleep_switches = (
|
||||
"switch.adaptive_lighting_living_room_sleep_mode",
|
||||
"switch.adaptive_lighting_bedroom_sleep_mode",
|
||||
)
|
||||
automation_config = published_automation(
|
||||
summary,
|
||||
"sleep_mode.yaml",
|
||||
{
|
||||
"sleep_helper": "input_boolean.sleep_mode",
|
||||
"sleep_switches": list(sleep_switches),
|
||||
},
|
||||
)
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"input_boolean",
|
||||
{"input_boolean": {"sleep_mode": {}}},
|
||||
)
|
||||
await setup_switch(hass, {CONF_NAME: "Living Room"})
|
||||
await setup_switch(hass, {CONF_NAME: "Bedroom"})
|
||||
await _setup_automation(hass, automation_config)
|
||||
|
||||
for _ in range(10):
|
||||
await hass.services.async_call(
|
||||
"input_boolean",
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "input_boolean.sleep_mode"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.services.async_call(
|
||||
"input_boolean",
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: "input_boolean.sleep_mode"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for entity_id in sleep_switches:
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
|
||||
async def test_sleep_toggle_applies_restored_state_at_startup(
|
||||
hass: HomeAssistant,
|
||||
published_automation,
|
||||
) -> None:
|
||||
"""Verify startup applies the input boolean's restored state."""
|
||||
_prepare_hass_startup(hass)
|
||||
|
|
@ -630,13 +1298,24 @@ async def test_sleep_toggle_applies_restored_state_at_startup(
|
|||
'Toggle multiple Adaptive Lighting switches to "sleep mode" using an '
|
||||
"<code>input_boolean.sleep_mode</code>."
|
||||
)
|
||||
automation_config = _yaml_documents(summary)[0]
|
||||
automation_config = published_automation(
|
||||
summary,
|
||||
"sleep_mode.yaml",
|
||||
{
|
||||
"sleep_helper": "input_boolean.sleep_mode",
|
||||
"sleep_switches": [
|
||||
"switch.adaptive_lighting_living_room_sleep_mode",
|
||||
"switch.adaptive_lighting_bedroom_sleep_mode",
|
||||
],
|
||||
},
|
||||
)
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"input_boolean",
|
||||
{"input_boolean": {"sleep_mode": {}}},
|
||||
)
|
||||
await setup_switch(hass, {CONF_NAME: "Living Room"})
|
||||
await setup_switch(hass, {CONF_NAME: "Bedroom"})
|
||||
await hass.services.async_call(
|
||||
"input_boolean",
|
||||
SERVICE_TURN_ON,
|
||||
|
|
|
|||
|
|
@ -11,9 +11,12 @@ except ImportError:
|
|||
from voluptuous_serialize import convert as to_field_list
|
||||
from homeassistant.components.adaptive_lighting.const import (
|
||||
BASIC_OPTIONS,
|
||||
CONF_EXPAND_LIGHT_GROUPS,
|
||||
CONF_INITIAL_TRANSITION,
|
||||
CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON,
|
||||
CONF_SUNRISE_TIME,
|
||||
CONF_SUNSET_TIME,
|
||||
DEFAULT_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
NONE_STR,
|
||||
|
|
@ -104,6 +107,7 @@ async def test_options(hass):
|
|||
# Build input with advanced options nested in "advanced" section
|
||||
advanced_data = ADVANCED_DATA.copy()
|
||||
advanced_data[CONF_INITIAL_TRANSITION] = 23
|
||||
advanced_data[CONF_EXPAND_LIGHT_GROUPS] = False
|
||||
advanced_data[CONF_SUNRISE_TIME] = NONE_STR
|
||||
advanced_data[CONF_SUNSET_TIME] = NONE_STR
|
||||
basic_data = {**BASIC_DATA, "min_brightness": 12}
|
||||
|
|
@ -149,6 +153,10 @@ async def test_options_schema_has_each_setting_once(hass):
|
|||
advanced = _advanced_section(result)
|
||||
|
||||
assert advanced.options == {"collapsed": True}
|
||||
assert (
|
||||
_schema_defaults(advanced.schema)[CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON]
|
||||
is DEFAULT_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON
|
||||
)
|
||||
assert {key.schema for key in schema if key.schema != "advanced"} == BASIC_OPTIONS
|
||||
assert {key.schema for key in advanced.schema.schema} == set(
|
||||
DEFAULT_DATA,
|
||||
|
|
|
|||
372
tests/test_diagnostics.py
Normal file
372
tests/test_diagnostics.py
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
"""Tests for Adaptive Lighting diagnostics."""
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from homeassistant.components.adaptive_lighting.adaptation_utils import (
|
||||
AdaptationData,
|
||||
LightControlAttributes,
|
||||
_create_service_call_data_iterator,
|
||||
)
|
||||
from homeassistant.components.adaptive_lighting.const import (
|
||||
ATTR_ADAPTIVE_LIGHTING_MANAGER,
|
||||
CONF_AUTORESET_CONTROL,
|
||||
CONF_INTERCEPT,
|
||||
CONF_MANUAL_CONTROL,
|
||||
DOMAIN,
|
||||
SERVICE_SET_MANUAL_CONTROL,
|
||||
)
|
||||
from homeassistant.components.adaptive_lighting.diagnostics import (
|
||||
async_get_config_entry_diagnostics,
|
||||
)
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_RGB_COLOR,
|
||||
ATTR_TRANSITION,
|
||||
SERVICE_TURN_ON,
|
||||
)
|
||||
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SERVICE_DATA,
|
||||
CONF_LIGHTS,
|
||||
CONF_NAME,
|
||||
EVENT_CALL_SERVICE,
|
||||
EVENT_STATE_CHANGED,
|
||||
STATE_OFF,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import State
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.diagnostics import get_diagnostics_for_config_entry
|
||||
|
||||
from .test_switch import (
|
||||
ENTITY_LIGHT_1,
|
||||
ENTITY_LIGHT_2,
|
||||
ENTITY_LIGHT_3,
|
||||
setup_lights,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def cleanup_diagnostics(hass):
|
||||
"""Cancel integration tasks created by diagnostics fixtures."""
|
||||
yield
|
||||
manager = hass.data.get(DOMAIN, {}).get(ATTR_ADAPTIVE_LIGHTING_MANAGER)
|
||||
if manager is None:
|
||||
return
|
||||
for timer in manager.auto_reset_manual_control_timers.values():
|
||||
timer.cancel()
|
||||
for timer in manager.transition_timers.values():
|
||||
timer.cancel()
|
||||
for task in manager.adaptation_tasks:
|
||||
task.cancel()
|
||||
|
||||
|
||||
async def _setup_entry(hass, name, lights, **data):
|
||||
"""Set up a real Adaptive Lighting config entry."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_NAME: name,
|
||||
CONF_LIGHTS: lights,
|
||||
CONF_INTERCEPT: False,
|
||||
**data,
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
return entry, hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN]
|
||||
|
||||
|
||||
async def test_config_entry_diagnostics_reports_allowlisted_current_facts(
|
||||
hass,
|
||||
hass_client,
|
||||
cleanup_diagnostics,
|
||||
):
|
||||
"""Diagnostics report current selected-profile facts without identifiers."""
|
||||
await setup_lights(hass)
|
||||
entry, switch = await _setup_entry(
|
||||
hass,
|
||||
"Private Upstairs Profile",
|
||||
[ENTITY_LIGHT_1, ENTITY_LIGHT_2],
|
||||
**{CONF_AUTORESET_CONTROL: 60},
|
||||
)
|
||||
other_entry, _ = await _setup_entry(
|
||||
hass,
|
||||
"Private Basement Profile",
|
||||
[ENTITY_LIGHT_3],
|
||||
)
|
||||
|
||||
await switch.adapt_color_switch.async_turn_off()
|
||||
await switch.sleep_mode_switch.async_turn_on()
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_SET_MANUAL_CONTROL,
|
||||
{
|
||||
ATTR_ENTITY_ID: switch.entity_id,
|
||||
CONF_LIGHTS: [ENTITY_LIGHT_1],
|
||||
CONF_MANUAL_CONTROL: "brightness",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_SET_MANUAL_CONTROL,
|
||||
{
|
||||
ATTR_ENTITY_ID: switch.entity_id,
|
||||
CONF_LIGHTS: [ENTITY_LIGHT_2],
|
||||
CONF_MANUAL_CONTROL: "color",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
hass.states.async_set(
|
||||
ENTITY_LIGHT_2,
|
||||
STATE_UNAVAILABLE,
|
||||
{"friendly_name": "Private Bedside Lamp", "room": "Private Bedroom"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
manager = hass.data[DOMAIN][ATTR_ADAPTIVE_LIGHTING_MANAGER]
|
||||
assert manager.get_manual_control_attributes(ENTITY_LIGHT_1) == (
|
||||
LightControlAttributes.BRIGHTNESS
|
||||
)
|
||||
manager.last_service_data[ENTITY_LIGHT_1] = {
|
||||
ATTR_ENTITY_ID: ENTITY_LIGHT_1,
|
||||
ATTR_BRIGHTNESS: 123,
|
||||
ATTR_COLOR_TEMP_KELVIN: 3456,
|
||||
ATTR_RGB_COLOR: (12, 34, 56),
|
||||
ATTR_TRANSITION: 4.5,
|
||||
"context_id": "private-context-id",
|
||||
"friendly_name": "Private Bedside Lamp",
|
||||
}
|
||||
manager.last_service_data.pop(ENTITY_LIGHT_2, None)
|
||||
|
||||
result = await get_diagnostics_for_config_entry(hass, hass_client, entry)
|
||||
|
||||
assert result["loaded"] is True
|
||||
assert result["profile_switches"] == {
|
||||
"profile": True,
|
||||
"adapt_brightness": True,
|
||||
"adapt_color": False,
|
||||
"sleep_mode": True,
|
||||
}
|
||||
assert result["manager_fact_scope"] == "global_shared_across_profiles"
|
||||
assert list(result["lights"]) == ["light_1", "light_2"]
|
||||
assert result["lights"]["light_1"]["state"] == "on"
|
||||
assert result["lights"]["light_1"]["global_manager_manual_control"] == {
|
||||
"brightness": True,
|
||||
"color": False,
|
||||
}
|
||||
assert result["lights"]["light_1"][
|
||||
"global_manager_autoreset_seconds"
|
||||
] == pytest.approx(60, abs=2)
|
||||
assert result["lights"]["light_1"]["global_manager_last_adaptation_values"] == {
|
||||
ATTR_BRIGHTNESS: 123,
|
||||
ATTR_COLOR_TEMP_KELVIN: 3456,
|
||||
ATTR_RGB_COLOR: [12, 34, 56],
|
||||
ATTR_TRANSITION: 4.5,
|
||||
}
|
||||
assert result["lights"]["light_2"] == {
|
||||
"state": STATE_UNAVAILABLE,
|
||||
"global_manager_manual_control": {
|
||||
"brightness": False,
|
||||
"color": True,
|
||||
},
|
||||
"global_manager_autoreset_seconds": pytest.approx(60, abs=2),
|
||||
"global_manager_last_adaptation_values": None,
|
||||
}
|
||||
|
||||
serialized = json.dumps(result, sort_keys=True)
|
||||
for sensitive_value in (
|
||||
entry.entry_id,
|
||||
other_entry.entry_id,
|
||||
ENTITY_LIGHT_1,
|
||||
ENTITY_LIGHT_2,
|
||||
ENTITY_LIGHT_3,
|
||||
"Private Upstairs Profile",
|
||||
"Private Basement Profile",
|
||||
"Private Bedside Lamp",
|
||||
"Private Bedroom",
|
||||
"private-context-id",
|
||||
):
|
||||
assert sensitive_value not in serialized
|
||||
|
||||
|
||||
async def test_diagnostics_labels_accumulated_partial_adaptation_values(
|
||||
hass,
|
||||
cleanup_diagnostics,
|
||||
):
|
||||
"""Diagnostics do not describe merged per-attribute history as one command."""
|
||||
await setup_lights(hass)
|
||||
entry, switch = await _setup_entry(
|
||||
hass,
|
||||
"Private Profile",
|
||||
[ENTITY_LIGHT_1],
|
||||
)
|
||||
commands = [
|
||||
{
|
||||
ATTR_ENTITY_ID: ENTITY_LIGHT_1,
|
||||
ATTR_BRIGHTNESS: 100,
|
||||
ATTR_RGB_COLOR: (12, 34, 56),
|
||||
ATTR_TRANSITION: 2,
|
||||
},
|
||||
{ATTR_ENTITY_ID: ENTITY_LIGHT_1, ATTR_BRIGHTNESS: 180},
|
||||
{ATTR_ENTITY_ID: ENTITY_LIGHT_1, ATTR_COLOR_TEMP_KELVIN: 3500},
|
||||
]
|
||||
call_events = []
|
||||
remove_listener = hass.bus.async_listen(EVENT_CALL_SERVICE, call_events.append)
|
||||
|
||||
await switch._execute_adaptation_calls(
|
||||
AdaptationData(
|
||||
entity_id=ENTITY_LIGHT_1,
|
||||
context=switch.create_context("diagnostics_test"),
|
||||
sleep_time=0,
|
||||
service_call_datas=_create_service_call_data_iterator(
|
||||
hass,
|
||||
commands,
|
||||
filter_by_state=False,
|
||||
),
|
||||
force=True,
|
||||
max_length=len(commands),
|
||||
attributes=LightControlAttributes.ALL,
|
||||
),
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
remove_listener()
|
||||
|
||||
actual_commands = [
|
||||
event.data[ATTR_SERVICE_DATA]
|
||||
for event in call_events
|
||||
if event.data["domain"] == LIGHT_DOMAIN
|
||||
and event.data["service"] == SERVICE_TURN_ON
|
||||
]
|
||||
assert actual_commands == commands
|
||||
|
||||
result = await async_get_config_entry_diagnostics(hass, entry)
|
||||
light = result["lights"]["light_1"]
|
||||
assert "global_manager_last_sent_target" not in light
|
||||
assert light["global_manager_last_adaptation_values"] == {
|
||||
ATTR_BRIGHTNESS: 180,
|
||||
ATTR_COLOR_TEMP_KELVIN: 3500,
|
||||
ATTR_RGB_COLOR: [12, 34, 56],
|
||||
ATTR_TRANSITION: 2,
|
||||
}
|
||||
|
||||
|
||||
async def test_diagnostics_preserves_restored_off_profile_tracked_group(hass):
|
||||
"""Diagnostics report tracked targets without refreshing late groups."""
|
||||
await setup_lights(hass)
|
||||
group = "light.private_late_group"
|
||||
members = [ENTITY_LIGHT_1, ENTITY_LIGHT_2]
|
||||
with patch(
|
||||
"homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state",
|
||||
return_value=State("switch.restored", STATE_OFF),
|
||||
):
|
||||
entry, switch = await _setup_entry(
|
||||
hass,
|
||||
"Private Restored Profile",
|
||||
[group],
|
||||
)
|
||||
assert not switch.is_on
|
||||
assert switch.lights == [group]
|
||||
|
||||
hass.states.async_set(
|
||||
group,
|
||||
STATE_UNAVAILABLE,
|
||||
{ATTR_ENTITY_ID: members, "friendly_name": "Private Late Group"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
manager = hass.data[DOMAIN][ATTR_ADAPTIVE_LIGHTING_MANAGER]
|
||||
manager_lights_before = set(manager.lights)
|
||||
reset_times_before = dict(manager.auto_reset_manual_control_times)
|
||||
|
||||
result = await async_get_config_entry_diagnostics(hass, entry)
|
||||
|
||||
assert result["lights"] == {
|
||||
"light_1": {
|
||||
"state": STATE_UNAVAILABLE,
|
||||
"global_manager_manual_control": {
|
||||
"brightness": False,
|
||||
"color": False,
|
||||
},
|
||||
"global_manager_autoreset_seconds": None,
|
||||
"global_manager_last_adaptation_values": None,
|
||||
},
|
||||
}
|
||||
assert switch.lights == [group]
|
||||
assert manager.lights == manager_lights_before
|
||||
assert manager.auto_reset_manual_control_times == reset_times_before
|
||||
assert group not in json.dumps(result)
|
||||
|
||||
|
||||
async def test_diagnostics_handles_missing_states_and_unload_without_side_effects(
|
||||
hass,
|
||||
):
|
||||
"""Diagnostics normalize states and never change live integration state."""
|
||||
await setup_lights(hass)
|
||||
entry, switch = await _setup_entry(
|
||||
hass,
|
||||
"Private Profile",
|
||||
[ENTITY_LIGHT_1, ENTITY_LIGHT_2, ENTITY_LIGHT_3, "light.private_missing"],
|
||||
)
|
||||
hass.states.async_set(ENTITY_LIGHT_2, STATE_OFF)
|
||||
hass.states.async_set(ENTITY_LIGHT_3, STATE_UNAVAILABLE)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
manager = hass.data[DOMAIN][ATTR_ADAPTIVE_LIGHTING_MANAGER]
|
||||
manual_control_before = dict(manager.manual_control)
|
||||
last_service_data_before = deepcopy(manager.last_service_data)
|
||||
timers_before = dict(manager.auto_reset_manual_control_timers)
|
||||
switch_states_before = (
|
||||
switch.is_on,
|
||||
switch.adapt_brightness_switch.is_on,
|
||||
switch.adapt_color_switch.is_on,
|
||||
switch.sleep_mode_switch.is_on,
|
||||
)
|
||||
service_events = []
|
||||
state_events = []
|
||||
remove_service_listener = hass.bus.async_listen(
|
||||
EVENT_CALL_SERVICE,
|
||||
service_events.append,
|
||||
)
|
||||
remove_state_listener = hass.bus.async_listen(
|
||||
EVENT_STATE_CHANGED,
|
||||
state_events.append,
|
||||
)
|
||||
|
||||
result = await async_get_config_entry_diagnostics(hass, entry)
|
||||
await hass.async_block_till_done()
|
||||
remove_service_listener()
|
||||
remove_state_listener()
|
||||
|
||||
assert [light["state"] for light in result["lights"].values()] == [
|
||||
"on",
|
||||
STATE_OFF,
|
||||
STATE_UNAVAILABLE,
|
||||
"missing",
|
||||
]
|
||||
assert json.dumps(result)
|
||||
assert not service_events
|
||||
assert not state_events
|
||||
assert manager.manual_control == manual_control_before
|
||||
assert manager.last_service_data == last_service_data_before
|
||||
assert manager.auto_reset_manual_control_timers == timers_before
|
||||
assert (
|
||||
switch.is_on,
|
||||
switch.adapt_brightness_switch.is_on,
|
||||
switch.adapt_color_switch.is_on,
|
||||
switch.sleep_mode_switch.is_on,
|
||||
) == switch_states_before
|
||||
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert await async_get_config_entry_diagnostics(hass, entry) == {"loaded": False}
|
||||
1487
tests/test_switch.py
1487
tests/test_switch.py
File diff suppressed because it is too large
Load diff
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -71,7 +71,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "adaptive-lighting"
|
||||
version = "1.30.1"
|
||||
version = "1.32.0"
|
||||
source = { editable = "." }
|
||||
|
||||
[package.dev-dependencies]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue