diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 50b84b630d..526afa914b 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -648,7 +648,10 @@ class DriverChip: sequence.append((INVON,)) else: sequence.append((INVOFF,)) - if brightness := config.get(CONF_BRIGHTNESS, self.get_default(CONF_BRIGHTNESS)): + brightness = config.get( + CONF_BRIGHTNESS, self.get_default(CONF_BRIGHTNESS, None) + ) + if brightness is not None: sequence.append((BRIGHTNESS, brightness)) # Add a SLPOUT command if required. if not self.skip_command("SLPOUT"): diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index b49675ca4c..ff49979a24 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -225,10 +225,10 @@ def model_schema(config: ConfigType) -> cv.All | cv.Schema: ) .extend({model.option(x): cv.boolean for x in other_options}) ) - if brightness := model.get_default(CONF_BRIGHTNESS): + if model.get_default(CONF_BRIGHTNESS, None) is not None or model.name == "CUSTOM": schema = schema.extend( { - cv.Optional(CONF_BRIGHTNESS, default=brightness): cv.int_range( + model.option(CONF_BRIGHTNESS, cv.UNDEFINED): cv.int_range( 0, 0xFF, min_included=True, max_included=True ), } diff --git a/esphome/components/mipi_spi/light/__init__.py b/esphome/components/mipi_spi/light/__init__.py new file mode 100644 index 0000000000..aec5d9b4fd --- /dev/null +++ b/esphome/components/mipi_spi/light/__init__.py @@ -0,0 +1,68 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.const import ( + CONF_BRIGHTNESS, + CONF_DISPLAY_ID, + CONF_GAMMA_CORRECT, + CONF_MAX_BRIGHTNESS, + CONF_MIN_BRIGHTNESS, + CONF_MODEL, + CONF_OUTPUT_ID, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from ..display import MipiSpi, mipi_spi_ns + +MipiSpiLight = mipi_spi_ns.class_("MipiSpiLight", light.LightOutput) + + +def _validate_brightness_range(config: ConfigType) -> ConfigType: + if config[CONF_MIN_BRIGHTNESS] >= config[CONF_MAX_BRIGHTNESS]: + raise cv.Invalid( + f"'{CONF_MIN_BRIGHTNESS}' must be less than '{CONF_MAX_BRIGHTNESS}'" + ) + return config + + +CONFIG_SCHEMA = cv.All( + light.BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(MipiSpiLight), + cv.GenerateID(CONF_DISPLAY_ID): cv.use_id(MipiSpi), + cv.Optional(CONF_GAMMA_CORRECT, default=1.0): cv.positive_float, + cv.Optional(CONF_MIN_BRIGHTNESS, default=0): cv.int_range(0, 255), + cv.Optional(CONF_MAX_BRIGHTNESS, default=255): cv.int_range(0, 255), + } + ), + _validate_brightness_range, +) + + +def _final_validate(config: ConfigType) -> None: + full_config = fv.full_config.get() + display_path = full_config.get_path_for_id(config[CONF_DISPLAY_ID])[:-1] + display_config = full_config.get_config_for_path(display_path) + if CONF_BRIGHTNESS not in display_config: + model = display_config[CONF_MODEL] + if model == "CUSTOM": + raise cv.Invalid( + f"The '{CONF_BRIGHTNESS}' option must be set in the display config" + ) + raise cv.Invalid( + f"Display model {model} does not support setting brightness with an SPI command" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable( + config[CONF_OUTPUT_ID], + config[CONF_MIN_BRIGHTNESS], + config[CONF_MAX_BRIGHTNESS], + ) + await light.register_light(var, config) + await cg.register_parented(var, config[CONF_DISPLAY_ID]) diff --git a/esphome/components/mipi_spi/light/mipi_spi_light.h b/esphome/components/mipi_spi/light/mipi_spi_light.h new file mode 100644 index 0000000000..30bbdcd8ee --- /dev/null +++ b/esphome/components/mipi_spi/light/mipi_spi_light.h @@ -0,0 +1,36 @@ +#pragma once + +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" +#include "esphome/components/mipi_spi/mipi_spi.h" +#include "esphome/core/helpers.h" + +namespace esphome::mipi_spi { + +// Brightness-only light that sets the display brightness with an SPI command. +// Brightness is scaled into [min_brightness, max_brightness]; off writes min_brightness. +class MipiSpiLight final : public light::LightOutput, public Parented { + public: + MipiSpiLight(uint8_t min_brightness, uint8_t max_brightness) + : min_brightness_(min_brightness), max_brightness_(max_brightness) {} + + light::LightTraits get_traits() override { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::BRIGHTNESS}); + return traits; + } + + void write_state(light::LightState *state) override { + float brightness; + state->current_values_as_brightness(&brightness); + this->parent_->set_brightness( + this->min_brightness_ + + static_cast(roundf(brightness * (this->max_brightness_ - this->min_brightness_)))); + } + + protected: + uint8_t min_brightness_; + uint8_t max_brightness_; +}; + +} // namespace esphome::mipi_spi diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index e428a1f96e..48a2cafbdd 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -72,6 +72,12 @@ void internal_dump_config(const char *model, int width, int height, int offset_w GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width, bool has_hardware_rotation); +// Lets a light set the display brightness without knowing the display's template parameters. +class MipiSpiBrightness { + public: + virtual void set_brightness(uint8_t brightness) = 0; +}; + /** * Base class for MIPI SPI displays. * All the methods are defined here in the header file, as it is not possible to define templated methods in a cpp file. @@ -94,6 +100,7 @@ template class MipiSpi : public display::Display, + public MipiSpiBrightness, public spi::SPIDevice { public: @@ -108,9 +115,11 @@ class MipiSpi : public display::Display, this->invert_colors_ = invert_colors; this->reset_params_(); } - void set_brightness(uint8_t brightness) { + void set_brightness(uint8_t brightness) override { this->brightness_ = brightness; - this->reset_params_(); + // Before setup the stored value is applied by reset_params_() + if (this->is_ready()) + this->write_command_(BRIGHTNESS, brightness); } void set_rotation(display::DisplayRotation rotation) override { this->rotation_ = rotation; diff --git a/tests/component_tests/mipi_spi/test_get_sequence.py b/tests/component_tests/mipi_spi/test_get_sequence.py index 263b567b9c..f8614b17a3 100644 --- a/tests/component_tests/mipi_spi/test_get_sequence.py +++ b/tests/component_tests/mipi_spi/test_get_sequence.py @@ -4,7 +4,13 @@ from collections.abc import Generator import pytest -from esphome.components.mipi import CONF_INVERT_COLORS, CONF_PIXEL_MODE, DriverChip +from esphome.components.mipi import ( + BRIGHTNESS, + CONF_INVERT_COLORS, + CONF_PIXEL_MODE, + DriverChip, +) +from esphome.const import CONF_BRIGHTNESS # A minimal config with no reset pin: enough for get_sequence(add_madctl=False) to run # without needing a full display configuration. @@ -62,3 +68,31 @@ def test_get_sequence_skips_reset_delay_validation_without_add_reset() -> None: chip = DriverChip("TEST-GET-SEQUENCE-NO-RESET", reset_delay=999) chip.get_sequence(_BASE_CONFIG, add_madctl=False, add_reset=False) + + +def _has_brightness_command(sequence: tuple[int, ...], value: int) -> bool: + """True if the flattened sequence holds a one-byte brightness (0x51) command.""" + return any( + sequence[i : i + 3] == (BRIGHTNESS, 1, value) for i in range(len(sequence) - 2) + ) + + +@pytest.mark.parametrize("brightness", [0, 0x80]) +def test_get_sequence_adds_configured_brightness(brightness: int) -> None: + """A configured brightness is sent at startup, including zero.""" + chip = DriverChip("TEST-GET-SEQUENCE-BRIGHTNESS") + + sequence = chip.get_sequence( + {**_BASE_CONFIG, CONF_BRIGHTNESS: brightness}, add_madctl=False + ) + + assert _has_brightness_command(sequence, brightness) + + +def test_get_sequence_omits_brightness_when_not_set() -> None: + """No brightness command is sent when neither config nor model sets one.""" + chip = DriverChip("TEST-GET-SEQUENCE-NO-BRIGHTNESS") + + sequence = chip.get_sequence(_BASE_CONFIG, add_madctl=False) + + assert not any(_has_brightness_command(sequence, v) for v in range(256)) diff --git a/tests/component_tests/mipi_spi/test_light.py b/tests/component_tests/mipi_spi/test_light.py new file mode 100644 index 0000000000..c42b56cf5a --- /dev/null +++ b/tests/component_tests/mipi_spi/test_light.py @@ -0,0 +1,126 @@ +"""Tests for the mipi_spi light platform validation.""" + +from __future__ import annotations + +import pytest + +from esphome import final_validate +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mipi_spi.display import CONFIG_SCHEMA as DISPLAY_SCHEMA +from esphome.components.mipi_spi.light import ( + CONFIG_SCHEMA as LIGHT_SCHEMA, + FINAL_VALIDATE_SCHEMA as LIGHT_FINAL_VALIDATE_SCHEMA, +) +from esphome.config import Config +import esphome.config_validation as cv +from esphome.const import ( + CONF_BRIGHTNESS, + CONF_DISPLAY_ID, + CONF_GAMMA_CORRECT, + CONF_ID, + CONF_MAX_BRIGHTNESS, + CONF_MIN_BRIGHTNESS, + PlatformFramework, +) +from esphome.core import ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +DISPLAY_ID = "test_display" + +_CUSTOM: ConfigType = { + "model": "custom", + "dimensions": {"width": 240, "height": 240}, + "init_sequence": [[0xA0, 0x01]], +} + + +@pytest.fixture(autouse=True) +def _esp32(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + +def _light(**extra: object) -> ConfigType: + return { + "name": "Display Brightness", + CONF_DISPLAY_ID: DISPLAY_ID, + **extra, + } + + +def _final_validate_with_display(display: ConfigType) -> None: + """Validate a display, place it in the full config, then final-validate a light on it.""" + display = DISPLAY_SCHEMA( + {"id": DISPLAY_ID, "dc_pin": 18, **display}, + ) + full = Config() + full["display"] = [display] + full.declare_ids.append((display[CONF_ID], ["display", 0, CONF_ID])) + final_validate.full_config.set(full) + LIGHT_FINAL_VALIDATE_SCHEMA({CONF_DISPLAY_ID: ID(DISPLAY_ID, is_declaration=False)}) + + +def test_light_defaults() -> None: + """Gamma defaults to 1.0 and the range to the full 0-255.""" + config = LIGHT_SCHEMA(_light()) + + assert config[CONF_GAMMA_CORRECT] == 1.0 + assert config[CONF_MIN_BRIGHTNESS] == 0 + assert config[CONF_MAX_BRIGHTNESS] == 255 + + +@pytest.mark.parametrize( + ("min_brightness", "max_brightness"), + [(100, 100), (200, 100)], + ids=["equal", "inverted"], +) +def test_light_rejects_bad_range(min_brightness: int, max_brightness: int) -> None: + with pytest.raises( + cv.Invalid, match="'min_brightness' must be less than 'max_brightness'" + ): + LIGHT_SCHEMA( + _light(min_brightness=min_brightness, max_brightness=max_brightness) + ) + + +@pytest.mark.parametrize("key", [CONF_MIN_BRIGHTNESS, CONF_MAX_BRIGHTNESS]) +def test_light_rejects_out_of_range_value(key: str) -> None: + with pytest.raises(cv.Invalid): + LIGHT_SCHEMA(_light(**{key: 256})) + + +@pytest.mark.parametrize( + "display", + [ + pytest.param({"model": "rm690b0"}, id="model_with_default_brightness"), + pytest.param({**_CUSTOM, CONF_BRIGHTNESS: 0}, id="custom_with_brightness"), + ], +) +def test_light_accepts_display_with_brightness(display: ConfigType) -> None: + _final_validate_with_display(display) + + +def test_light_rejects_model_without_brightness() -> None: + with pytest.raises( + cv.Invalid, + match="Display model ILI9488 does not support setting brightness", + ): + _final_validate_with_display({"model": "ili9488"}) + + +def test_light_rejects_custom_without_brightness() -> None: + with pytest.raises( + cv.Invalid, + match="The 'brightness' option must be set in the display config", + ): + _final_validate_with_display(_CUSTOM) + + +def test_brightness_not_allowed_on_model_without_default() -> None: + with pytest.raises(cv.Invalid, match=r"extra keys not allowed.*brightness"): + DISPLAY_SCHEMA( + {"id": DISPLAY_ID, "dc_pin": 18, "model": "ili9488", CONF_BRIGHTNESS: 10} + ) diff --git a/tests/components/mipi_spi/common.yaml b/tests/components/mipi_spi/common.yaml index 6bdee9e54a..45815b1ba5 100644 --- a/tests/components/mipi_spi/common.yaml +++ b/tests/components/mipi_spi/common.yaml @@ -53,3 +53,48 @@ display: reset_pin: allow_other_uses: true number: ${reset_pin} + + - platform: mipi_spi + id: amoled_display + model: RM690B0 + dc_pin: + allow_other_uses: true + number: ${dc_pin} + cs_pin: + allow_other_uses: true + number: ${cs_pin} + reset_pin: + allow_other_uses: true + number: ${reset_pin} + + - platform: mipi_spi + id: custom_display + model: custom + brightness: 0x80 + dimensions: + width: 240 + height: 240 + init_sequence: + - [0xd0, 1] + dc_pin: + allow_other_uses: true + number: ${dc_pin} + cs_pin: + allow_other_uses: true + number: ${cs_pin} + reset_pin: + allow_other_uses: true + number: ${reset_pin} + +light: + - platform: mipi_spi + id: display_brightness + name: Display Brightness + display_id: amoled_display + + - platform: mipi_spi + id: custom_display_brightness + name: Custom Display Brightness + display_id: custom_display + min_brightness: 16 + max_brightness: 200