From 73c972a604e4ebb735b40d41f249dbd9deff0f9e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 13:59:32 -1000 Subject: [PATCH 01/10] [adc] Place ADC oneshot control functions in IRAM for cache safety (#15717) --- esphome/components/adc/sensor.py | 15 ++++++++++++++- esphome/components/esp32/__init__.py | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index bab2762f00..09e09f0dc1 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -2,7 +2,11 @@ import logging import esphome.codegen as cg from esphome.components import sensor, voltage_sampler -from esphome.components.esp32 import get_esp32_variant, include_builtin_idf_component +from esphome.components.esp32 import ( + get_esp32_variant, + include_builtin_idf_component, + require_adc_oneshot_iram, +) from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC from esphome.components.zephyr import ( zephyr_add_overlay, @@ -24,6 +28,7 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE +from esphome.types import ConfigType from . import ( ATTENUATION_MODES, @@ -65,6 +70,13 @@ def validate_config(config): return config +def _require_adc_iram(config: ConfigType) -> ConfigType: + """Register ADC oneshot IRAM requirement during config validation.""" + if CORE.is_esp32: + require_adc_oneshot_iram() + return config + + ADCSensor = adc_ns.class_( "ADCSensor", sensor.Sensor, cg.PollingComponent, voltage_sampler.VoltageSampler ) @@ -95,6 +107,7 @@ CONFIG_SCHEMA = cv.All( ) .extend(cv.polling_component_schema("60s")), validate_config, + _require_adc_iram, ) CONF_ADC_CHANNEL_ID = "adc_channel_id" diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 2974028b50..7b3f9da3da 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1058,6 +1058,7 @@ CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert" CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7" CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram" CONF_DISABLE_FATFS = "disable_fatfs" +CONF_ADC_ONESHOT_IN_IRAM = "adc_oneshot_in_iram" # VFS requirement tracking # Components that need VFS features can call require_vfs_*() functions @@ -1071,6 +1072,7 @@ KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required" KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required" KEY_FATFS_REQUIRED = "fatfs_required" KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required" +KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required" def require_vfs_select() -> None: @@ -1168,6 +1170,17 @@ def require_fatfs() -> None: CORE.data[KEY_ESP32][KEY_FATFS_REQUIRED] = True +def require_adc_oneshot_iram() -> None: + """Mark that ADC oneshot IRAM safety is required by a component. + + Call this from components that use the ADC oneshot driver. When flash cache is + disabled (e.g., during NVS writes by WiFi, BLE, Zigbee, or power management), + the ADC oneshot read function must be in IRAM to avoid crashes. + This sets CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM. + """ + CORE.data[KEY_ESP32][KEY_ADC_ONESHOT_IRAM_REQUIRED] = True + + def _parse_idf_component(value: str) -> ConfigType: """Parse IDF component shorthand syntax like 'owner/component^version'""" # Match operator followed by version-like string (digit or *) @@ -1268,6 +1281,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean, cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean, + cv.Optional(CONF_ADC_ONESHOT_IN_IRAM, default=False): cv.boolean, cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean, } ), @@ -2068,6 +2082,16 @@ async def to_code(config): if advanced[CONF_DISABLE_REGI2C_IN_IRAM]: add_idf_sdkconfig_option("CONFIG_ESP_REGI2C_CTRL_FUNC_IN_IRAM", False) + # Place ADC oneshot control functions in IRAM for cache safety + # When flash cache is disabled (during NVS writes by WiFi, BLE, Zigbee, Thread, + # power management, etc.), ADC reads will crash if these functions are in flash. + # Components using ADC call require_adc_oneshot_iram() to force this. + if ( + CORE.data[KEY_ESP32].get(KEY_ADC_ONESHOT_IRAM_REQUIRED, False) + or advanced[CONF_ADC_ONESHOT_IN_IRAM] + ): + add_idf_sdkconfig_option("CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM", True) + # Disable FATFS support # Components that need FATFS (SD card, etc.) can call require_fatfs() if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False): From 21df5d9bf6162f5d14529560f1c8b98c704a13f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 13:59:45 -1000 Subject: [PATCH 02/10] [web_server] Reset OTA backend on new upload to avoid brick after interrupted OTA (#15720) --- .../web_server/ota/ota_web_server.cpp | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 95b166901a..9812714ec0 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -114,7 +114,25 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf uint8_t *data, size_t len, bool final) { ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_OK; - if (index == 0 && !this->ota_backend_) { + // First byte of a new upload: index==0 with actual data. (web_server_idf + // fires a separate start-marker call with data==nullptr/len==0 before the + // first real chunk; gate on len>0 so we only trigger once per upload.) + if (index == 0 && len > 0) { + // If a previous upload was interrupted (e.g. client closed the tab, TCP + // reset) the backend from that session may still be open. Tear it down + // so flash state doesn't get concatenated with the new image (which can + // produce a technically-valid-sized but corrupted firmware that bricks + // the device once it reboots). + if (this->ota_backend_) { + ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session"); + this->ota_backend_->abort(); +#ifdef USE_OTA_STATE_LISTENER + // Notify listeners that the previous session was aborted before the new one starts. + this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0); +#endif + this->ota_backend_.reset(); + } + // Initialize OTA on first call this->ota_init_(filename.c_str()); From 3f56e0255a4f141a58f79f8e09e72db8854298c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 14:17:21 -1000 Subject: [PATCH 03/10] [light] Avoid addressable transition stall at low gamma-corrected values When a uniform-colored addressable strip transitions from one color to another, interpolate math-only against a cached start color instead of reading each LED's current value back through the 8-bit stored byte. The old algorithm used led.get_red()/etc. every step as the source for the delta, which round-tripped through gamma uncorrect/correct and the 8-bit stored byte. At gamma 2.8, any pre-gamma value below ~27 rounds to stored byte 0, so small early-transition steps produced stored 0 and the next step read back 0, stalling progress until ~90% of the transition before a single step produced a large-enough pre-gamma value to clear the gamma threshold. Result: dark for the first 9s of a 10s fade, then jump on in the final 1s. Detect uniform start state in start() and take a cheap math-only lerp path when true, so the stored byte advances through each gamma threshold as smoothed_progress crosses it. Falls back to the existing per-LED read-back algorithm when the buffer is non-uniform (e.g. when transitioning out of an addressable effect). --- .../components/light/addressable_light.cpp | 48 ++++++- esphome/components/light/addressable_light.h | 2 + .../addressable_light_transition.yaml | 29 +++++ .../mock_addressable_light/__init__.py | 1 + .../mock_addressable_light/light.py | 22 ++++ .../mock_addressable_light.h | 48 +++++++ .../test_addressable_light_transition.py | 119 ++++++++++++++++++ 7 files changed, 263 insertions(+), 6 deletions(-) create mode 100644 tests/integration/fixtures/addressable_light_transition.yaml create mode 100644 tests/integration/fixtures/external_components/mock_addressable_light/__init__.py create mode 100644 tests/integration/fixtures/external_components/mock_addressable_light/light.py create mode 100644 tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h create mode 100644 tests/integration/test_addressable_light_transition.py diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index 2f6ffc9a38..671f10874d 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -58,6 +58,26 @@ void AddressableLightTransformer::start() { // our transition will handle brightness, disable brightness in correction. this->light_.correction_.set_local_brightness(255); this->target_color_ *= to_uint8_scale(end_values.get_brightness() * end_values.get_state()); + + // When every LED starts at the same color (the common case: plain turn_on/turn_off on a uniform + // strip), interpolate math-only against a single start color. Avoiding the per-step read-back + // through the 8-bit stored byte prevents gamma round-trip quantization from stalling the fade + // at low values (e.g. gamma 2.8 pre-gamma values <27 round to stored 0, freezing progress). + this->uniform_start_ = false; + if (this->light_.size() > 0) { + Color first = this->light_[0].get(); + bool uniform = true; + for (int32_t i = 1; i < this->light_.size(); i++) { + if (this->light_[i].get() != first) { + uniform = false; + break; + } + } + if (uniform) { + this->uniform_start_ = true; + this->start_color_ = first; + } + } } inline constexpr uint8_t subtract_scaled_difference(uint8_t a, uint8_t b, int32_t scale) { @@ -97,12 +117,28 @@ optional AddressableLightTransformer::apply() { // non-linear when applying small deltas. if (smoothed_progress > this->last_transition_progress_ && this->last_transition_progress_ < 1.f) { - int32_t scale = int32_t(256.f * std::max((1.f - smoothed_progress) / (1.f - this->last_transition_progress_), 0.f)); - for (auto led : this->light_) { - led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale), - subtract_scaled_difference(this->target_color_.green, led.get_green(), scale), - subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale), - subtract_scaled_difference(this->target_color_.white, led.get_white(), scale)); + if (this->uniform_start_) { + // All LEDs started at the same color: compute the interpolated value once and write it to + // every LED. No read-back, so each LED's stored byte advances through every gamma threshold + // as smoothed_progress crosses it, instead of stalling at 0 for low pre-gamma values. + // lerp(start, target, progress) via existing helper: target - (target-start)*(1-progress). + int32_t remaining = int32_t(256.f * (1.f - smoothed_progress)); + uint8_t r = subtract_scaled_difference(this->target_color_.red, this->start_color_.red, remaining); + uint8_t g = subtract_scaled_difference(this->target_color_.green, this->start_color_.green, remaining); + uint8_t b = subtract_scaled_difference(this->target_color_.blue, this->start_color_.blue, remaining); + uint8_t w = subtract_scaled_difference(this->target_color_.white, this->start_color_.white, remaining); + for (auto led : this->light_) { + led.set_rgbw(r, g, b, w); + } + } else { + int32_t scale = + int32_t(256.f * std::max((1.f - smoothed_progress) / (1.f - this->last_transition_progress_), 0.f)); + for (auto led : this->light_) { + led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale), + subtract_scaled_difference(this->target_color_.green, led.get_green(), scale), + subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale), + subtract_scaled_difference(this->target_color_.white, led.get_white(), scale)); + } } this->last_transition_progress_ = smoothed_progress; this->light_.schedule_show(); diff --git a/esphome/components/light/addressable_light.h b/esphome/components/light/addressable_light.h index 17cdb7d6f6..684dcd4eb1 100644 --- a/esphome/components/light/addressable_light.h +++ b/esphome/components/light/addressable_light.h @@ -115,6 +115,8 @@ class AddressableLightTransformer : public LightTransformer { AddressableLight &light_; float last_transition_progress_{0.0f}; Color target_color_{}; + Color start_color_{}; + bool uniform_start_{false}; }; } // namespace esphome::light diff --git a/tests/integration/fixtures/addressable_light_transition.yaml b/tests/integration/fixtures/addressable_light_transition.yaml new file mode 100644 index 0000000000..7b847dd803 --- /dev/null +++ b/tests/integration/fixtures/addressable_light_transition.yaml @@ -0,0 +1,29 @@ +esphome: + name: addr-light-transition +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +light: + - platform: mock_addressable_light + output_id: strip_output + id: strip + name: "Test Strip" + num_leds: 4 + gamma_correct: 2.8 + default_transition_length: 0s + +sensor: + - platform: template + name: "led0_red_raw" + id: led0_red_raw + update_interval: 10ms + accuracy_decimals: 0 + lambda: |- + return (float) id(strip_output).get_raw_red(0); diff --git a/tests/integration/fixtures/external_components/mock_addressable_light/__init__.py b/tests/integration/fixtures/external_components/mock_addressable_light/__init__.py new file mode 100644 index 0000000000..e8cfff8e1f --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_addressable_light/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@esphome/tests"] diff --git a/tests/integration/fixtures/external_components/mock_addressable_light/light.py b/tests/integration/fixtures/external_components/mock_addressable_light/light.py new file mode 100644 index 0000000000..6a1a0a8596 --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_addressable_light/light.py @@ -0,0 +1,22 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.const import CONF_NUM_LEDS, CONF_OUTPUT_ID + +mock_addressable_light_ns = cg.esphome_ns.namespace("mock_addressable_light") +MockAddressableLight = mock_addressable_light_ns.class_( + "MockAddressableLight", light.AddressableLight +) + +CONFIG_SCHEMA = light.ADDRESSABLE_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(MockAddressableLight), + cv.Optional(CONF_NUM_LEDS, default=4): cv.positive_not_null_int, + } +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_OUTPUT_ID], config[CONF_NUM_LEDS]) + await light.register_light(var, config) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h b/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h new file mode 100644 index 0000000000..588b9c22a3 --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h @@ -0,0 +1,48 @@ +#pragma once + +#include "esphome/components/light/addressable_light.h" +#include "esphome/core/component.h" + +namespace esphome::mock_addressable_light { + +// In-memory addressable light for host-mode integration tests. Exposes the raw +// per-LED byte buffer (post-gamma-correction, as the hardware would see it) +// so tests can observe transition behavior without real hardware. +class MockAddressableLight : public light::AddressableLight { + public: + explicit MockAddressableLight(uint16_t num_leds) + : num_leds_(num_leds), buf_(new uint8_t[num_leds * 4]()), effect_data_(new uint8_t[num_leds]()) {} + + void setup() override {} + void write_state(light::LightState *state) override {} + int32_t size() const override { return this->num_leds_; } + void clear_effect_data() override { + for (uint16_t i = 0; i < this->num_leds_; i++) + this->effect_data_[i] = 0; + } + light::LightTraits get_traits() override { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::RGB}); + return traits; + } + + // Accessors for tests: return the raw stored byte (post gamma correction), + // which is what actual LED hardware would receive. + uint8_t get_raw_red(uint16_t index) const { return this->buf_[index * 4 + 0]; } + uint8_t get_raw_green(uint16_t index) const { return this->buf_[index * 4 + 1]; } + uint8_t get_raw_blue(uint16_t index) const { return this->buf_[index * 4 + 2]; } + uint8_t get_raw_white(uint16_t index) const { return this->buf_[index * 4 + 3]; } + + protected: + light::ESPColorView get_view_internal(int32_t index) const override { + size_t pos = index * 4; + return {this->buf_.get() + pos + 0, this->buf_.get() + pos + 1, this->buf_.get() + pos + 2, + this->buf_.get() + pos + 3, this->effect_data_.get() + index, &this->correction_}; + } + + uint16_t num_leds_; + std::unique_ptr buf_; + std::unique_ptr effect_data_; +}; + +} // namespace esphome::mock_addressable_light diff --git a/tests/integration/test_addressable_light_transition.py b/tests/integration/test_addressable_light_transition.py new file mode 100644 index 0000000000..8b4284b503 --- /dev/null +++ b/tests/integration/test_addressable_light_transition.py @@ -0,0 +1,119 @@ +"""Integration test for addressable light transitions with gamma correction. + +Regression test for a bug where a long turn-on transition on an addressable +light with gamma correction (e.g. gamma_correct: 2.8) produced no visible +output for ~90% of the transition duration, then jumped to the target in the +final ~10%. Root cause: the transition algorithm read each LED's current value +back through the 8-bit stored byte every step; at gamma 2.8 any pre-gamma value +below ~27 rounds to stored byte 0, so the stored byte stalled at 0 until +progress was high enough for a single step to produce a large-enough pre-gamma +value to clear the gamma threshold. + +The fix interpolates against a cached start color when all LEDs started at the +same value (the common case for plain turn_on/turn_off), avoiding the round-trip. + +This test uses a host-only mock addressable light that exposes the raw stored +byte of each LED, so we can observe the transition directly. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import SensorState +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_addressable_light_transition( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """With gamma 2.8, the stored raw byte must rise visibly well before the end.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + light = next(e for e in entities if e.object_id == "test_strip") + sensor = next(e for e in entities if e.object_id == "led0_red_raw") + + # Track the raw-byte sensor. It polls every 10ms in the fixture, and + # ESPHome sensors publish on every change, so we collect a time series. + loop = asyncio.get_event_loop() + samples: list[tuple[float, float]] = [] + start_time: float | None = None + + def on_state(state: object) -> None: + nonlocal start_time + if not isinstance(state, SensorState) or state.key != sensor.key: + return + now = loop.time() + if start_time is None: + start_time = now + samples.append((now - start_time, state.state)) + + client.subscribe_states(on_state) + + # Give the first poll a chance to land so we have a baseline of 0. + await asyncio.sleep(0.1) + + # Start transition: off -> full white over 1 second. This is the + # scenario from the bug report, compressed in time. + transition_s = 1.0 + client.light_command( + key=light.key, + state=True, + rgb=(1.0, 1.0, 1.0), + brightness=1.0, + transition_length=transition_s, + ) + + # Let the full transition run, plus margin for the final sample. + await asyncio.sleep(transition_s + 0.2) + + # Partition samples by transition progress. We reset the time origin + # at the moment the first post-command sample arrives, since there is + # some latency between issuing the command and the sensor observing + # the transition begin. + assert samples, "no sensor samples received" + + # Find first sample where the transition started producing nonzero + # output (or fall back to the first sample). + first_nonzero_idx = next((i for i, (_, v) in enumerate(samples) if v > 0), None) + assert first_nonzero_idx is not None, ( + "raw byte never rose above 0 during the transition — the fade stalled" + ) + + t0 = samples[first_nonzero_idx][0] + # Collect samples from the first nonzero point onward, re-based to t=0. + rel = [(t - t0, v) for (t, v) in samples[first_nonzero_idx:]] + + # Assertion 1: the transition is not stalled. With the bug, the raw + # byte stays at 0 until ~90% of the transition duration. With the fix, + # it becomes nonzero in the first ~30% (for gamma 2.8, pre-gamma 76 + # clears the gamma threshold at progress ~0.30). We assert that the + # first nonzero sample arrives well before 70% of the transition, + # giving generous slack for scheduling jitter. + first_nonzero_time = samples[first_nonzero_idx][0] - samples[0][0] + assert first_nonzero_time < transition_s * 0.7, ( + f"raw byte only rose above 0 at t={first_nonzero_time:.3f}s " + f"(>{transition_s * 0.7:.3f}s) — transition is stalling" + ) + + # Assertion 2: by the time the transition has had 70% of its duration + # to run from its first visible step, the raw byte should be at least + # ~half of its final value. This catches "barely moves then jumps at + # the end" regressions. + late_samples = [v for (t, v) in rel if t >= transition_s * 0.7] + assert late_samples, "no samples captured late in transition" + assert max(late_samples) >= 100, ( + f"raw byte peaked at only {max(late_samples)} late in transition " + "(expected >= 100 for white target at gamma 2.8)" + ) + + # Assertion 3: final value reaches target. Gamma 2.8 of 255 is 255. + final_samples = [v for (_, v) in samples[-5:]] + assert max(final_samples) >= 250, ( + f"final raw byte was {max(final_samples)}, expected >= 250" + ) From 4e8f98e7678ce59998a918626fd3e105ce485ad9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 14:22:13 -1000 Subject: [PATCH 04/10] [light] Collapse uniform-start flag+Color into optional --- .../components/light/addressable_light.cpp | 25 ++++++++----------- esphome/components/light/addressable_light.h | 3 +-- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index 671f10874d..f52f27c63d 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -63,20 +63,14 @@ void AddressableLightTransformer::start() { // strip), interpolate math-only against a single start color. Avoiding the per-step read-back // through the 8-bit stored byte prevents gamma round-trip quantization from stalling the fade // at low values (e.g. gamma 2.8 pre-gamma values <27 round to stored 0, freezing progress). - this->uniform_start_ = false; + this->uniform_start_color_.reset(); if (this->light_.size() > 0) { Color first = this->light_[0].get(); - bool uniform = true; for (int32_t i = 1; i < this->light_.size(); i++) { - if (this->light_[i].get() != first) { - uniform = false; - break; - } - } - if (uniform) { - this->uniform_start_ = true; - this->start_color_ = first; + if (this->light_[i].get() != first) + return; } + this->uniform_start_color_ = first; } } @@ -117,16 +111,17 @@ optional AddressableLightTransformer::apply() { // non-linear when applying small deltas. if (smoothed_progress > this->last_transition_progress_ && this->last_transition_progress_ < 1.f) { - if (this->uniform_start_) { + if (this->uniform_start_color_.has_value()) { // All LEDs started at the same color: compute the interpolated value once and write it to // every LED. No read-back, so each LED's stored byte advances through every gamma threshold // as smoothed_progress crosses it, instead of stalling at 0 for low pre-gamma values. // lerp(start, target, progress) via existing helper: target - (target-start)*(1-progress). + const Color &start = *this->uniform_start_color_; int32_t remaining = int32_t(256.f * (1.f - smoothed_progress)); - uint8_t r = subtract_scaled_difference(this->target_color_.red, this->start_color_.red, remaining); - uint8_t g = subtract_scaled_difference(this->target_color_.green, this->start_color_.green, remaining); - uint8_t b = subtract_scaled_difference(this->target_color_.blue, this->start_color_.blue, remaining); - uint8_t w = subtract_scaled_difference(this->target_color_.white, this->start_color_.white, remaining); + uint8_t r = subtract_scaled_difference(this->target_color_.red, start.red, remaining); + uint8_t g = subtract_scaled_difference(this->target_color_.green, start.green, remaining); + uint8_t b = subtract_scaled_difference(this->target_color_.blue, start.blue, remaining); + uint8_t w = subtract_scaled_difference(this->target_color_.white, start.white, remaining); for (auto led : this->light_) { led.set_rgbw(r, g, b, w); } diff --git a/esphome/components/light/addressable_light.h b/esphome/components/light/addressable_light.h index 684dcd4eb1..c4eabbad2e 100644 --- a/esphome/components/light/addressable_light.h +++ b/esphome/components/light/addressable_light.h @@ -115,8 +115,7 @@ class AddressableLightTransformer : public LightTransformer { AddressableLight &light_; float last_transition_progress_{0.0f}; Color target_color_{}; - Color start_color_{}; - bool uniform_start_{false}; + optional uniform_start_color_{}; }; } // namespace esphome::light From 6edadaa33bb9d1e971e31dbf4eeb26628318e494 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 14:31:42 -1000 Subject: [PATCH 05/10] [light] Use raw byte compare for uniformity scan to keep apply() hot path inlinable --- esphome/components/light/addressable_light.cpp | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index f52f27c63d..2b28bd562c 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -65,12 +65,21 @@ void AddressableLightTransformer::start() { // at low values (e.g. gamma 2.8 pre-gamma values <27 round to stored 0, freezing progress). this->uniform_start_color_.reset(); if (this->light_.size() > 0) { - Color first = this->light_[0].get(); + // Compare raw (post-gamma) bytes across LEDs for uniformity. This avoids N calls to the + // gamma-uncorrecting get_red/green/blue/white accessors, which would otherwise discourage + // the compiler from inlining them inside the apply() fallback path. + auto first = this->light_[0]; + uint8_t r_raw = first.get_red_raw(); + uint8_t g_raw = first.get_green_raw(); + uint8_t b_raw = first.get_blue_raw(); + uint8_t w_raw = first.get_white_raw(); for (int32_t i = 1; i < this->light_.size(); i++) { - if (this->light_[i].get() != first) + auto view = this->light_[i]; + if (view.get_red_raw() != r_raw || view.get_green_raw() != g_raw || view.get_blue_raw() != b_raw || + view.get_white_raw() != w_raw) return; } - this->uniform_start_color_ = first; + this->uniform_start_color_ = first.get(); } } From 93893e02a740f83da06ebfa973a11beda9535bf0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 14:35:23 -1000 Subject: [PATCH 06/10] Revert: raw-byte uniformity check (didn't help inlining) --- esphome/components/light/addressable_light.cpp | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index 2b28bd562c..f52f27c63d 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -65,21 +65,12 @@ void AddressableLightTransformer::start() { // at low values (e.g. gamma 2.8 pre-gamma values <27 round to stored 0, freezing progress). this->uniform_start_color_.reset(); if (this->light_.size() > 0) { - // Compare raw (post-gamma) bytes across LEDs for uniformity. This avoids N calls to the - // gamma-uncorrecting get_red/green/blue/white accessors, which would otherwise discourage - // the compiler from inlining them inside the apply() fallback path. - auto first = this->light_[0]; - uint8_t r_raw = first.get_red_raw(); - uint8_t g_raw = first.get_green_raw(); - uint8_t b_raw = first.get_blue_raw(); - uint8_t w_raw = first.get_white_raw(); + Color first = this->light_[0].get(); for (int32_t i = 1; i < this->light_.size(); i++) { - auto view = this->light_[i]; - if (view.get_red_raw() != r_raw || view.get_green_raw() != g_raw || view.get_blue_raw() != b_raw || - view.get_white_raw() != w_raw) + if (this->light_[i].get() != first) return; } - this->uniform_start_color_ = first.get(); + this->uniform_start_color_ = first; } } From 32130e1cb19dadf7e4ea319cbf308a499c2ce48c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 14:53:17 -1000 Subject: [PATCH 07/10] [light] Address Copilot review feedback on PR #15726 - mock_addressable_light.h: add direct // includes - test: use asyncio.get_running_loop() instead of deprecated get_event_loop() - test: rebase timing to command-issue time (not first-nonzero) and use absolute progress for assertion 2, so late-transition check can't skew when the first nonzero sample happens to land near the assertion-1 limit --- .../mock_addressable_light.h | 4 ++ .../test_addressable_light_transition.py | 67 +++++++++---------- 2 files changed, 34 insertions(+), 37 deletions(-) diff --git a/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h b/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h index 588b9c22a3..c6b0d10601 100644 --- a/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h +++ b/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h @@ -1,5 +1,9 @@ #pragma once +#include +#include +#include + #include "esphome/components/light/addressable_light.h" #include "esphome/core/component.h" diff --git a/tests/integration/test_addressable_light_transition.py b/tests/integration/test_addressable_light_transition.py index 8b4284b503..c642a6f841 100644 --- a/tests/integration/test_addressable_light_transition.py +++ b/tests/integration/test_addressable_light_transition.py @@ -40,18 +40,20 @@ async def test_addressable_light_transition( # Track the raw-byte sensor. It polls every 10ms in the fixture, and # ESPHome sensors publish on every change, so we collect a time series. - loop = asyncio.get_event_loop() + # Samples are stored as (seconds_since_command_issue, value). Times + # before the command was issued are negative. + loop = asyncio.get_running_loop() samples: list[tuple[float, float]] = [] - start_time: float | None = None + command_time: float | None = None def on_state(state: object) -> None: - nonlocal start_time if not isinstance(state, SensorState) or state.key != sensor.key: return now = loop.time() - if start_time is None: - start_time = now - samples.append((now - start_time, state.state)) + # If the command hasn't been issued yet, use 0 as the origin; + # those samples get negative times and are excluded below. + origin = command_time if command_time is not None else now + samples.append((now - origin, state.state)) client.subscribe_states(on_state) @@ -61,6 +63,7 @@ async def test_addressable_light_transition( # Start transition: off -> full white over 1 second. This is the # scenario from the bug report, compressed in time. transition_s = 1.0 + command_time = loop.time() client.light_command( key=light.key, state=True, @@ -72,48 +75,38 @@ async def test_addressable_light_transition( # Let the full transition run, plus margin for the final sample. await asyncio.sleep(transition_s + 0.2) - # Partition samples by transition progress. We reset the time origin - # at the moment the first post-command sample arrives, since there is - # some latency between issuing the command and the sensor observing - # the transition begin. - assert samples, "no sensor samples received" - - # Find first sample where the transition started producing nonzero - # output (or fall back to the first sample). - first_nonzero_idx = next((i for i, (_, v) in enumerate(samples) if v > 0), None) - assert first_nonzero_idx is not None, ( - "raw byte never rose above 0 during the transition — the fade stalled" - ) - - t0 = samples[first_nonzero_idx][0] - # Collect samples from the first nonzero point onward, re-based to t=0. - rel = [(t - t0, v) for (t, v) in samples[first_nonzero_idx:]] + # Only look at samples that arrived after the command was issued. + post_command = [(t, v) for (t, v) in samples if t >= 0] + assert post_command, "no sensor samples received after command was issued" # Assertion 1: the transition is not stalled. With the bug, the raw # byte stays at 0 until ~90% of the transition duration. With the fix, # it becomes nonzero in the first ~30% (for gamma 2.8, pre-gamma 76 - # clears the gamma threshold at progress ~0.30). We assert that the - # first nonzero sample arrives well before 70% of the transition, - # giving generous slack for scheduling jitter. - first_nonzero_time = samples[first_nonzero_idx][0] - samples[0][0] - assert first_nonzero_time < transition_s * 0.7, ( - f"raw byte only rose above 0 at t={first_nonzero_time:.3f}s " - f"(>{transition_s * 0.7:.3f}s) — transition is stalling" + # clears the gamma threshold at progress ~0.30). Require the first + # nonzero sample to land well before 70% of the transition duration, + # measured from the command-issue time. + first_nonzero = next(((t, v) for (t, v) in post_command if v > 0), None) + assert first_nonzero is not None, ( + "raw byte never rose above 0 during the transition — the fade stalled" + ) + assert first_nonzero[0] < transition_s * 0.7, ( + f"raw byte only rose above 0 at t={first_nonzero[0]:.3f}s " + f"(>{transition_s * 0.7:.3f}s after command) — transition is stalling" ) - # Assertion 2: by the time the transition has had 70% of its duration - # to run from its first visible step, the raw byte should be at least - # ~half of its final value. This catches "barely moves then jumps at - # the end" regressions. - late_samples = [v for (t, v) in rel if t >= transition_s * 0.7] + # Assertion 2: by 70% of the transition duration after the command, + # the raw byte should have reached a substantial fraction of its final + # value. This catches "barely moves then jumps at the end" regressions + # that pass assertion 1 but still stall most of the range. + late_samples = [v for (t, v) in post_command if t >= transition_s * 0.7] assert late_samples, "no samples captured late in transition" assert max(late_samples) >= 100, ( - f"raw byte peaked at only {max(late_samples)} late in transition " - "(expected >= 100 for white target at gamma 2.8)" + f"raw byte peaked at only {max(late_samples)} at/after 70% of " + "transition (expected >= 100 for white target at gamma 2.8)" ) # Assertion 3: final value reaches target. Gamma 2.8 of 255 is 255. - final_samples = [v for (_, v) in samples[-5:]] + final_samples = [v for (_, v) in post_command[-5:]] assert max(final_samples) >= 250, ( f"final raw byte was {max(final_samples)}, expected >= 250" ) From 8da24fd1d99cbc7ef604a9ddf376a576ff6c9e79 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 14:54:39 -1000 Subject: [PATCH 08/10] [light] Use existing integration test helpers in transition test --- .../test_addressable_light_transition.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/integration/test_addressable_light_transition.py b/tests/integration/test_addressable_light_transition.py index c642a6f841..3f99c87766 100644 --- a/tests/integration/test_addressable_light_transition.py +++ b/tests/integration/test_addressable_light_transition.py @@ -20,9 +20,10 @@ from __future__ import annotations import asyncio -from aioesphomeapi import SensorState +from aioesphomeapi import LightInfo, SensorInfo, SensorState import pytest +from .state_utils import InitialStateHelper, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction @@ -35,8 +36,8 @@ async def test_addressable_light_transition( """With gamma 2.8, the stored raw byte must rise visibly well before the end.""" async with run_compiled(yaml_config), api_client_connected() as client: entities, _ = await client.list_entities_services() - light = next(e for e in entities if e.object_id == "test_strip") - sensor = next(e for e in entities if e.object_id == "led0_red_raw") + light = require_entity(entities, "test_strip", LightInfo) + sensor = require_entity(entities, "led0_red_raw", SensorInfo) # Track the raw-byte sensor. It polls every 10ms in the fixture, and # ESPHome sensors publish on every change, so we collect a time series. @@ -55,10 +56,11 @@ async def test_addressable_light_transition( origin = command_time if command_time is not None else now samples.append((now - origin, state.state)) - client.subscribe_states(on_state) - - # Give the first poll a chance to land so we have a baseline of 0. - await asyncio.sleep(0.1) + # InitialStateHelper swallows the first state ESPHome sends per entity + # on subscribe, so on_state only sees real post-subscribe updates. + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() # Start transition: off -> full white over 1 second. This is the # scenario from the bug report, compressed in time. From b324630f8ec4b9e69ba9cda6461de4a3d93cdfa4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 14:56:02 -1000 Subject: [PATCH 09/10] [light] Type-annotate to_code in mock_addressable_light --- .../external_components/mock_addressable_light/light.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/integration/fixtures/external_components/mock_addressable_light/light.py b/tests/integration/fixtures/external_components/mock_addressable_light/light.py index 6a1a0a8596..293d2854f4 100644 --- a/tests/integration/fixtures/external_components/mock_addressable_light/light.py +++ b/tests/integration/fixtures/external_components/mock_addressable_light/light.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import light import esphome.config_validation as cv from esphome.const import CONF_NUM_LEDS, CONF_OUTPUT_ID +from esphome.types import ConfigType mock_addressable_light_ns = cg.esphome_ns.namespace("mock_addressable_light") MockAddressableLight = mock_addressable_light_ns.class_( @@ -16,7 +17,7 @@ CONFIG_SCHEMA = light.ADDRESSABLE_LIGHT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID], config[CONF_NUM_LEDS]) await light.register_light(var, config) await cg.register_component(var, config) From 12b55f176fb4f7b3a99d765087de3412048b3680 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 14:56:48 -1000 Subject: [PATCH 10/10] [light] Clearer uniformity scan + note edge case in uniform path --- esphome/components/light/addressable_light.cpp | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index f52f27c63d..5f5f123fe7 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -66,11 +66,15 @@ void AddressableLightTransformer::start() { this->uniform_start_color_.reset(); if (this->light_.size() > 0) { Color first = this->light_[0].get(); + bool uniform = true; for (int32_t i = 1; i < this->light_.size(); i++) { - if (this->light_[i].get() != first) - return; + if (this->light_[i].get() != first) { + uniform = false; + break; + } } - this->uniform_start_color_ = first; + if (uniform) + this->uniform_start_color_ = first; } } @@ -115,6 +119,11 @@ optional AddressableLightTransformer::apply() { // All LEDs started at the same color: compute the interpolated value once and write it to // every LED. No read-back, so each LED's stored byte advances through every gamma threshold // as smoothed_progress crosses it, instead of stalling at 0 for low pre-gamma values. + // + // Trade-off: any mid-transition writes to individual LEDs (e.g. from a user lambda) will be + // overwritten on the next apply() here. The fallback path below would have respected them + // via its read-back. Concurrent per-LED mutation during a transition isn't a pattern we + // support, so this is acceptable. // lerp(start, target, progress) via existing helper: target - (target-start)*(1-progress). const Color &start = *this->uniform_start_color_; int32_t remaining = int32_t(256.f * (1.f - smoothed_progress));