Merge main before final turn-on policy review

This commit is contained in:
Bas Nijholt 2026-09-06 11:55:29 -07:00
commit b34db1261c
14 changed files with 2181 additions and 25 deletions

View file

@ -1523,6 +1523,51 @@
"contributions": [
"bug"
]
},
{
"login": "jaynis",
"name": "jaynis",
"avatar_url": "https://avatars.githubusercontent.com/u/1553675?v=4",
"profile": "https://github.com/jaynis",
"contributions": [
"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"
]
}
],
"contributorsPerLine": 7,

176
README.md
View file

@ -1,7 +1,7 @@
[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/hacs/integration)
![Version](https://img.shields.io/github/v/release/basnijholt/adaptive-lighting?style=for-the-badge)
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-167-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-172-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
# 🌞 Adaptive Lighting: Enhance Your Home's Atmosphere with Smart, Sun-Synchronized Lighting 🌙
@ -273,6 +273,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. | [![Import blueprint](https://my.home-assistant.io/badges/blueprint_import.svg)](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. | [![Import blueprint](https://my.home-assistant.io/badges/blueprint_import.svg)](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. | [![Import blueprint](https://my.home-assistant.io/badges/blueprint_import.svg)](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. | [![Import blueprint](https://my.home-assistant.io/badges/blueprint_import.svg)](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. | [![Import blueprint](https://my.home-assistant.io/badges/blueprint_import.svg)](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">
@ -295,6 +307,8 @@ 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'"
trigger:
@ -317,6 +331,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 0255 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 0255 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>
@ -343,6 +496,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
@ -392,6 +547,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
@ -584,6 +741,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.
<!-- SECTION:troubleshooting-intro:END -->
<!-- SECTION:common-problems:START -->
@ -929,6 +1096,13 @@ 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/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>
</tr>
</tbody>
<tfoot>

View 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

View 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
0255 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' }}

View 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 (1100) 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

View file

@ -0,0 +1,41 @@
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

View 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 0255 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

View 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,
}

View file

@ -2623,22 +2623,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 (

View file

@ -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. | [![Import blueprint](https://my.home-assistant.io/badges/blueprint_import.svg)](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. | [![Import blueprint](https://my.home-assistant.io/badges/blueprint_import.svg)](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. | [![Import blueprint](https://my.home-assistant.io/badges/blueprint_import.svg)](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. | [![Import blueprint](https://my.home-assistant.io/badges/blueprint_import.svg)](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. | [![Import blueprint](https://my.home-assistant.io/badges/blueprint_import.svg)](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,6 +50,8 @@ 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'"
trigger:
@ -60,6 +74,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 0255 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 0255 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 +239,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 +290,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

View file

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

View file

@ -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",
@ -623,6 +1239,7 @@ async def test_sleep_toggle_uses_fresh_profile_entity_ids(
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 +1247,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,

372
tests/test_diagnostics.py Normal file
View 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}

View file

@ -41,6 +41,7 @@ from homeassistant.components.adaptive_lighting.const import (
CONF_INITIAL_TRANSITION,
CONF_MANUAL_CONTROL,
CONF_MAX_BRIGHTNESS,
CONF_MAX_COLOR_TEMP,
CONF_MIN_BRIGHTNESS,
CONF_MIN_COLOR_TEMP,
CONF_MULTI_LIGHT_INTERCEPT,
@ -1761,6 +1762,155 @@ async def test_manual_control_state_updates_shared_switches(hass):
assert attrs["manual_control_color"] == []
@pytest.mark.parametrize("intercept", [False, True])
@pytest.mark.parametrize(
(
"brightness_takeover",
"color_takeover",
"color_enabled",
"color_mode",
"expected_brightness",
"expected_color_calls",
"event_profiles",
),
[
(
True,
True,
True,
TakeOverControlMode.PAUSE_CHANGED,
77,
1,
["brightness", "color"],
),
(
True,
True,
True,
TakeOverControlMode.PAUSE_ALL,
77,
0,
["brightness", "color"],
),
(False, True, True, TakeOverControlMode.PAUSE_CHANGED, 77, 1, ["color"]),
(True, False, True, TakeOverControlMode.PAUSE_CHANGED, 77, 1, ["brightness"]),
(False, False, True, TakeOverControlMode.PAUSE_CHANGED, 128, 1, []),
(False, True, False, TakeOverControlMode.PAUSE_CHANGED, 128, 0, []),
(True, True, False, TakeOverControlMode.PAUSE_CHANGED, 77, 0, ["brightness"]),
],
)
async def test_shared_profiles_track_manual_brightness(
hass,
intercept,
brightness_takeover,
color_takeover,
color_enabled,
color_mode,
expected_brightness,
expected_color_calls,
event_profiles,
):
"""Shared owners track manual service calls and apply each profile's pause mode."""
await setup_lights(hass)
profiles = {}
for name, takeover, mode in (
("brightness", brightness_takeover, TakeOverControlMode.PAUSE_CHANGED),
("color", color_takeover, color_mode),
):
_, switch = await setup_switch(
hass,
{
CONF_NAME: name,
CONF_LIGHTS: [ENTITY_LIGHT_1],
CONF_INTERCEPT: intercept,
CONF_TAKE_OVER_CONTROL: takeover,
CONF_TAKE_OVER_CONTROL_MODE: mode,
CONF_DETECT_NON_HA_CHANGES: False,
CONF_INITIAL_TRANSITION: 0,
CONF_TRANSITION: 0,
CONF_MIN_BRIGHTNESS: 50,
CONF_MAX_BRIGHTNESS: 50,
CONF_MIN_COLOR_TEMP: 4000,
CONF_MAX_COLOR_TEMP: 4000,
},
)
other_axis = (
switch.adapt_color_switch
if name == "brightness"
else switch.adapt_brightness_switch
)
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: other_axis.entity_id},
blocking=True,
)
profiles[name] = switch
if not color_enabled:
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: profiles["color"].entity_id},
blocking=True,
)
await hass.async_block_till_done()
events = []
remove_events = hass.bus.async_listen(f"{DOMAIN}.manual_control", events.append)
context = Context()
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: ENTITY_LIGHT_1, ATTR_BRIGHTNESS: 77},
context=context,
blocking=True,
)
await hass.async_block_till_done()
calls = []
remove_calls = hass.bus.async_listen(EVENT_CALL_SERVICE, calls.append)
for switch in profiles.values():
if switch.is_on:
await switch._async_update_at_interval_action()
await hass.async_block_till_done()
remove_calls()
remove_events()
light_calls = [
event.data["service_data"]
for event in calls
if event.data["domain"] == LIGHT_DOMAIN
and event.data["service"] == SERVICE_TURN_ON
]
assert (
sum(ATTR_COLOR_TEMP_KELVIN in call for call in light_calls)
== expected_color_calls
)
assert sum(ATTR_BRIGHTNESS in call for call in light_calls) == (
expected_brightness == 128
)
assert (
hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS]
== expected_brightness
)
# Independent profiles may publish their events in either order.
assert sorted(event.data[SWITCH_DOMAIN] for event in events) == sorted(
profiles[name].entity_id for name in event_profiles
)
assert all(event.context == context for event in events)
assert all(
event.data[CONF_MANUAL_CONTROL] == LightControlAttributes.BRIGHTNESS
for event in events
)
for switch in profiles.values():
if not switch.is_on:
continue
attrs = hass.states.get(switch.entity_id).attributes
assert attrs["manual_control_brightness"] == (
[ENTITY_LIGHT_1] if event_profiles else []
)
assert attrs["manual_control_color"] == []
async def test_manual_control_state_ignores_incomplete_entries(hass):
"""An entry awaiting platform setup must not break another profile's updates."""
switch, (light, *_) = await setup_lights_and_switch(hass)
@ -5113,3 +5263,74 @@ async def test_forced_split_apply_stays_off(hass, off_action, cleanup):
assert ATTR_BRIGHTNESS in turn_on_events[0].data["service_data"]
assert ATTR_COLOR_TEMP_KELVIN not in turn_on_events[0].data["service_data"]
assert hass.states.get(ENTITY_LIGHT_3).state == STATE_OFF
@pytest.mark.parametrize("intercept", [False, True])
async def test_shared_profiles_keep_independent_sun_schedules(
hass,
intercept,
reset_time_zone,
):
"""A later color sunrise keeps running after manual brightness takeover."""
await hass.config.async_set_time_zone("UTC")
await setup_lights(hass)
profiles = []
for name, sunrise, sunset in (("brightness", 6, 18), ("color", 10, 22)):
_, switch = await setup_switch(
hass,
{
CONF_NAME: name,
CONF_LIGHTS: [ENTITY_LIGHT_1],
CONF_INTERCEPT: intercept,
CONF_TAKE_OVER_CONTROL_MODE: TakeOverControlMode.PAUSE_CHANGED,
CONF_DETECT_NON_HA_CHANGES: False,
CONF_INITIAL_TRANSITION: 0,
CONF_TRANSITION: 0,
CONF_SUNRISE_TIME: datetime.time(sunrise),
CONF_SUNSET_TIME: datetime.time(sunset),
CONF_MIN_BRIGHTNESS: 10,
CONF_MAX_BRIGHTNESS: 90,
CONF_MIN_COLOR_TEMP: 2000,
CONF_MAX_COLOR_TEMP: 6000,
},
)
other_axis = (
switch.adapt_color_switch
if name == "brightness"
else switch.adapt_brightness_switch
)
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: other_axis.entity_id},
blocking=True,
)
profiles.append(switch)
with patch(
"homeassistant.components.adaptive_lighting.color_and_brightness.utcnow",
return_value=datetime.datetime.fromisoformat("2026-09-05T08:00:00+00:00"),
):
for switch in profiles:
await switch._async_update_at_interval_action()
await hass.async_block_till_done()
morning = hass.states.get(ENTITY_LIGHT_1)
assert morning.attributes[ATTR_BRIGHTNESS] > 26
assert morning.attributes[ATTR_COLOR_TEMP_KELVIN] == 2000
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: ENTITY_LIGHT_1, ATTR_BRIGHTNESS: 77},
blocking=True,
)
await hass.async_block_till_done()
with patch(
"homeassistant.components.adaptive_lighting.color_and_brightness.utcnow",
return_value=datetime.datetime.fromisoformat("2026-09-05T12:00:00+00:00"),
):
for switch in profiles:
await switch._async_update_at_interval_action()
await hass.async_block_till_done()
noon = hass.states.get(ENTITY_LIGHT_1)
assert noon.attributes[ATTR_BRIGHTNESS] == 77
assert noon.attributes[ATTR_COLOR_TEMP_KELVIN] > 2000