mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 11:26:39 +00:00
[mipi_spi] Light platform for AMOLED display brightness (#20212)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1520d228bc
commit
490d478e54
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
@@ -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<MipiSpiBrightness> {
|
||||
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<uint8_t>(roundf(brightness * (this->max_brightness_ - this->min_brightness_))));
|
||||
}
|
||||
|
||||
protected:
|
||||
uint8_t min_brightness_;
|
||||
uint8_t max_brightness_;
|
||||
};
|
||||
|
||||
} // namespace esphome::mipi_spi
|
||||
@@ -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<typename BUFFERTYPE, PixelMode BUFFERPIXEL, bool IS_BIG_ENDIAN, PixelMo
|
||||
int WIDTH, int HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, uint16_t MADCTL,
|
||||
bool HAS_HARDWARE_ROTATION>
|
||||
class MipiSpi : public display::Display,
|
||||
public MipiSpiBrightness,
|
||||
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW, spi::CLOCK_PHASE_LEADING,
|
||||
spi::DATA_RATE_1MHZ> {
|
||||
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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user