[light] Add transition_state_publish_interval option to publish intermediate states during transitions (#11968)

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
David Friedland
2026-09-24 14:48:46 +00:00
committed by GitHub
co-authored by Cursor pre-commit-ci-lite[bot] Claude Opus 4.8 J. Nick Koston
parent d2fefe2299
commit 04da1827e3
14 changed files with 606 additions and 18 deletions
+1 -1
View File
@@ -600,7 +600,7 @@ bool APIConnection::send_light_state(light::LightState *light) {
uint16_t APIConnection::try_send_light_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) {
auto *light = static_cast<light::LightState *>(entity);
LightStateResponse resp;
auto values = light->remote_values;
auto values = light->get_reported_values();
auto color_mode = values.get_color_mode();
resp.state = values.is_on();
resp.color_mode = static_cast<enums::ColorMode>(color_mode);
+15
View File
@@ -343,6 +343,7 @@ RESTORE_MODES = {
# Schema default that also matches the C++ initializer in light_state.h; codegen
# skips the setter when the config equals it.
DEFAULT_FLASH_TRANSITION_LENGTH = "0s"
CONF_TRANSITION_STATE_PUBLISH_INTERVAL = "transition_state_publish_interval"
LIGHT_SCHEMA = (
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
@@ -393,6 +394,11 @@ BRIGHTNESS_ONLY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend(
cv.Optional(
CONF_FLASH_TRANSITION_LENGTH, default=DEFAULT_FLASH_TRANSITION_LENGTH
): cv.positive_time_period_milliseconds,
# Below 150ms a device cannot publish any faster and only spends CPU and traffic
cv.Optional(CONF_TRANSITION_STATE_PUBLISH_INTERVAL): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(min=cv.TimePeriod(milliseconds=150)),
),
cv.Optional(CONF_EFFECTS): validate_effects(MONOCHROMATIC_EFFECTS),
}
)
@@ -406,6 +412,11 @@ RGB_LIGHT_SCHEMA = BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend(
ADDRESSABLE_LIGHT_SCHEMA = RGB_LIGHT_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(AddressableLightState),
# The addressable transformer writes the LED buffer directly, so there is no
# intermediate state to publish
cv.Optional(CONF_TRANSITION_STATE_PUBLISH_INTERVAL): cv.invalid(
"transition_state_publish_interval is not supported on addressable lights"
),
cv.Optional(CONF_EFFECTS): validate_effects(ADDRESSABLE_EFFECTS),
cv.Optional(CONF_COLOR_CORRECT): cv.All(
[cv.percentage], cv.Length(min=3, max=4)
@@ -513,6 +524,10 @@ async def setup_light_core_(light_var, config, output_var):
DEFAULT_FLASH_TRANSITION_LENGTH
):
cg.add(light_var.set_flash_transition_length(flash_transition_length))
# Setting an interval opts this light in and compiles the feature in
if (interval := config.get(CONF_TRANSITION_STATE_PUBLISH_INTERVAL)) is not None:
cg.add(light_var.set_transition_state_publish_interval(interval))
cg.add_define("USE_LIGHT_TRANSITION_PUBLISH_INTERVAL")
if (gamma_correct := config.get(CONF_GAMMA_CORRECT)) is not None:
cg.add(light_var.set_gamma_correct(gamma_correct))
fwd_arr = _get_or_create_gamma_table(gamma_correct)
@@ -32,7 +32,7 @@ void LightJSONSchema::dump_json(LightState &state, JsonObject root) {
root[ESPHOME_F("effect_count")] = state.get_effect_count();
}
auto values = state.remote_values;
auto values = state.get_reported_values();
const auto color_mode = values.get_color_mode();
const auto *mode_str = get_color_mode_json_str(color_mode);
+46 -10
View File
@@ -1,6 +1,9 @@
#include "light_state.h"
#include "esp_color_correction.h"
#include "esphome/core/defines.h"
#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL
#include "esphome/core/application.h"
#endif
#include "esphome/core/controller_registry.h"
#include "esphome/core/log.h"
#include "light_output.h"
@@ -105,6 +108,13 @@ void LightState::dump_config() {
" Default Transition Length: %.1fs\n"
" Gamma Correct: %.2f",
this->default_transition_length_ / 1e3f, this->gamma_correct_);
#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL
// The define is build wide; only lights that set the option have an interval
if (this->transition_state_publish_interval_ != 0) {
ESP_LOGCONFIG(TAG, " Transition State Publish Interval: %" PRIu32 "ms",
this->transition_state_publish_interval_);
}
#endif
}
if (traits.supports_color_capability(ColorCapability::COLOR_TEMPERATURE)) {
ESP_LOGCONFIG(TAG,
@@ -130,13 +140,30 @@ void LightState::loop() {
this->next_write_ = true;
}
if (this->transformer_->is_finished()) {
const bool finished = this->transformer_->is_finished();
#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL
if (this->transition_publish_enabled_ && !finished) {
const uint32_t now = App.get_loop_component_start_time();
if (now - this->last_transition_state_publish_ >= this->transition_state_publish_interval_) {
this->publish_state();
this->last_transition_state_publish_ = now;
}
}
#endif
if (finished) {
// if the transition has written directly to the output, current_values is outdated, so update it
this->current_values = this->transformer_->get_target_values();
this->transformer_->stop();
this->is_transformer_active_ = false;
this->transformer_ = nullptr;
#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL
if (this->transition_publish_enabled_) {
// Report the end state from remote_values; a flash's stop() left publishing to us
this->transition_publish_enabled_ = false;
this->publish_state();
}
#endif
if (this->target_state_reached_listeners_) {
for (auto *listener : *this->target_state_reached_listeners_) {
listener->on_light_target_state_reached();
@@ -348,10 +375,7 @@ void LightState::stop_effect_() {
void LightState::start_transition_(const LightColorValues &target, uint32_t length, bool set_remote_values) {
this->transformer_ = this->output_->create_default_transition();
this->transformer_->setup(this->current_values, target, length);
if (set_remote_values) {
this->remote_values = target;
}
this->set_transformer_remote_values_(target, set_remote_values);
// Enable loop while transition is active
this->enable_loop();
}
@@ -365,10 +389,7 @@ void LightState::start_flash_(const LightColorValues &target, uint32_t length, b
this->transformer_ = make_unique<LightFlashTransformer>(*this);
this->transformer_->setup(end_colors, target, length);
if (set_remote_values) {
this->remote_values = target;
};
this->set_transformer_remote_values_(target, set_remote_values);
// Enable loop while flash is active
this->enable_loop();
}
@@ -376,6 +397,9 @@ void LightState::start_flash_(const LightColorValues &target, uint32_t length, b
void LightState::set_immediately_(const LightColorValues &target, bool set_remote_values) {
this->is_transformer_active_ = false;
this->transformer_ = nullptr;
#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL
this->transition_publish_enabled_ = false;
#endif
this->current_values = target;
if (set_remote_values) {
this->remote_values = target;
@@ -391,6 +415,18 @@ void LightState::disable_loop_if_idle_() {
}
}
#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL
void LightState::set_transformer_remote_values_(const LightColorValues &target, bool set_remote_values) {
this->transition_publish_enabled_ = set_remote_values && this->transition_state_publish_interval_ > 0;
if (this->transition_publish_enabled_) {
this->last_transition_state_publish_ = App.get_loop_component_start_time();
}
if (set_remote_values) {
this->remote_values = target;
}
}
#endif
void LightState::save_remote_values_() {
LightStateRTCState saved;
saved.color_mode = this->remote_values.get_color_mode();
+43
View File
@@ -100,6 +100,21 @@ class LightState : public EntityBase, public Component {
LightCall turn_on();
LightCall turn_off();
LightCall toggle();
/// The values reported to the frontend: current_values while a light publishes intermediate
/// states on an interval, otherwise remote_values. Each interval sample is a publish_state(),
/// so on_state automations run on every sample as well.
const LightColorValues &get_reported_values() const {
#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL
if (this->transition_publish_enabled_) {
return this->current_values;
}
#endif
return this->remote_values;
}
/// True from the call that starts a transition or flash until it reaches its target.
bool is_transitioning() const { return this->transformer_ != nullptr; }
LightCall make_call();
// ========== INTERNAL METHODS ==========
@@ -172,6 +187,13 @@ class LightState : public EntityBase, public Component {
void set_gamma_correct(float gamma_correct) { this->gamma_correct_ = gamma_correct; }
float get_gamma_correct() const { return this->gamma_correct_; }
#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL
void set_transition_state_publish_interval(uint32_t transition_state_publish_interval) {
this->transition_state_publish_interval_ = transition_state_publish_interval;
}
uint32_t get_transition_state_publish_interval() const { return this->transition_state_publish_interval_; }
#endif
#ifdef USE_LIGHT_GAMMA_LUT
/// Set pre-computed gamma forward lookup table (256-entry uint16 PROGMEM array)
void set_gamma_table(const uint16_t *forward) { this->gamma_table_ = forward; }
@@ -291,6 +313,7 @@ class LightState : public EntityBase, public Component {
friend LightOutput;
friend LightCall;
friend class AddressableLight;
friend class LightFlashTransformer;
/// Internal method to start an effect with the given index
void start_effect_(uint32_t effect_index);
@@ -307,6 +330,18 @@ class LightState : public EntityBase, public Component {
/// Internal method to set the color values to target immediately (with no transition).
void set_immediately_(const LightColorValues &target, bool set_remote_values);
/// Point remote_values at the new transformer's target and, when this light publishes
/// intermediate states on an interval, start the interval clock.
#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL
void set_transformer_remote_values_(const LightColorValues &target, bool set_remote_values);
#else
void set_transformer_remote_values_(const LightColorValues &target, bool set_remote_values) {
if (set_remote_values) {
this->remote_values = target;
}
}
#endif
/// Internal method to save the current remote_values to the preferences
void save_remote_values_();
@@ -357,6 +392,10 @@ class LightState : public EntityBase, public Component {
uint32_t default_transition_length_{};
/// Transition length to use for flash transitions.
uint32_t flash_transition_length_{}; // Keep in sync with DEFAULT_FLASH_TRANSITION_LENGTH in __init__.py
#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL
uint32_t transition_state_publish_interval_{0};
uint32_t last_transition_state_publish_{0};
#endif
/// Gamma correction factor for the light.
float gamma_correct_{};
#ifdef USE_LIGHT_GAMMA_LUT
@@ -367,6 +406,10 @@ class LightState : public EntityBase, public Component {
bool next_write_{true};
// for effects, true if a transformer (transition) is active.
bool is_transformer_active_{false};
#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL
/// True while the active transformer publishes current_values on an interval from loop().
bool transition_publish_enabled_{false};
#endif
/// Restore mode of the light.
LightRestoreMode restore_mode_;
};
+5
View File
@@ -105,6 +105,11 @@ class LightFlashTransformer : public LightTransformer {
}
this->state_.current_values = this->get_start_values();
this->state_.remote_values = this->get_start_values();
#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL
// The light reports the end state itself once the transformer finishes
if (this->state_.transition_publish_enabled_)
return;
#endif
this->state_.publish_state();
}
@@ -322,7 +322,7 @@ void PrometheusHandler::light_row_(AsyncResponseStream *stream, light::LightStat
// State
print_metric_labels_(stream, ESPHOME_F("esphome_light_state"), obj, area, node, friendly_name);
stream->print(ESPHOME_F("\"} "));
stream->print(obj->remote_values.is_on());
stream->print(obj->get_reported_values().is_on());
stream->print(ESPHOME_F("\n"));
// Brightness and RGBW
light::LightColorValues color = obj->current_values;
+4 -4
View File
@@ -9,7 +9,7 @@ static const char *const TAG = "tuya.light";
void TuyaLight::setup() {
if (this->color_temperature_id_.has_value()) {
this->parent_->register_listener(*this->color_temperature_id_, [this](const TuyaDatapoint &datapoint) {
if (this->state_->current_values != this->state_->remote_values) {
if (this->state_->is_transitioning()) {
ESP_LOGD(TAG, "Light is transitioning, datapoint change ignored");
return;
}
@@ -27,7 +27,7 @@ void TuyaLight::setup() {
}
if (this->dimmer_id_.has_value()) {
this->parent_->register_listener(*this->dimmer_id_, [this](const TuyaDatapoint &datapoint) {
if (this->state_->current_values != this->state_->remote_values) {
if (this->state_->is_transitioning()) {
ESP_LOGD(TAG, "Light is transitioning, datapoint change ignored");
return;
}
@@ -39,7 +39,7 @@ void TuyaLight::setup() {
}
if (switch_id_.has_value()) {
this->parent_->register_listener(*this->switch_id_, [this](const TuyaDatapoint &datapoint) {
if (this->state_->current_values != this->state_->remote_values) {
if (this->state_->is_transitioning()) {
ESP_LOGD(TAG, "Light is transitioning, datapoint change ignored");
return;
}
@@ -51,7 +51,7 @@ void TuyaLight::setup() {
}
if (color_id_.has_value()) {
this->parent_->register_listener(*this->color_id_, [this](const TuyaDatapoint &datapoint) {
if (this->state_->current_values != this->state_->remote_values) {
if (this->state_->is_transitioning()) {
ESP_LOGD(TAG, "Light is transitioning, datapoint change ignored");
return;
}
+1 -1
View File
@@ -1015,7 +1015,7 @@ json::SerializationBuffer<> WebServer::light_json_(light::LightState *obj, JsonD
json::JsonBuilder builder;
JsonObject root = builder.root();
set_json_value(root, obj, "light", obj->remote_values.is_on() ? "ON" : "OFF", start_config);
set_json_value(root, obj, "light", obj->get_reported_values().is_on() ? "ON" : "OFF", start_config);
light::LightJSONSchema::dump_json(*obj, root);
if (start_config == DETAIL_ALL) {
+1
View File
@@ -89,6 +89,7 @@
#define USE_RADIO_FREQUENCY
#define USE_LIGHT
#define USE_LIGHT_GAMMA_LUT
#define USE_LIGHT_TRANSITION_PUBLISH_INTERVAL
#define USE_LOCK
#define USE_LOGGER
#define USE_LOGGER_LEVEL_LISTENERS
+1
View File
@@ -187,6 +187,7 @@ light:
name: Monochromatic Light
output: test_ledc_1
gamma_correct: 2.8
transition_state_publish_interval: 200ms
default_transition_length: 2s
effects:
- strobe:
@@ -0,0 +1,38 @@
esphome:
name: ltspi-save
host:
api:
logger:
preferences:
flash_write_interval: 0s # Flush to disk on every loop
output:
- platform: template
id: mono_output
type: float
write_action:
- logger.log:
format: "mono_output: %.2f"
args: [state]
light:
- platform: monochromatic
id: test_mono_light
name: "Test Mono Light"
output: mono_output
restore_mode: RESTORE_DEFAULT_OFF
default_transition_length: 1s
transition_state_publish_interval: 0.2s
button:
- platform: template
id: run_persistence_transition
name: "Run Persistence Transition"
on_press:
- lambda: |-
auto call = id(test_mono_light).turn_on();
call.set_brightness(1.0f);
call.set_transition_length(1000);
call.set_save(true);
call.perform();
@@ -0,0 +1,121 @@
esphome:
name: ltspi
host:
api:
logger:
output:
# Monochromatic output
- platform: template
id: mono_output
type: float
write_action:
- logger.log:
format: "mono_output: %.2f"
args: [state]
# RGB outputs
- platform: template
id: rgb_red
type: float
write_action:
- logger.log:
format: "rgb_red: %.2f"
args: [state]
- platform: template
id: rgb_green
type: float
write_action:
- logger.log:
format: "rgb_green: %.2f"
args: [state]
- platform: template
id: rgb_blue
type: float
write_action:
- logger.log:
format: "rgb_blue: %.2f"
args: [state]
# CWWW outputs (using rgbww light for CT + cold/warm white coverage)
- platform: template
id: cwww_red
type: float
write_action:
- logger.log:
format: "cwww_red: %.2f"
args: [state]
- platform: template
id: cwww_green
type: float
write_action:
- logger.log:
format: "cwww_green: %.2f"
args: [state]
- platform: template
id: cwww_blue
type: float
write_action:
- logger.log:
format: "cwww_blue: %.2f"
args: [state]
- platform: template
id: cwww_cold_white
type: float
write_action:
- logger.log:
format: "cwww_cold_white: %.2f"
args: [state]
- platform: template
id: cwww_warm_white
type: float
write_action:
- logger.log:
format: "cwww_warm_white: %.2f"
args: [state]
- platform: template
id: legacy_output
type: float
write_action:
- logger.log:
format: "legacy_output: %.2f"
args: [state]
light:
# Monochromatic light (brightness only)
- platform: monochromatic
id: test_mono_light
name: "Test Mono Light"
output: mono_output
default_transition_length: 1s
transition_state_publish_interval: 0.2s
# RGB light
- platform: rgb
id: test_rgb_light
name: "Test RGB Light"
red: rgb_red
green: rgb_green
blue: rgb_blue
default_transition_length: 1s
transition_state_publish_interval: 0.2s
# CWWW/CT-capable light
- platform: rgbww
id: test_cwww_light
name: "Test CWWW Light"
red: cwww_red
green: cwww_green
blue: cwww_blue
cold_white: cwww_cold_white
warm_white: cwww_warm_white
cold_white_color_temperature: 6500 K
warm_white_color_temperature: 2000 K
constant_brightness: true
default_transition_length: 1s
transition_state_publish_interval: 0.2s
# No interval: publishes only when a call starts
- platform: monochromatic
id: test_legacy_light
name: "Test Legacy Light"
output: legacy_output
default_transition_length: 1s
@@ -0,0 +1,328 @@
"""Integration tests for the light transition_state_publish_interval option."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from itertools import pairwise
from aioesphomeapi import (
APIClient,
ButtonInfo,
EntityInfo,
EntityState,
LightInfo,
LightState,
)
import pytest
from .state_utils import InitialStateHelper, require_entity, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
Timeline = list[tuple[float, LightState]]
DonePredicate = Callable[[float, LightState], bool]
class _Recorder:
"""Records the states one light publishes while an action runs.
``run`` fires ``action`` and returns the (elapsed, state) timeline once a
published state satisfies ``done``, after ``settle`` more seconds so late
publishes still land in the timeline.
"""
def __init__(self) -> None:
self._loop = asyncio.get_running_loop()
self._event = asyncio.Event()
self._key = 0
self._start = 0.0
self._done: DonePredicate | None = None
self.timeline: Timeline = []
def on_state(self, state: EntityState) -> None:
if (
self._done is None
or not isinstance(state, LightState)
or state.key != self._key
):
return
elapsed = self._loop.time() - self._start
self.timeline.append((elapsed, state))
if self._done(elapsed, state):
self._event.set()
async def run(
self,
key: int,
action: Callable[[], None],
done: DonePredicate,
settle: float = 0.0,
) -> Timeline:
self.timeline = []
self._key = key
self._done = done
self._event.clear()
self._start = self._loop.time()
action()
async with asyncio.timeout(5):
await self._event.wait()
if settle:
await asyncio.sleep(settle)
self._done = None
return self.timeline
async def _subscribe(client: APIClient) -> tuple[list[EntityInfo], _Recorder]:
"""List entities and attach a recorder once the initial states have arrived."""
entities, _ = await client.list_entities_services()
helper = InitialStateHelper(entities)
recorder = _Recorder()
client.subscribe_states(helper.on_state_wrapper(recorder.on_state))
await helper.wait_for_initial_states()
return entities, recorder
def _visible_brightness(state: LightState) -> float:
"""Brightness as a remote sees it: an off light counts as zero."""
return state.brightness if state.state else 0.0
def _brightness_is(value: float) -> DonePredicate:
return lambda _elapsed, state: (
_visible_brightness(state) == pytest.approx(value, abs=0.01)
)
def _brightness_values(timeline: Timeline) -> list[float]:
return [_visible_brightness(state) for _, state in timeline]
def _assert_ramp(timeline: Timeline, target: float) -> None:
"""Several states were published and the last one is ``target`` after ~1 s."""
values = _brightness_values(timeline)
assert len(values) >= 3, values
assert values[-1] == pytest.approx(target, abs=0.05), values
assert timeline[-1][0] >= 0.8, timeline[-1][0]
@pytest.mark.asyncio
@pytest.mark.shared_yaml("light_transition_state_publish_interval")
async def test_light_without_interval_publishes_target_once(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A light without the option publishes the target once, up front."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, recorder = await _subscribe(client)
legacy = require_entity(entities, "test_legacy_light", LightInfo)
timeline = await recorder.run(
legacy.key,
lambda: client.light_command(
key=legacy.key, state=True, brightness=0.8, transition_length=1.0
),
_brightness_is(0.8),
settle=1.3,
)
assert _brightness_values(timeline) == [pytest.approx(0.8)]
@pytest.mark.asyncio
@pytest.mark.shared_yaml("light_transition_state_publish_interval")
async def test_transition_interval_nonzero_emits_intermediate_updates(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Turning on over 1 s with a 200 ms interval publishes a rising ramp."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, recorder = await _subscribe(client)
mono = require_entity(entities, "test_mono_light", LightInfo)
timeline = await recorder.run(
mono.key,
lambda: client.light_command(
key=mono.key, state=True, brightness=1.0, transition_length=1.0
),
_brightness_is(1.0),
)
values = _brightness_values(timeline)
assert len(values) >= 5, values
assert values[0] == pytest.approx(0.0, abs=0.1), values
assert values[-1] == pytest.approx(1.0, abs=0.05), values
assert len([v for v in values if 0.1 < v < 0.9]) >= 2, values
assert all(b >= a - 0.1 for a, b in pairwise(values)), values
assert timeline[-1][0] >= 0.8, timeline[-1][0]
@pytest.mark.asyncio
@pytest.mark.shared_yaml("light_transition_state_publish_interval")
async def test_light_transition_state_publish_interval(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Default-length, RGB and color temperature transitions publish on the interval."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, recorder = await _subscribe(client)
mono = require_entity(entities, "test_mono_light", LightInfo)
rgb = require_entity(entities, "test_rgb_light", LightInfo)
cwww = require_entity(entities, "test_cwww_light", LightInfo)
# No transition_length: default_transition_length (1 s) applies
timeline = await recorder.run(
mono.key,
lambda: client.light_command(key=mono.key, state=True, brightness=1.0),
_brightness_is(1.0),
)
_assert_ramp(timeline, 1.0)
timeline = await recorder.run(
rgb.key,
lambda: client.light_command(
key=rgb.key,
state=True,
brightness=1.0,
rgb=(1.0, 0.0, 0.0),
transition_length=1.0,
),
_brightness_is(1.0),
)
_assert_ramp(timeline, 1.0)
# Start at the cold end instantly so the fade to 300 mireds has a gradient
await recorder.run(
cwww.key,
lambda: client.light_command(
key=cwww.key,
state=True,
brightness=1.0,
color_temperature=153.0,
transition_length=0.0,
),
lambda _t, s: (
s.state and s.color_temperature == pytest.approx(153.0, abs=1.0)
),
)
timeline = await recorder.run(
cwww.key,
lambda: client.light_command(
key=cwww.key,
state=True,
brightness=1.0,
color_temperature=300.0,
transition_length=1.0,
),
lambda _t, s: s.color_temperature == pytest.approx(300.0, abs=1.0),
)
ct_values = [state.color_temperature for _, state in timeline]
assert len(ct_values) >= 3, ct_values
assert min(ct_values) < max(ct_values), ct_values
assert timeline[-1][0] >= 0.8, timeline[-1][0]
@pytest.mark.asyncio
@pytest.mark.shared_yaml("light_transition_state_publish_interval")
async def test_flash_interval_emits_intermediate_updates(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A flash publishes its value on the interval and ends back where it started."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, recorder = await _subscribe(client)
mono = require_entity(entities, "test_mono_light", LightInfo)
await recorder.run(
mono.key,
lambda: client.light_command(
key=mono.key, state=True, brightness=0.4, transition_length=0.0
),
_brightness_is(0.4),
)
timeline = await recorder.run(
mono.key,
lambda: client.light_command(
key=mono.key, brightness=1.0, flash_length=1.0
),
lambda t, s: (
t > 0.5 and _visible_brightness(s) == pytest.approx(0.4, abs=0.01)
),
)
values = _brightness_values(timeline)
assert values.count(pytest.approx(1.0, abs=0.01)) >= 3, values
assert values[-1] == pytest.approx(0.4, abs=0.05), values
assert timeline[-1][0] >= 0.8, timeline[-1][0]
@pytest.mark.asyncio
@pytest.mark.shared_yaml("light_transition_interval_save")
async def test_transition_interval_persistence_semantics(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A save=true interval transition saves its target, not a sample, and restores it."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, recorder = await _subscribe(client)
mono = require_entity(entities, "test_mono_light", LightInfo)
button = require_entity(entities, "run_persistence_transition", ButtonInfo)
timeline = await recorder.run(
mono.key,
lambda: client.button_command(button.key),
_brightness_is(1.0),
)
_assert_ramp(timeline, 1.0)
# The restored light fades up from off over its default transition, so wait for the end
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
mono = require_entity(entities, "test_mono_light", LightInfo)
await wait_for_state(
client,
lambda s: (
isinstance(s, LightState)
and s.key == mono.key
and _visible_brightness(s) == pytest.approx(1.0, abs=0.01)
),
)
@pytest.mark.asyncio
@pytest.mark.shared_yaml("light_transition_state_publish_interval")
async def test_partial_call_during_interval_transition_keeps_target(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A colour temperature change mid-fade keeps the fade's brightness target."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, recorder = await _subscribe(client)
cwww = require_entity(entities, "test_cwww_light", LightInfo)
# Let the fade from off run for a couple of samples before changing only the colour
await recorder.run(
cwww.key,
lambda: client.light_command(
key=cwww.key,
state=True,
brightness=1.0,
color_temperature=153.0,
transition_length=1.0,
),
lambda t, _s: t >= 0.3,
)
timeline = await recorder.run(
cwww.key,
lambda: client.light_command(
key=cwww.key, color_temperature=300.0, transition_length=1.0
),
lambda _t, s: s.color_temperature == pytest.approx(300.0, abs=1.0),
)
assert _brightness_values(timeline)[-1] == pytest.approx(1.0, abs=0.01)