Merge branch 'dev' into kamilcuk/use-placement-new

This commit is contained in:
J. Nick Koston
2026-03-22 11:19:51 -10:00
committed by GitHub
22 changed files with 542 additions and 99 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ void setup() {
// Log functions call global_logger->log_vprintf_() without a null check,
// so we must set up a Logger before any test that triggers logging.
static esphome::logger::Logger test_logger(0);
static esphome::logger::Logger test_logger(0, 64);
test_logger.set_log_level(ESPHOME_LOG_LEVEL);
test_logger.pre_setup();
+1 -1
View File
@@ -22,7 +22,7 @@ void original_setup() {
void setup() {
// Log functions call global_logger->log_vprintf_() without a null check,
// so we must set up a Logger before any test that triggers logging.
static esphome::logger::Logger test_logger(0);
static esphome::logger::Logger test_logger(0, 64);
test_logger.set_log_level(ESPHOME_LOG_LEVEL);
test_logger.pre_setup();
+4
View File
@@ -6,4 +6,8 @@ sensor:
humidity:
name: SHT4X Humidity
address: 0x44
precision: High
heater_max_duty: 0.02
heater_power: High
heater_time: Long
update_interval: 15s
@@ -0,0 +1,14 @@
uart:
- id: uart_id
tx_pin: PA23
rx_pin: PA18
baud_rate: 9600
data_bits: 8
parity: NONE
stop_bits: 1
switch:
- platform: uart
name: "UART Switch"
uart_id: uart_id
data: [0x01, 0x02, 0x03]
@@ -0,0 +1,57 @@
esphome:
name: light-cb-test
host:
api: # Port will be automatically injected
logger:
level: DEBUG
output:
- platform: template
id: cb_cold_white_output
type: float
write_action:
- logger.log:
format: "CB_CW_OUTPUT:%.6f"
args: [state]
- platform: template
id: cb_warm_white_output
type: float
write_action:
- logger.log:
format: "CB_WW_OUTPUT:%.6f"
args: [state]
- platform: template
id: ncb_cold_white_output
type: float
write_action:
- logger.log:
format: "NCB_CW_OUTPUT:%.6f"
args: [state]
- platform: template
id: ncb_warm_white_output
type: float
write_action:
- logger.log:
format: "NCB_WW_OUTPUT:%.6f"
args: [state]
light:
- platform: cwww
name: "Test CB Light"
id: test_cb_light
cold_white: cb_cold_white_output
warm_white: cb_warm_white_output
cold_white_color_temperature: 6536 K
warm_white_color_temperature: 2000 K
constant_brightness: true
gamma_correct: 2.8
- platform: cwww
name: "Test NCB Light"
id: test_ncb_light
cold_white: ncb_cold_white_output
warm_white: ncb_warm_white_output
cold_white_color_temperature: 6536 K
warm_white_color_temperature: 2000 K
constant_brightness: false
gamma_correct: 2.8
@@ -0,0 +1,188 @@
"""Integration test for constant_brightness with gamma correction.
Tests both constant_brightness: true and false cwww lights with gamma
correction in a single compilation to verify:
- constant_brightness: true maintains constant total CW+WW power output
- constant_brightness: false correctly varies total power across color temps
This is a regression test for https://github.com/esphome/esphome/issues/15040
where the gamma LUT refactor (#14123) broke constant_brightness by applying
gamma after the balancing formula instead of before it.
"""
from __future__ import annotations
import asyncio
import re
from typing import Any
from aioesphomeapi import EntityState, LightInfo, LightState
import pytest
from .state_utils import InitialStateHelper
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_light_constant_brightness(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test constant_brightness true and false behavior with gamma correction."""
# Track output values for both lights from log lines
cb_cw_pattern = re.compile(r"(?<!N)CB_CW_OUTPUT:([\d.]+)")
cb_ww_pattern = re.compile(r"(?<!N)CB_WW_OUTPUT:([\d.]+)")
ncb_cw_pattern = re.compile(r"NCB_CW_OUTPUT:([\d.]+)")
ncb_ww_pattern = re.compile(r"NCB_WW_OUTPUT:([\d.]+)")
latest: dict[str, float] = {
"cb_cw": 0.0,
"cb_ww": 0.0,
"ncb_cw": 0.0,
"ncb_ww": 0.0,
}
def on_log_line(line: str) -> None:
for pattern, key in [
(cb_cw_pattern, "cb_cw"),
(cb_ww_pattern, "cb_ww"),
(ncb_cw_pattern, "ncb_cw"),
(ncb_ww_pattern, "ncb_ww"),
]:
match = pattern.search(line)
if match:
latest[key] = float(match.group(1))
loop = asyncio.get_running_loop()
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
lights = [e for e in entities if isinstance(e, LightInfo)]
cb_light = next(e for e in lights if e.object_id.endswith("cb_light"))
ncb_light = next(e for e in lights if e.object_id.endswith("ncb_light"))
# Use InitialStateHelper to wait for initial state broadcast
initial_state_helper = InitialStateHelper(entities)
# Track state changes per light key
state_futures: dict[int, asyncio.Future[EntityState]] = {}
def on_state(state: EntityState) -> None:
if isinstance(state, LightState) and state.key in state_futures:
future = state_futures[state.key]
if not future.done():
future.set_result(state)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
async def send_and_wait(
light_key: int, timeout: float = 5.0, **kwargs: Any
) -> LightState:
"""Send a light command and wait for the state response."""
state_futures[light_key] = loop.create_future()
client.light_command(key=light_key, **kwargs)
try:
return await asyncio.wait_for(state_futures[light_key], timeout=timeout)
except TimeoutError:
pytest.fail(f"Timeout waiting for light state after command: {kwargs}")
# --- Test constant_brightness: true ---
# Turn on CB light at full brightness
await send_and_wait(
cb_light.key,
state=True,
brightness=1.0,
color_temperature=153.0,
transition_length=0,
)
test_mireds = [
153.0, # Pure cold white
200.0, # Mostly cold
280.0, # Mixed
326.5, # Midpoint
400.0, # Mostly warm
500.0, # Pure warm white
]
cb_totals: list[tuple[float, float, float]] = []
for mireds in test_mireds:
await send_and_wait(
cb_light.key, color_temperature=mireds, transition_length=0
)
cb_totals.append((mireds, latest["cb_cw"], latest["cb_ww"]))
# All totals should be approximately equal (constant brightness)
reference_total = next((cw + ww for _, cw, ww in cb_totals if cw + ww > 0), 0)
assert reference_total > 0, (
f"Reference total power is zero, CB light outputs not working. "
f"Values: {cb_totals}"
)
for mireds, cw, ww in cb_totals:
total = cw + ww
assert total == pytest.approx(reference_total, rel=0.05), (
f"constant_brightness: Total power at {mireds} mireds "
f"({total:.4f}) differs from reference ({reference_total:.4f}) "
f"by more than 5%. CW={cw:.4f}, WW={ww:.4f}. "
f"All values: {cb_totals}"
)
# --- Test constant_brightness: false ---
# Turn on NCB light at full brightness
await send_and_wait(
ncb_light.key,
state=True,
brightness=1.0,
color_temperature=153.0,
transition_length=0,
)
ncb_totals: list[tuple[float, float, float]] = []
for mireds in test_mireds:
await send_and_wait(
ncb_light.key, color_temperature=mireds, transition_length=0
)
ncb_totals.append((mireds, latest["ncb_cw"], latest["ncb_ww"]))
extreme_cw = ncb_totals[0] # 153 mireds - pure cold
extreme_ww = ncb_totals[-1] # 500 mireds - pure warm
midpoint = ncb_totals[3] # 326.5 mireds - midpoint
# At pure cold white, WW should be ~0
assert extreme_cw[2] == pytest.approx(0.0, abs=0.01), (
f"Pure cold white should have WW~0, got WW={extreme_cw[2]:.4f}"
)
# At pure warm white, CW should be ~0
assert extreme_ww[1] == pytest.approx(0.0, abs=0.01), (
f"Pure warm white should have CW~0, got CW={extreme_ww[1]:.4f}"
)
# At midpoint, both channels should be non-zero
assert midpoint[1] > 0.05, f"Midpoint CW should be >0.05, got {midpoint[1]:.4f}"
assert midpoint[2] > 0.05, f"Midpoint WW should be >0.05, got {midpoint[2]:.4f}"
# Total power at midpoint should be higher than at the extremes
midpoint_total = midpoint[1] + midpoint[2]
extreme_cw_total = extreme_cw[1] + extreme_cw[2]
extreme_ww_total = extreme_ww[1] + extreme_ww[2]
assert midpoint_total > extreme_cw_total, (
f"Midpoint total ({midpoint_total:.4f}) should be > pure CW total "
f"({extreme_cw_total:.4f}). All values: {ncb_totals}"
)
assert midpoint_total > extreme_ww_total, (
f"Midpoint total ({midpoint_total:.4f}) should be > pure WW total "
f"({extreme_ww_total:.4f}). All values: {ncb_totals}"
)
@@ -0,0 +1,117 @@
"""Tests for the gamma LUT table generation."""
import pytest
from esphome.components.light import generate_gamma_table
def _simulate_gamma_correct_lut(table: list[int], value: float) -> float:
"""Simulate the C++ gamma_correct_lut interpolation from light_state.cpp."""
if value <= 0.0:
return 0.0
if value >= 1.0:
return 1.0
scaled = value * 255.0
idx = int(scaled)
if idx >= 255:
return table[255] / 65535.0
frac = scaled - idx
a = float(table[idx])
b = float(table[idx + 1])
return (a + frac * (b - a)) / 65535.0
def test_table_length() -> None:
"""Table must always have exactly 256 entries."""
table = generate_gamma_table(2.8)
assert len(table) == 256
def test_index_zero_is_zero() -> None:
"""Index 0 must be 0 so true off remains off."""
for gamma in (1.0, 2.0, 2.2, 2.8, 3.0):
table = generate_gamma_table(gamma)
assert table[0] == 0, f"gamma={gamma}"
def test_index_255_is_max() -> None:
"""Index 255 must be 65535 (full on)."""
for gamma in (1.0, 2.0, 2.2, 2.8, 3.0):
table = generate_gamma_table(gamma)
assert table[255] == 65535, f"gamma={gamma}"
@pytest.mark.parametrize("gamma", [1.0, 2.0, 2.2, 2.8, 3.0])
def test_nonzero_indices_are_nonzero(gamma: float) -> None:
"""All indices > 0 must produce non-zero values.
This prevents zero_means_zero breakage: non-zero input must always
produce non-zero output so FloatOutput applies min_power scaling.
"""
table = generate_gamma_table(gamma)
for i in range(1, 256):
assert table[i] >= 1, f"gamma={gamma}, index {i}: got {table[i]}"
@pytest.mark.parametrize("gamma", [1.0, 2.0, 2.2, 2.8, 3.0])
def test_table_monotonically_nondecreasing(gamma: float) -> None:
"""The gamma table must be monotonically non-decreasing."""
table = generate_gamma_table(gamma)
for i in range(1, 256):
assert table[i] >= table[i - 1], (
f"gamma={gamma}: table[{i}]={table[i]} < table[{i - 1}]={table[i - 1]}"
)
def test_linear_gamma() -> None:
"""With gamma=0 (linear), table should be evenly spaced."""
table = generate_gamma_table(0)
assert table[0] == 0
assert table[128] == round(128 / 255.0 * 65535)
assert table[255] == 65535
@pytest.mark.parametrize("brightness", [0.01, 0.005, 0.001, 1 / 255])
def test_small_brightness_nonzero_after_lut(brightness: float) -> None:
"""Small but non-zero brightness must produce non-zero output through the LUT.
Regression test for #15055: with zero_means_zero=true, a gamma-corrected
value of exactly 0.0 causes FloatOutput to skip min_power scaling, turning
the LED off instead of to minimum brightness.
"""
table = generate_gamma_table(2.8)
result = _simulate_gamma_correct_lut(table, brightness)
assert result > 0.0, (
f"brightness={brightness}: gamma LUT returned 0.0, would break zero_means_zero"
)
@pytest.mark.parametrize("gamma", [1.0, 2.0, 2.2, 2.8, 3.0])
def test_small_brightness_nonzero_all_gammas(gamma: float) -> None:
"""1% brightness must be non-zero for all common gamma values."""
table = generate_gamma_table(gamma)
result = _simulate_gamma_correct_lut(table, 0.01)
assert result > 0.0, f"gamma={gamma}: 1% brightness returned 0.0"
def test_lut_zero_returns_zero() -> None:
"""LUT with input 0.0 must return 0.0."""
table = generate_gamma_table(2.8)
assert _simulate_gamma_correct_lut(table, 0.0) == 0.0
def test_lut_one_returns_one() -> None:
"""LUT with input 1.0 must return 1.0."""
table = generate_gamma_table(2.8)
assert _simulate_gamma_correct_lut(table, 1.0) == 1.0
def test_lut_output_monotonically_nondecreasing() -> None:
"""LUT output must be monotonically non-decreasing across the full range."""
table = generate_gamma_table(2.8)
prev = 0.0
for i in range(1001):
value = i / 1000.0
result = _simulate_gamma_correct_lut(table, value)
assert result >= prev, f"value={value}: result {result} < previous {prev}"
prev = result