[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).
This commit is contained in:
J. Nick Koston
2026-04-13 14:17:21 -10:00
parent 21df5d9bf6
commit 3f56e0255a
7 changed files with 263 additions and 6 deletions
+42 -6
View File
@@ -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<LightColorValues> 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();
@@ -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
@@ -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);
@@ -0,0 +1 @@
CODEOWNERS = ["@esphome/tests"]
@@ -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)
@@ -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<uint8_t[]> buf_;
std::unique_ptr<uint8_t[]> effect_data_;
};
} // namespace esphome::mock_addressable_light
@@ -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"
)