diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index f69181b527..3d685e22d9 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -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(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(color_mode); diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index ab9624c364..428d1abf1f 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -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) diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index 8624fade16..98df98ce5d 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -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); diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 82c00e2382..63c311f5a8 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -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(*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(); diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index eafa161f51..22ce9bf198 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -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_; }; diff --git a/esphome/components/light/transformers.h b/esphome/components/light/transformers.h index 34e192a034..27f5c396dc 100644 --- a/esphome/components/light/transformers.h +++ b/esphome/components/light/transformers.h @@ -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(); } diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 6e51707ccd..00c2e04310 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -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; diff --git a/esphome/components/tuya/light/tuya_light.cpp b/esphome/components/tuya/light/tuya_light.cpp index 9f3f3c13cc..32cbca9a39 100644 --- a/esphome/components/tuya/light/tuya_light.cpp +++ b/esphome/components/tuya/light/tuya_light.cpp @@ -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; } diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index bad3d050f3..49943f8a71 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -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) { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 30c9dc27ed..7bc08ea577 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -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 diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 5dc7091e40..5d78c411e7 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -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: diff --git a/tests/integration/fixtures/light_transition_interval_save.yaml b/tests/integration/fixtures/light_transition_interval_save.yaml new file mode 100644 index 0000000000..8f32b56e6d --- /dev/null +++ b/tests/integration/fixtures/light_transition_interval_save.yaml @@ -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(); diff --git a/tests/integration/fixtures/light_transition_state_publish_interval.yaml b/tests/integration/fixtures/light_transition_state_publish_interval.yaml new file mode 100644 index 0000000000..85627c1738 --- /dev/null +++ b/tests/integration/fixtures/light_transition_state_publish_interval.yaml @@ -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 diff --git a/tests/integration/test_light_transition_state_publish_interval.py b/tests/integration/test_light_transition_state_publish_interval.py new file mode 100644 index 0000000000..3b37854e76 --- /dev/null +++ b/tests/integration/test_light_transition_state_publish_interval.py @@ -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)