Fixed lighting is the same colour at breakfast and at bedtime, which is the one thing daylight never is. This recipe moves your dimmable lights along a daily curve instead. Around the middle of the day they sit cool and bright, and as the evening comes in they warm and drop down toward a low, restful light.
It runs every fifteen minutes and on every Home Assistant restart, so a light switched on at any point picks up the right setting within a few minutes. The important part is what it does not do. It only ever adjusts a light that is already on, so a room you left dark stays dark. Nothing in this recipe turns a light on.
Setting the curve
You set four points. The morning and evening times mark where the day begins and ends for the lights, and the recipe puts the coolest, brightest setting at the midpoint between them. The day and night colour temperatures set the two ends of the warm-to-cool range, and the day and night brightness levels do the same for how bright the lights sit. The defaults run from a warm, low evening to a bright, neutral midday, which suits most homes without changing anything.
Colour-temperature and colour bulbs get the full effect, both the warm-to-cool shift and the brightness change. Dimmable white lights that cannot change colour still follow the brightness curve.
Holding brightness on a dark afternoon
The clock does not know the weather. Add a light sensor as the optional illuminance role and the recipe leans on it to keep the brightness up when the room is genuinely dark in the middle of the day, rather than dimming to the schedule while a storm is overhead. Leave the sensor out and the curve follows the time of day alone.
Files Selora AI's pipeline reads to install this recipe. The manifest declares roles, inputs, and integrations; the package files are Jinja-templated YAML applied to Home Assistant.
# Circadian Adaptive Lighting — walk the colour temperature and brightness of
# the selected lights along a daily curve, only touching lights already on.
#
# One automation runs every 15 minutes and on start. It computes a target
# colour temperature and brightness from the time of day (coolest and
# brightest at the midpoint between the morning and evening times, warm and low
# at both ends) and applies them to each selected light that is currently on. A
# repeat/for_each with a per-light "is it on" guard is what keeps the recipe
# from ever switching a light on by itself.
slug: circadian-adaptive-lighting
version: 1.0.0
title: Circadian Adaptive Lighting
tagline: Cool and bright at midday, warm and low in the evening, applied only to the lights that are already on.
description: >-
Moves the colour temperature and brightness of your dimmable lights along a
daily curve, coolest and brightest around midday and warm and dim in the
evening. Runs every 15 minutes and on restart, and only ever changes a light
that is already on, so it never switches one on by itself.
author: Selora AI
released: "2026-08-28"
min_integration_version: "0.12.0"
tags: [lighting, circadian, color-temperature, brightness]
roles:
# Dimmable lights the recipe tracks. min_count 1: with no lights there is
# nothing to adjust. Colour-temp / colour lights get the warm-to-cool shift;
# dimmable white lights follow the brightness curve only.
- id: primary_lights
title: Dimmable lights
kind: light
min_count: 1
selection: required
description: >-
The dimmable lights to move through the day. Colour-temperature or colour
bulbs also get the warm-to-cool shift.
# Optional colour strips brought into the same curve.
- id: strips
title: Light strips
kind: light
min_count: 0
selection: required
description: >-
Optional. Colour strips that follow the same curve as the main lights.
Leave empty to skip.
# Optional extra bulbs brought into the same curve.
- id: bulbs
title: Extra bulbs
kind: light
min_count: 0
selection: required
description: >-
Optional. Additional colour or white-ambiance bulbs to move with the
rest. Leave empty to skip.
# Optional illuminance sensor. When picked, a dark daytime reading holds the
# brightness at the day level instead of dimming on the clock. max_count 1.
- id: illuminance
title: Light level sensor
kind: sensor
device_class: illuminance
min_count: 0
max_count: 1
selection: required
description: >-
Optional. A light sensor so a dark, overcast afternoon holds the
brightness up rather than dimming to the schedule. Leave empty to follow
the clock alone.
inputs:
# Cool end of the range, applied around the midpoint of the day. 5000 K is a
# neutral daylight white.
- id: day_kelvin
type: number
label: Daytime colour temperature (K)
description: >-
The coolest colour temperature, used around the middle of the day.
default: 5000
min: 2000
max: 6500
# Warm end of the range, applied at the morning and evening edges. 2300 K is
# a low, restful warm white.
- id: night_kelvin
type: number
label: Evening colour temperature (K)
description: >-
The warmest colour temperature, used at the start and end of the day.
default: 2300
min: 1500
max: 4000
# Where the day begins for the lights. The coolest/brightest point sits at
# the midpoint between this and the evening time.
- id: morning_time
type: string
label: Morning start
description: >-
The time the daily curve starts warming up from its evening low.
default: "06:30:00"
- id: evening_time
type: string
label: Evening end
description: >-
The time the curve returns to its warmest, lowest setting.
default: "20:30:00"
# Brightness ceiling around midday.
- id: day_brightness
type: number
label: Daytime brightness (percent)
description: >-
Brightness used around the middle of the day.
default: 100
min: 1
max: 100
# Brightness floor at the morning and evening edges.
- id: night_brightness
type: number
label: Evening brightness (percent)
description: >-
Brightness used at the start and end of the day.
default: 35
min: 1
max: 100
# Darkness threshold in lux, used only when an illuminance sensor is picked.
# Below this during the day, brightness is held at the day level.
- id: dark_lux
type: number
label: Darkness threshold (lux)
description: >-
With a light sensor picked, a daytime reading below this holds the
brightness at the daytime level.
default: 150
min: 1
max: 2000
package_files:
- package/automations/adaptive.yaml.j2
{# Circadian Adaptive Lighting.
The colour temperature and brightness both follow a triangular curve across
the day: warm and low (night values) at the morning and evening edges,
cool and bright (day values) at the midpoint between them. f is the 0..1
position on that curve, computed at runtime from the clock.
The edge minutes (morning, midpoint, evening) and the day/night targets are
rendered into a variables block as plain numbers at install time, so the
runtime template only has to do the arithmetic. A second variables step
builds the target templates from them.
The repeat walks every selected light and only calls light.turn_on on the
ones already on, checked per light, so the recipe never switches a light on.
#}
{# The two time inputs are free-form strings, so a homeowner can enter HH:MM as
readily as HH:MM:SS. Pad with trailing zero fields before splitting: an
unpack that expects exactly three parts raises at render time, which fails
the install rather than degrading. Only hours and minutes are ever read. #}
{% set mparts = (inputs.morning_time ~ ':0:0').split(':') %}
{% set morning_min = mparts[0] | int * 60 + mparts[1] | int %}
{% set eparts = (inputs.evening_time ~ ':0:0').split(':') %}
{% set evening_min = eparts[0] | int * 60 + eparts[1] | int %}
{% set noon_min = (morning_min + evening_min) // 2 %}
{% set all_lights = roles.primary_lights + roles.strips + roles.bulbs %}
automation:
- id: selora_recipe_{{ slug | replace('-', '_') }}_adjust
alias: "Circadian Adaptive Lighting: adjust"
description: >-
Every 15 minutes and on restart, set the colour temperature and
brightness of each selected light that is on to its point on the daily
curve.
mode: restart
trigger:
- platform: time_pattern
minutes: "/15"
- platform: homeassistant
event: start
action:
- variables:
mor: {{ morning_min }}
noon: {{ noon_min }}
eve: {{ evening_min }}
day_k: {{ inputs.day_kelvin }}
night_k: {{ inputs.night_kelvin }}
day_b: {{ inputs.day_brightness }}
night_b: {{ inputs.night_brightness }}
{% if roles.illuminance %}
dark_lux: {{ inputs.dark_lux }}
lux: "{% raw %}{{ states('{% endraw %}{{ roles.illuminance[0] }}{% raw %}') | float(99999) }}{% endraw %}"
{% endif %}
- variables:
target_kelvin: "{% raw %}{% set m = now().hour * 60 + now().minute %}{% set f = 0 %}{% if mor < m < eve %}{% if m <= noon %}{% set f = (m - mor) / (noon - mor) %}{% else %}{% set f = (eve - m) / (eve - noon) %}{% endif %}{% endif %}{{ (night_k + (day_k - night_k) * f) | round | int }}{% endraw %}"
target_brightness: "{% raw %}{% set m = now().hour * 60 + now().minute %}{% set f = 0 %}{% if mor < m < eve %}{% if m <= noon %}{% set f = (m - mor) / (noon - mor) %}{% else %}{% set f = (eve - m) / (eve - noon) %}{% endif %}{% endif %}{% endraw %}{% if roles.illuminance %}{% raw %}{% if mor < m < eve and lux < dark_lux %}{% set f = 1 %}{% endif %}{% endraw %}{% endif %}{% raw %}{{ (night_b + (day_b - night_b) * f) | round | int }}{% endraw %}"
- repeat:
for_each:
{% for light in all_lights %}
- {{ light }}
{% endfor %}
sequence:
- if:
- condition: template
value_template: "{% raw %}{{ is_state(repeat.item, 'on') }}{% endraw %}"
then:
- service: light.turn_on
target:
entity_id: "{% raw %}{{ repeat.item }}{% endraw %}"
data:
color_temp_kelvin: "{% raw %}{{ target_kelvin | int }}{% endraw %}"
brightness_pct: "{% raw %}{{ target_brightness | int }}{% endraw %}"