Files
esphome/tests/integration/test_addressable_light_transition.py

120 lines
5.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 LightInfo, SensorInfo, SensorState
import pytest
from .state_utils import InitialStateHelper, require_entity
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 = 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.
# Samples are stored as absolute (loop_time, value); we rebase to the
# command-issue time after the run so pre-command samples are strictly
# negative and reliably excluded.
loop = asyncio.get_running_loop()
samples: list[tuple[float, float]] = []
def on_state(state: object) -> None:
if not isinstance(state, SensorState) or state.key != sensor.key:
return
samples.append((loop.time(), state.state))
# 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.
transition_s = 1.0
command_time = loop.time()
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)
# Rebase to command-issue time. Pre-command samples have t < 0 and are
# excluded; everything else is in seconds since the command was issued.
post_command = [
(t - command_time, v) for (t, v) in samples if t >= command_time
]
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). Require the first
# nonzero sample to land well before 50% of the transition duration,
# measured from the command-issue time. The 50% bound (rather than
# 70%) leaves headroom for assertion 2's mid-window check.
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.5, (
f"raw byte only rose above 0 at t={first_nonzero[0]:.3f}s "
f"(>{transition_s * 0.5:.3f}s after command) — transition is stalling"
)
# Assertion 2: by mid-late transition, the raw byte should have reached
# a substantial fraction of its final value. Bound the window to
# [50%, 90%] of the transition so the post-transition settled value
# (which always reaches 255) can't satisfy this assertion — that would
# let "stays at 0 then jumps at 99%" regressions slip through.
mid_window = [
v
for (t, v) in post_command
if transition_s * 0.5 <= t <= transition_s * 0.9
]
assert mid_window, "no samples captured in mid-transition window"
assert max(mid_window) >= 100, (
f"raw byte peaked at only {max(mid_window)} between 50%90% 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 post_command[-5:]]
assert max(final_samples) >= 250, (
f"final raw byte was {max(final_samples)}, expected >= 250"
)