mirror of
https://github.com/esphome/esphome.git
synced 2026-05-22 01:42:49 +08:00
120 lines
5.5 KiB
Python
120 lines
5.5 KiB
Python
"""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"
|
||
)
|