Repository: esphome/esphome (2026-03-21T18:15:14Z)

Issue:
libretiny_pwm: light turns off at 1% brightness with zero_means_zero: true, min_power setting has no effect
### The problem

**Problem Statement**
When using `libretiny_pwm` output with `zero_means_zero: true` and `min_power` configured, the LED turns off completely when brightness is set to 1%. Changing the `min_power` value (increasing it) has no effect — the LED still turns off at 1% brightness.

If `zero_means_zero: false` is set, the LED stays on even at 1% brightness (which is expected for this mode), but then the LED cannot be turned off completely.

This behavior differs from ESPHome 2025.6, where the min_power setting worked correctly and allowed the LED to stay on at minimum brightness when set to 1%.

**Expected Behavior**
With `zero_means_zero: true` and brightness set to 1%, the PWM output should output the `min_power` value, allowing the LED to stay on at a stable minimum brightness. Changing `min_power` should affect the minimum brightness level.

**Actual Behavior (ESPHome 2026.3.0)**
With `zero_means_zero: true`: setting brightness to 1% completely turns off the LED

The `min_power` parameter has no effect on this behavior (the LED turns off regardless of `min_power` value)

With `zero_means_zero: false`: the LED stays on at 1% brightness but cannot be turned off completely

**Working Configuration (ESPHome 2025.6)**
```yaml
bk72xx:
  board: cbu

output:
  - platform: libretiny_pwm
    pin: P8
    id: id_output_cold
    min_power: 0.01  # 1% minimum PWM for stable operation
    max_power: 0.3   # 30% maximum PWM
    zero_means_zero: true

  - platform: libretiny_pwm
    pin: P7
    id: id_output_warm
    min_power: 0.01  # 1% minimum PWM for stable operation
    max_power: 0.3   # 30% maximum PWM
    zero_means_zero: true

light:
  - platform: cwww
    name: "Test Light"
    cold_white: id_output_cold
    warm_white: id_output_warm
    cold_white_color_temperature: 6500 K
    warm_white_color_temperature: 2700 K
    constant_brightness: false
    id: id_ceiling_light
    restore_mode: RESTORE_DEFAULT_ON
    default_transition_length: 1s
```

**Non-Working Configuration (ESPHome 2026.3.0)**
Same configuration as above. When setting brightness to 1%:

- With `zero_means_zero: true` → LED turns OFF (changing min_power from 0.01 to 0.05, 0.1, etc. has no effect)
- With `zero_means_zero: false` → LED stays ON at minimum brightness

**Steps to Reproduce**

1. Flash ESPHome 2026.3.0 to a BK7231N/CBU device using the configuration above
2. Set the light brightness to 1% via HA or ESPHome API
3. Observe that the LED turns off completely
4. Try increasing `min_power` to 0.05, 0.1, 0.2
5. Repeat step 2 — the LED still turns off
6. Change `zero_means_zero: false` and repeat step 2
7. Observe that the LED stays on at 1% brightness
8. Flash ESPHome 2025.6 with the same configuration
9. Set brightness to 1% — the LED stays on at minimum brightness as configured by `min_power`

**Additional Context**

- Hardware: BK7231N (CBU module)
- Platform: libretiny_pwm
- ESPHome Version: 2026.3.0 (broken), 2025.6 (working)
- Light Platform: cwww with two PWM outputs

**Workaround**
Currently, the only way to achieve correct behavior (LED stays on at 1% brightness and can be turned off completely) is to use ESPHome 2025.6.

**Proposed Fix**
When `zero_means_zero: true`, the mapping between light brightness (0-100%) and PWM output should properly respect `min_power `as the minimum output value for any non-zero brightness command. In the current version 2026.3.0, it appears that for brightness values below a certain threshold, the output is set to 0, ignoring `min_power`.




### Which version of ESPHome has the issue?

ESPHome 2026.3.0

### What type of installation are you using?

Home Assistant Add-on

### What platform are you using?

BK72XX

### Component causing the issue

libretiny_pwm

### YAML Config

```yaml
bk72xx:
  board: cbu

output:
  - platform: libretiny_pwm
    pin: P8
    id: id_output_cold
    min_power: 0.01  # 1% minimum PWM for stable operation
    max_power: 0.3   # 30% maximum PWM
    zero_means_zero: true

  - platform: libretiny_pwm
    pin: P7
    id: id_output_warm
    min_power: 0.01  # 1% minimum PWM for stable operation
    max_power: 0.3   # 30% maximum PWM
    zero_means_zero: true

light:
  - platform: cwww
    name: "Test Light"
    cold_white: id_output_cold
    warm_white: id_output_warm
    cold_white_color_temperature: 6500 K
    warm_white_color_temperature: 2700 K
    constant_brightness: false
    id: id_ceiling_light
    restore_mode: RESTORE_DEFAULT_ON
    default_transition_length: 1s
```

### Anything in the logs that might be useful for us?

```txt

```

### Additional information

_No response_


Discussion:
Logs?  1% might be rounding to 0.



Patch:
diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py
index 4403281116a0..64452e428203 100644
--- a/esphome/components/light/__init__.py
+++ b/esphome/components/light/__init__.py
@@ -81,18 +81,32 @@ def _get_data() -> LightData:
     return CORE.data[DOMAIN]
 
 
+def generate_gamma_table(gamma_correct: float) -> list[HexInt]:
+    """Generate a 256-entry uint16 gamma lookup table.
+
+    For gamma > 0, non-zero indices are clamped to a minimum of 1 to preserve
+    the invariant that non-zero input always produces non-zero output. Without
+    this, small brightness values (e.g. 1%) get quantized to exactly 0.0,
+    which breaks zero_means_zero logic in FloatOutput.
+    """
+    if gamma_correct > 0:
+        return [
+            HexInt(
+                max(1, min(65535, int(round((i / 255.0) ** gamma_correct * 65535))))
+                if i > 0
+                else HexInt(0)
+            )
+            for i in range(256)
+        ]
+    return [HexInt(int(round(i / 255.0 * 65535))) for i in range(256)]
+
+
 def _get_or_create_gamma_table(gamma_correct):
     data = _get_data()
     if gamma_correct in data.gamma_tables:
         return data.gamma_tables[gamma_correct]
 
-    if gamma_correct > 0:
-        forward = [
-            HexInt(min(65535, int(round((i / 255.0) ** gamma_correct * 65535))))
-            for i in range(256)
-        ]
-    else:
-        forward = [HexInt(int(round(i / 255.0 * 65535))) for i in range(256)]
+    forward = generate_gamma_table(gamma_correct)
 
     gamma_str = f"{gamma_correct}".replace(".", "_")
     fwd_id = ID(f"gamma_{gamma_str}_fwd", is_declaration=True, type=cg.uint16)


Test patch:
diff --git a/tests/unit_tests/components/light/__init__.py b/tests/unit_tests/components/light/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/tests/unit_tests/components/light/test_gamma_table.py b/tests/unit_tests/components/light/test_gamma_table.py
new file mode 100644
index 000000000000..a302a355dc7e
--- /dev/null
+++ b/tests/unit_tests/components/light/test_gamma_table.py
@@ -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