diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 18ef4d48b6..19924f0da7 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -3,8 +3,12 @@ CODEOWNERS = ["@esphome/core"] CONF_BYTE_ORDER = "byte_order" +BYTE_ORDER_LITTLE = "little_endian" +BYTE_ORDER_BIG = "big_endian" + CONF_COLOR_DEPTH = "color_depth" CONF_DRAW_ROUNDING = "draw_rounding" CONF_ON_RECEIVE = "on_receive" CONF_ON_STATE_CHANGE = "on_state_change" CONF_REQUEST_HEADERS = "request_headers" +CONF_USE_PSRAM = "use_psram" diff --git a/esphome/components/esp32_rmt_led_strip/light.py b/esphome/components/esp32_rmt_led_strip/light.py index 33ae44e435..ac4f0b2e92 100644 --- a/esphome/components/esp32_rmt_led_strip/light.py +++ b/esphome/components/esp32_rmt_led_strip/light.py @@ -4,6 +4,7 @@ import logging from esphome import pins import esphome.codegen as cg from esphome.components import esp32, light +from esphome.components.const import CONF_USE_PSRAM import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, @@ -57,7 +58,6 @@ CHIPSETS = { "SM16703": LEDStripTimings(300, 900, 900, 300, 0, 0), } -CONF_USE_PSRAM = "use_psram" CONF_IS_WRGB = "is_wrgb" CONF_BIT0_HIGH = "bit0_high" CONF_BIT0_LOW = "bit0_low" diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 4a450375c4..b1879e6314 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -2,7 +2,7 @@ import logging from esphome.automation import build_automation, register_action, validate_automation import esphome.codegen as cg -from esphome.components.const import CONF_DRAW_ROUNDING +from esphome.components.const import CONF_COLOR_DEPTH, CONF_DRAW_ROUNDING from esphome.components.display import Display import esphome.config_validation as cv from esphome.const import ( @@ -186,7 +186,7 @@ def multi_conf_validate(configs: list[dict]): for config in configs[1:]: for item in ( df.CONF_LOG_LEVEL, - df.CONF_COLOR_DEPTH, + CONF_COLOR_DEPTH, df.CONF_BYTE_ORDER, df.CONF_TRANSPARENCY_KEY, ): @@ -275,11 +275,11 @@ async def to_code(configs): "LVGL_LOG_LEVEL", cg.RawExpression(f"ESPHOME_LOG_LEVEL_{config_0[df.CONF_LOG_LEVEL]}"), ) - add_define("LV_COLOR_DEPTH", config_0[df.CONF_COLOR_DEPTH]) + add_define("LV_COLOR_DEPTH", config_0[CONF_COLOR_DEPTH]) for font in helpers.lv_fonts_used: add_define(f"LV_FONT_{font.upper()}") - if config_0[df.CONF_COLOR_DEPTH] == 16: + if config_0[CONF_COLOR_DEPTH] == 16: add_define( "LV_COLOR_16_SWAP", "1" if config_0[df.CONF_BYTE_ORDER] == "big_endian" else "0", @@ -416,7 +416,7 @@ LVGL_SCHEMA = cv.All( { cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), cv.GenerateID(df.CONF_DISPLAYS): display_schema, - cv.Optional(df.CONF_COLOR_DEPTH, default=16): cv.one_of(16), + cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16), cv.Optional( df.CONF_DEFAULT_FONT, default="montserrat_14" ): lvalid.lv_font, diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index baa9a19c51..206a3d1622 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -418,7 +418,6 @@ CONF_BUTTONS = "buttons" CONF_BYTE_ORDER = "byte_order" CONF_CHANGE_RATE = "change_rate" CONF_CLOSE_BUTTON = "close_button" -CONF_COLOR_DEPTH = "color_depth" CONF_CONTROL = "control" CONF_DEFAULT_FONT = "default_font" CONF_DEFAULT_GROUP = "default_group" diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py new file mode 100644 index 0000000000..a6cb8f31eb --- /dev/null +++ b/esphome/components/mipi/__init__.py @@ -0,0 +1,403 @@ +# Various constants used in MIPI DBI communication +# Various configuration constants for MIPI displays +# Various utility functions for MIPI DBI configuration + +from typing import Any + +from esphome.components.const import CONF_COLOR_DEPTH +from esphome.components.display import CONF_SHOW_TEST_CARD, display_ns +import esphome.config_validation as cv +from esphome.const import ( + CONF_BRIGHTNESS, + CONF_COLOR_ORDER, + CONF_DIMENSIONS, + CONF_HEIGHT, + CONF_INIT_SEQUENCE, + CONF_INVERT_COLORS, + CONF_LAMBDA, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_OFFSET_HEIGHT, + CONF_OFFSET_WIDTH, + CONF_PAGES, + CONF_ROTATION, + CONF_SWAP_XY, + CONF_TRANSFORM, + CONF_WIDTH, +) +from esphome.core import TimePeriod + +LOGGER = cv.logging.getLogger(__name__) + +ColorOrder = display_ns.enum("ColorMode") + +NOP = 0x00 +SWRESET = 0x01 +RDDID = 0x04 +RDDST = 0x09 +RDMODE = 0x0A +RDMADCTL = 0x0B +RDPIXFMT = 0x0C +RDIMGFMT = 0x0D +RDSELFDIAG = 0x0F +SLEEP_IN = 0x10 +SLPIN = 0x10 +SLEEP_OUT = 0x11 +SLPOUT = 0x11 +PTLON = 0x12 +NORON = 0x13 +INVERT_OFF = 0x20 +INVOFF = 0x20 +INVERT_ON = 0x21 +INVON = 0x21 +ALL_ON = 0x23 +WRAM = 0x24 +GAMMASET = 0x26 +MIPI = 0x26 +DISPOFF = 0x28 +DISPON = 0x29 +CASET = 0x2A +PASET = 0x2B +RASET = 0x2B +RAMWR = 0x2C +WDATA = 0x2C +RAMRD = 0x2E +PTLAR = 0x30 +VSCRDEF = 0x33 +TEON = 0x35 +MADCTL = 0x36 +MADCTL_CMD = 0x36 +VSCRSADD = 0x37 +IDMOFF = 0x38 +IDMON = 0x39 +COLMOD = 0x3A +PIXFMT = 0x3A +GETSCANLINE = 0x45 +BRIGHTNESS = 0x51 +WRDISBV = 0x51 +RDDISBV = 0x52 +WRCTRLD = 0x53 +SWIRE1 = 0x5A +SWIRE2 = 0x5B +IFMODE = 0xB0 +FRMCTR1 = 0xB1 +FRMCTR2 = 0xB2 +FRMCTR3 = 0xB3 +INVCTR = 0xB4 +DFUNCTR = 0xB6 +ETMOD = 0xB7 +PWCTR1 = 0xC0 +PWCTR2 = 0xC1 +PWCTR3 = 0xC2 +PWCTR4 = 0xC3 +PWCTR5 = 0xC4 +VMCTR1 = 0xC5 +IFCTR = 0xC6 +VMCTR2 = 0xC7 +GMCTR = 0xC8 +SETEXTC = 0xC8 +PWSET = 0xD0 +VMCTR = 0xD1 +PWSETN = 0xD2 +RDID4 = 0xD3 +RDINDEX = 0xD9 +RDID1 = 0xDA +RDID2 = 0xDB +RDID3 = 0xDC +RDIDX = 0xDD +GMCTRP1 = 0xE0 +GMCTRN1 = 0xE1 +CSCON = 0xF0 +PWCTR6 = 0xF6 +ADJCTL3 = 0xF7 +PAGESEL = 0xFE + +MADCTL_MY = 0x80 # Bit 7 Bottom to top +MADCTL_MX = 0x40 # Bit 6 Right to left +MADCTL_MV = 0x20 # Bit 5 Reverse Mode +MADCTL_ML = 0x10 # Bit 4 LCD refresh Bottom to top +MADCTL_RGB = 0x00 # Bit 3 Red-Green-Blue pixel order +MADCTL_BGR = 0x08 # Bit 3 Blue-Green-Red pixel order +MADCTL_MH = 0x04 # Bit 2 LCD refresh right to left + +# These bits are used instead of the above bits on some chips, where using MX and MY results in incorrect +# partial updates. +MADCTL_XFLIP = 0x02 # Mirror the display horizontally +MADCTL_YFLIP = 0x01 # Mirror the display vertically + +# Special constant for delays in command sequences +DELAY_FLAG = 0xFFF # Special flag to indicate a delay + +CONF_PIXEL_MODE = "pixel_mode" +CONF_USE_AXIS_FLIPS = "use_axis_flips" + +PIXEL_MODE_24BIT = "24bit" +PIXEL_MODE_18BIT = "18bit" +PIXEL_MODE_16BIT = "16bit" + +PIXEL_MODES = { + PIXEL_MODE_16BIT: 0x55, + PIXEL_MODE_18BIT: 0x66, + PIXEL_MODE_24BIT: 0x77, +} + +MODE_RGB = "RGB" +MODE_BGR = "BGR" +COLOR_ORDERS = { + MODE_RGB: ColorOrder.COLOR_ORDER_RGB, + MODE_BGR: ColorOrder.COLOR_ORDER_BGR, +} + +CONF_HSYNC_BACK_PORCH = "hsync_back_porch" +CONF_HSYNC_FRONT_PORCH = "hsync_front_porch" +CONF_HSYNC_PULSE_WIDTH = "hsync_pulse_width" +CONF_VSYNC_BACK_PORCH = "vsync_back_porch" +CONF_VSYNC_FRONT_PORCH = "vsync_front_porch" +CONF_VSYNC_PULSE_WIDTH = "vsync_pulse_width" +CONF_PCLK_FREQUENCY = "pclk_frequency" +CONF_PCLK_INVERTED = "pclk_inverted" +CONF_NATIVE_HEIGHT = "native_height" +CONF_NATIVE_WIDTH = "native_width" + +CONF_DE_PIN = "de_pin" +CONF_PCLK_PIN = "pclk_pin" + + +def power_of_two(value): + value = cv.int_range(1, 128)(value) + if value & (value - 1) != 0: + raise cv.Invalid("value must be a power of two") + return value + + +def validate_dimension(rounding): + def validator(value): + value = cv.positive_int(value) + if value % rounding != 0: + raise cv.Invalid(f"Dimensions and offsets must be divisible by {rounding}") + return value + + return validator + + +def dimension_schema(rounding): + return cv.Any( + cv.dimensions, + cv.Schema( + { + cv.Required(CONF_WIDTH): validate_dimension(rounding), + cv.Required(CONF_HEIGHT): validate_dimension(rounding), + cv.Optional(CONF_OFFSET_HEIGHT, default=0): validate_dimension( + rounding + ), + cv.Optional(CONF_OFFSET_WIDTH, default=0): validate_dimension(rounding), + } + ), + ) + + +def map_sequence(value): + """ + Maps one entry in a sequence to a command and data bytes. + The format is a repeated sequence of [CMD, ] where is s a sequence of bytes. The length is inferred + from the length of the sequence and should not be explicit. + A single integer can be provided where there are no data bytes, in which case it is treated as a command. + A delay can be inserted by specifying "- delay N" where N is in ms + """ + if isinstance(value, str) and value.lower().startswith("delay "): + value = value.lower()[6:] + delay_value = cv.All( + cv.positive_time_period_milliseconds, + cv.Range(TimePeriod(milliseconds=1), TimePeriod(milliseconds=255)), + )(value) + return DELAY_FLAG, delay_value.total_milliseconds + value = cv.All(cv.ensure_list(cv.int_range(0, 255)), cv.Length(1, 254))(value) + return tuple(value) + + +def delay(ms): + return DELAY_FLAG, ms + + +class DriverChip: + models = {} + + def __init__( + self, + name: str, + initsequence=None, + **defaults, + ): + name = name.upper() + self.name = name + self.initsequence = initsequence + self.defaults = defaults + DriverChip.models[name] = self + + def extend(self, name, **kwargs) -> "DriverChip": + defaults = self.defaults.copy() + if ( + CONF_WIDTH in defaults + and CONF_OFFSET_WIDTH in kwargs + and CONF_NATIVE_WIDTH not in defaults + ): + defaults[CONF_NATIVE_WIDTH] = defaults[CONF_WIDTH] + if ( + CONF_HEIGHT in defaults + and CONF_OFFSET_HEIGHT in kwargs + and CONF_NATIVE_HEIGHT not in defaults + ): + defaults[CONF_NATIVE_HEIGHT] = defaults[CONF_HEIGHT] + defaults.update(kwargs) + return DriverChip(name, initsequence=self.initsequence, **defaults) + + def get_default(self, key, fallback: Any = False) -> Any: + return self.defaults.get(key, fallback) + + def option(self, name, fallback=False) -> cv.Optional: + return cv.Optional(name, default=self.get_default(name, fallback)) + + def rotation_as_transform(self, config) -> bool: + """ + Check if a rotation can be implemented in hardware using the MADCTL register. + A rotation of 180 is always possible, 90 and 270 are possible if the model supports swapping X and Y. + """ + rotation = config.get(CONF_ROTATION, 0) + return rotation and ( + self.get_default(CONF_SWAP_XY) != cv.UNDEFINED or rotation == 180 + ) + + def get_dimensions(self, config) -> tuple[int, int, int, int]: + if CONF_DIMENSIONS in config: + # Explicit dimensions, just use as is + dimensions = config[CONF_DIMENSIONS] + if isinstance(dimensions, dict): + width = dimensions[CONF_WIDTH] + height = dimensions[CONF_HEIGHT] + offset_width = dimensions[CONF_OFFSET_WIDTH] + offset_height = dimensions[CONF_OFFSET_HEIGHT] + return width, height, offset_width, offset_height + (width, height) = dimensions + return width, height, 0, 0 + + # Default dimensions, use model defaults + transform = self.get_transform(config) + + width = self.get_default(CONF_WIDTH) + height = self.get_default(CONF_HEIGHT) + offset_width = self.get_default(CONF_OFFSET_WIDTH, 0) + offset_height = self.get_default(CONF_OFFSET_HEIGHT, 0) + + # if mirroring axes and there are offsets, also mirror the offsets to cater for situations where + # the offset is asymmetric + if transform[CONF_MIRROR_X]: + native_width = self.get_default(CONF_NATIVE_WIDTH, width + offset_width * 2) + offset_width = native_width - width - offset_width + if transform[CONF_MIRROR_Y]: + native_height = self.get_default( + CONF_NATIVE_HEIGHT, height + offset_height * 2 + ) + offset_height = native_height - height - offset_height + # Swap default dimensions if swap_xy is set + if transform[CONF_SWAP_XY] is True: + width, height = height, width + offset_height, offset_width = offset_width, offset_height + return width, height, offset_width, offset_height + + def get_transform(self, config) -> dict[str, bool]: + can_transform = self.rotation_as_transform(config) + transform = config.get( + CONF_TRANSFORM, + { + CONF_MIRROR_X: self.get_default(CONF_MIRROR_X, False), + CONF_MIRROR_Y: self.get_default(CONF_MIRROR_Y, False), + CONF_SWAP_XY: self.get_default(CONF_SWAP_XY, False), + }, + ) + + # Can we use the MADCTL register to set the rotation? + if can_transform and CONF_TRANSFORM not in config: + rotation = config[CONF_ROTATION] + if rotation == 180: + transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] + transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] + elif rotation == 90: + transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] + transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] + else: + transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] + transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] + transform[CONF_TRANSFORM] = True + return transform + + def get_sequence(self, config) -> tuple[tuple[int, ...], int]: + """ + Create the init sequence for the display. + Use the default sequence from the model, if any, and append any custom sequence provided in the config. + Append SLPOUT (if not already in the sequence) and DISPON to the end of the sequence + Pixel format, color order, and orientation will be set. + Returns a tuple of the init sequence and the computed MADCTL value. + """ + sequence = list(self.initsequence) + custom_sequence = config.get(CONF_INIT_SEQUENCE, []) + sequence.extend(custom_sequence) + # Ensure each command is a tuple + sequence = [x if isinstance(x, tuple) else (x,) for x in sequence] + + # Set pixel format if not already in the custom sequence + pixel_mode = config[CONF_PIXEL_MODE] + if not isinstance(pixel_mode, int): + pixel_mode = PIXEL_MODES[pixel_mode] + sequence.append((PIXFMT, pixel_mode)) + + # Does the chip use the flipping bits for mirroring rather than the reverse order bits? + use_flip = config.get(CONF_USE_AXIS_FLIPS) + madctl = 0 + transform = self.get_transform(config) + if self.rotation_as_transform(config): + LOGGER.info("Using hardware transform to implement rotation") + if transform.get(CONF_MIRROR_X): + madctl |= MADCTL_XFLIP if use_flip else MADCTL_MX + if transform.get(CONF_MIRROR_Y): + madctl |= MADCTL_YFLIP if use_flip else MADCTL_MY + if transform.get(CONF_SWAP_XY) is True: # Exclude Undefined + madctl |= MADCTL_MV + if config[CONF_COLOR_ORDER] == MODE_BGR: + madctl |= MADCTL_BGR + sequence.append((MADCTL, madctl)) + if config[CONF_INVERT_COLORS]: + sequence.append((INVON,)) + else: + sequence.append((INVOFF,)) + if brightness := config.get(CONF_BRIGHTNESS, self.get_default(CONF_BRIGHTNESS)): + sequence.append((BRIGHTNESS, brightness)) + sequence.append((SLPOUT,)) + sequence.append((DISPON,)) + + # Flatten the sequence into a list of bytes, with the length of each command + # or the delay flag inserted where needed + return sum( + tuple( + (x[1], 0xFF) if x[0] == DELAY_FLAG else (x[0], len(x) - 1) + x[1:] + for x in sequence + ), + (), + ), madctl + + +def requires_buffer(config) -> bool: + """ + Check if the display configuration requires a buffer. It will do so if any drawing methods are configured. + :param config: + :return: True if a buffer is required, False otherwise + """ + return any( + config.get(key) for key in (CONF_LAMBDA, CONF_PAGES, CONF_SHOW_TEST_CARD) + ) + + +def get_color_depth(config) -> int: + """ + Get the color depth in bits from the configuration. + """ + return int(config[CONF_COLOR_DEPTH].removesuffix("bit")) diff --git a/esphome/components/mipi_spi/__init__.py b/esphome/components/mipi_spi/__init__.py index 879efda619..f0f02aedd8 100644 --- a/esphome/components/mipi_spi/__init__.py +++ b/esphome/components/mipi_spi/__init__.py @@ -3,11 +3,4 @@ CODEOWNERS = ["@clydebarrow"] DOMAIN = "mipi_spi" CONF_SPI_16 = "spi_16" -CONF_PIXEL_MODE = "pixel_mode" CONF_BUS_MODE = "bus_mode" -CONF_USE_AXIS_FLIPS = "use_axis_flips" -CONF_NATIVE_WIDTH = "native_width" -CONF_NATIVE_HEIGHT = "native_height" - -MODE_RGB = "RGB" -MODE_BGR = "BGR" diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index d25dfd8539..d5c9d7aa0f 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -9,6 +9,20 @@ from esphome.components.const import ( CONF_DRAW_ROUNDING, ) from esphome.components.display import CONF_SHOW_TEST_CARD, DISPLAY_ROTATIONS +from esphome.components.mipi import ( + CONF_PIXEL_MODE, + CONF_USE_AXIS_FLIPS, + MADCTL, + MODE_BGR, + MODE_RGB, + PIXFMT, + DriverChip, + dimension_schema, + get_color_depth, + map_sequence, + power_of_two, + requires_buffer, +) from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE import esphome.config_validation as cv from esphome.config_validation import ALLOW_EXTRA @@ -21,7 +35,6 @@ from esphome.const import ( CONF_DC_PIN, CONF_DIMENSIONS, CONF_ENABLE_PIN, - CONF_HEIGHT, CONF_ID, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, @@ -29,49 +42,18 @@ from esphome.const import ( CONF_MIRROR_X, CONF_MIRROR_Y, CONF_MODEL, - CONF_OFFSET_HEIGHT, - CONF_OFFSET_WIDTH, - CONF_PAGES, CONF_RESET_PIN, CONF_ROTATION, CONF_SWAP_XY, CONF_TRANSFORM, CONF_WIDTH, ) -from esphome.core import CORE, TimePeriod +from esphome.core import CORE from esphome.cpp_generator import TemplateArguments from esphome.final_validate import full_config -from . import ( - CONF_BUS_MODE, - CONF_NATIVE_HEIGHT, - CONF_NATIVE_WIDTH, - CONF_PIXEL_MODE, - CONF_SPI_16, - CONF_USE_AXIS_FLIPS, - DOMAIN, - MODE_BGR, - MODE_RGB, -) -from .models import ( - DELAY_FLAG, - MADCTL_BGR, - MADCTL_MV, - MADCTL_MX, - MADCTL_MY, - MADCTL_XFLIP, - MADCTL_YFLIP, - DriverChip, - adafruit, - amoled, - cyd, - ili, - jc, - lanbon, - lilygo, - waveshare, -) -from .models.commands import BRIGHTNESS, DISPON, INVOFF, INVON, MADCTL, PIXFMT, SLPOUT +from . import CONF_BUS_MODE, CONF_SPI_16, DOMAIN +from .models import adafruit, amoled, cyd, ili, jc, lanbon, lilygo, waveshare DEPENDENCIES = ["spi"] @@ -124,45 +106,6 @@ DISPLAY_PIXEL_MODES = { } -def get_dimensions(config): - if CONF_DIMENSIONS in config: - # Explicit dimensions, just use as is - dimensions = config[CONF_DIMENSIONS] - if isinstance(dimensions, dict): - width = dimensions[CONF_WIDTH] - height = dimensions[CONF_HEIGHT] - offset_width = dimensions[CONF_OFFSET_WIDTH] - offset_height = dimensions[CONF_OFFSET_HEIGHT] - return width, height, offset_width, offset_height - (width, height) = dimensions - return width, height, 0, 0 - - # Default dimensions, use model defaults - transform = get_transform(config) - - model = MODELS[config[CONF_MODEL]] - width = model.get_default(CONF_WIDTH) - height = model.get_default(CONF_HEIGHT) - offset_width = model.get_default(CONF_OFFSET_WIDTH, 0) - offset_height = model.get_default(CONF_OFFSET_HEIGHT, 0) - - # if mirroring axes and there are offsets, also mirror the offsets to cater for situations where - # the offset is asymmetric - if transform[CONF_MIRROR_X]: - native_width = model.get_default(CONF_NATIVE_WIDTH, width + offset_width * 2) - offset_width = native_width - width - offset_width - if transform[CONF_MIRROR_Y]: - native_height = model.get_default( - CONF_NATIVE_HEIGHT, height + offset_height * 2 - ) - offset_height = native_height - height - offset_height - # Swap default dimensions if swap_xy is set - if transform[CONF_SWAP_XY] is True: - width, height = height, width - offset_height, offset_width = offset_width, offset_height - return width, height, offset_width, offset_height - - def denominator(config): """ Calculate the best denominator for a buffer size fraction. @@ -171,10 +114,11 @@ def denominator(config): :config: The configuration dictionary containing the buffer size fraction and display dimensions :return: The denominator to use for the buffer size fraction """ + model = MODELS[config[CONF_MODEL]] frac = config.get(CONF_BUFFER_SIZE) if frac is None or frac > 0.75: return 1 - height, _width, _offset_width, _offset_height = get_dimensions(config) + height, _width, _offset_width, _offset_height = model.get_dimensions(config) try: return next(x for x in range(2, 17) if frac >= 1 / x and height % x == 0) except StopIteration: @@ -183,58 +127,6 @@ def denominator(config): ) from StopIteration -def validate_dimension(rounding): - def validator(value): - value = cv.positive_int(value) - if value % rounding != 0: - raise cv.Invalid(f"Dimensions and offsets must be divisible by {rounding}") - return value - - return validator - - -def map_sequence(value): - """ - The format is a repeated sequence of [CMD, ] where is s a sequence of bytes. The length is inferred - from the length of the sequence and should not be explicit. - A delay can be inserted by specifying "- delay N" where N is in ms - """ - if isinstance(value, str) and value.lower().startswith("delay "): - value = value.lower()[6:] - delay = cv.All( - cv.positive_time_period_milliseconds, - cv.Range(TimePeriod(milliseconds=1), TimePeriod(milliseconds=255)), - )(value) - return DELAY_FLAG, delay.total_milliseconds - if isinstance(value, int): - return (value,) - value = cv.All(cv.ensure_list(cv.int_range(0, 255)), cv.Length(1, 254))(value) - return tuple(value) - - -def power_of_two(value): - value = cv.int_range(1, 128)(value) - if value & (value - 1) != 0: - raise cv.Invalid("value must be a power of two") - return value - - -def dimension_schema(rounding): - return cv.Any( - cv.dimensions, - cv.Schema( - { - cv.Required(CONF_WIDTH): validate_dimension(rounding), - cv.Required(CONF_HEIGHT): validate_dimension(rounding), - cv.Optional(CONF_OFFSET_HEIGHT, default=0): validate_dimension( - rounding - ), - cv.Optional(CONF_OFFSET_WIDTH, default=0): validate_dimension(rounding), - } - ), - ) - - def swap_xy_schema(model): uses_swap = model.get_default(CONF_SWAP_XY, None) != cv.UNDEFINED @@ -250,7 +142,7 @@ def swap_xy_schema(model): def model_schema(config): model = MODELS[config[CONF_MODEL]] - bus_mode = config.get(CONF_BUS_MODE, model.modes[0]) + bus_mode = config[CONF_BUS_MODE] transform = cv.Schema( { cv.Required(CONF_MIRROR_X): cv.boolean, @@ -340,18 +232,6 @@ def model_schema(config): return schema -def is_rotation_transformable(config): - """ - Check if a rotation can be implemented in hardware using the MADCTL register. - A rotation of 180 is always possible, 90 and 270 are possible if the model supports swapping X and Y. - """ - model = MODELS[config[CONF_MODEL]] - rotation = config.get(CONF_ROTATION, 0) - return rotation and ( - model.get_default(CONF_SWAP_XY) != cv.UNDEFINED or rotation == 180 - ) - - def customise_schema(config): """ Create a customised config schema for a specific model and validate the configuration. @@ -367,7 +247,7 @@ def customise_schema(config): extra=ALLOW_EXTRA, )(config) model = MODELS[config[CONF_MODEL]] - bus_modes = model.modes + bus_modes = (TYPE_SINGLE, TYPE_QUAD, TYPE_OCTAL) config = cv.Schema( { model.option(CONF_BUS_MODE, TYPE_SINGLE): cv.one_of(*bus_modes, lower=True), @@ -375,7 +255,7 @@ def customise_schema(config): }, extra=ALLOW_EXTRA, )(config) - bus_mode = config.get(CONF_BUS_MODE, model.modes[0]) + bus_mode = config[CONF_BUS_MODE] config = model_schema(config)(config) # Check for invalid combinations of MADCTL config if init_sequence := config.get(CONF_INIT_SEQUENCE): @@ -400,23 +280,9 @@ def customise_schema(config): CONFIG_SCHEMA = customise_schema -def requires_buffer(config): - """ - Check if the display configuration requires a buffer. It will do so if any drawing methods are configured. - :param config: - :return: True if a buffer is required, False otherwise - """ - return any( - config.get(key) for key in (CONF_LAMBDA, CONF_PAGES, CONF_SHOW_TEST_CARD) - ) - - -def get_color_depth(config): - return int(config[CONF_COLOR_DEPTH].removesuffix("bit")) - - def _final_validate(config): global_config = full_config.get() + model = MODELS[config[CONF_MODEL]] from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN @@ -433,7 +299,7 @@ def _final_validate(config): return config color_depth = get_color_depth(config) frac = denominator(config) - height, width, _offset_width, _offset_height = get_dimensions(config) + height, width, _offset_width, _offset_height = model.get_dimensions(config) buffer_size = color_depth // 8 * width * height // frac # Target a buffer size of 20kB @@ -463,7 +329,7 @@ def get_transform(config): :return: """ model = MODELS[config[CONF_MODEL]] - can_transform = is_rotation_transformable(config) + can_transform = model.rotation_as_transform(config) transform = config.get( CONF_TRANSFORM, { @@ -489,63 +355,6 @@ def get_transform(config): return transform -def get_sequence(model, config): - """ - Create the init sequence for the display. - Use the default sequence from the model, if any, and append any custom sequence provided in the config. - Append SLPOUT (if not already in the sequence) and DISPON to the end of the sequence - Pixel format, color order, and orientation will be set. - """ - sequence = list(model.initsequence) - custom_sequence = config.get(CONF_INIT_SEQUENCE, []) - sequence.extend(custom_sequence) - # Ensure each command is a tuple - sequence = [x if isinstance(x, tuple) else (x,) for x in sequence] - commands = [x[0] for x in sequence] - # Set pixel format if not already in the custom sequence - pixel_mode = DISPLAY_PIXEL_MODES[config[CONF_PIXEL_MODE]] - sequence.append((PIXFMT, pixel_mode[0])) - # Does the chip use the flipping bits for mirroring rather than the reverse order bits? - use_flip = config[CONF_USE_AXIS_FLIPS] - if MADCTL not in commands: - madctl = 0 - transform = get_transform(config) - if transform.get(CONF_TRANSFORM): - LOGGER.info("Using hardware transform to implement rotation") - if transform.get(CONF_MIRROR_X): - madctl |= MADCTL_XFLIP if use_flip else MADCTL_MX - if transform.get(CONF_MIRROR_Y): - madctl |= MADCTL_YFLIP if use_flip else MADCTL_MY - if transform.get(CONF_SWAP_XY) is True: # Exclude Undefined - madctl |= MADCTL_MV - if config[CONF_COLOR_ORDER] == MODE_BGR: - madctl |= MADCTL_BGR - sequence.append((MADCTL, madctl)) - if INVON not in commands and INVOFF not in commands: - if config[CONF_INVERT_COLORS]: - sequence.append((INVON,)) - else: - sequence.append((INVOFF,)) - if BRIGHTNESS not in commands: - if brightness := config.get( - CONF_BRIGHTNESS, model.get_default(CONF_BRIGHTNESS) - ): - sequence.append((BRIGHTNESS, brightness)) - if SLPOUT not in commands: - sequence.append((SLPOUT,)) - sequence.append((DISPON,)) - - # Flatten the sequence into a list of bytes, with the length of each command - # or the delay flag inserted where needed - return sum( - tuple( - (x[1], 0xFF) if x[0] == DELAY_FLAG else (x[0], len(x) - 1) + x[1:] - for x in sequence - ), - (), - ) - - def get_instance(config): """ Get the type of MipiSpi instance to create based on the configuration, @@ -553,7 +362,8 @@ def get_instance(config): :param config: :return: type, template arguments """ - width, height, offset_width, offset_height = get_dimensions(config) + model = MODELS[config[CONF_MODEL]] + width, height, offset_width, offset_height = model.get_dimensions(config) color_depth = int(config[CONF_COLOR_DEPTH].removesuffix("bit")) bufferpixels = COLOR_DEPTHS[color_depth] @@ -568,7 +378,7 @@ def get_instance(config): buffer_type = cg.uint8 if color_depth == 8 else cg.uint16 frac = denominator(config) rotation = DISPLAY_ROTATIONS[ - 0 if is_rotation_transformable(config) else config.get(CONF_ROTATION, 0) + 0 if model.rotation_as_transform(config) else config.get(CONF_ROTATION, 0) ] templateargs = [ buffer_type, @@ -594,8 +404,9 @@ async def to_code(config): var_id = config[CONF_ID] var_id.type, templateargs = get_instance(config) var = cg.new_Pvariable(var_id, TemplateArguments(*templateargs)) - cg.add(var.set_init_sequence(get_sequence(model, config))) - if is_rotation_transformable(config): + init_sequence, _madctl = model.get_sequence(config) + cg.add(var.set_init_sequence(init_sequence)) + if model.rotation_as_transform(config): if CONF_TRANSFORM in config: LOGGER.warning("Use of 'transform' with 'rotation' is not recommended") else: diff --git a/esphome/components/mipi_spi/models/__init__.py b/esphome/components/mipi_spi/models/__init__.py index e9726032d4..e69de29bb2 100644 --- a/esphome/components/mipi_spi/models/__init__.py +++ b/esphome/components/mipi_spi/models/__init__.py @@ -1,65 +0,0 @@ -from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE -import esphome.config_validation as cv -from esphome.const import CONF_HEIGHT, CONF_OFFSET_HEIGHT, CONF_OFFSET_WIDTH, CONF_WIDTH - -from .. import CONF_NATIVE_HEIGHT, CONF_NATIVE_WIDTH - -MADCTL_MY = 0x80 # Bit 7 Bottom to top -MADCTL_MX = 0x40 # Bit 6 Right to left -MADCTL_MV = 0x20 # Bit 5 Reverse Mode -MADCTL_ML = 0x10 # Bit 4 LCD refresh Bottom to top -MADCTL_RGB = 0x00 # Bit 3 Red-Green-Blue pixel order -MADCTL_BGR = 0x08 # Bit 3 Blue-Green-Red pixel order -MADCTL_MH = 0x04 # Bit 2 LCD refresh right to left - -# These bits are used instead of the above bits on some chips, where using MX and MY results in incorrect -# partial updates. -MADCTL_XFLIP = 0x02 # Mirror the display horizontally -MADCTL_YFLIP = 0x01 # Mirror the display vertically - -DELAY_FLAG = 0xFFF # Special flag to indicate a delay - - -def delay(ms): - return DELAY_FLAG, ms - - -class DriverChip: - models = {} - - def __init__( - self, - name: str, - modes=(TYPE_SINGLE, TYPE_QUAD, TYPE_OCTAL), - initsequence=None, - **defaults, - ): - name = name.upper() - self.name = name - self.modes = modes - self.initsequence = initsequence - self.defaults = defaults - DriverChip.models[name] = self - - def extend(self, name, **kwargs): - defaults = self.defaults.copy() - if ( - CONF_WIDTH in defaults - and CONF_OFFSET_WIDTH in kwargs - and CONF_NATIVE_WIDTH not in defaults - ): - defaults[CONF_NATIVE_WIDTH] = defaults[CONF_WIDTH] - if ( - CONF_HEIGHT in defaults - and CONF_OFFSET_HEIGHT in kwargs - and CONF_NATIVE_HEIGHT not in defaults - ): - defaults[CONF_NATIVE_HEIGHT] = defaults[CONF_HEIGHT] - defaults.update(kwargs) - return DriverChip(name, self.modes, initsequence=self.initsequence, **defaults) - - def get_default(self, key, fallback=False): - return self.defaults.get(key, fallback) - - def option(self, name, fallback=False): - return cv.Optional(name, default=self.get_default(name, fallback)) diff --git a/esphome/components/mipi_spi/models/amoled.py b/esphome/components/mipi_spi/models/amoled.py index 882d19db30..6fe882b584 100644 --- a/esphome/components/mipi_spi/models/amoled.py +++ b/esphome/components/mipi_spi/models/amoled.py @@ -1,9 +1,19 @@ +from esphome.components.mipi import ( + MIPI, + MODE_RGB, + NORON, + PAGESEL, + PIXFMT, + SLPOUT, + SWIRE1, + SWIRE2, + TEON, + WRAM, + DriverChip, + delay, +) from esphome.components.spi import TYPE_QUAD -from .. import MODE_RGB -from . import DriverChip, delay -from .commands import MIPI, NORON, PAGESEL, PIXFMT, SLPOUT, SWIRE1, SWIRE2, TEON, WRAM - DriverChip( "T-DISPLAY-S3-AMOLED", width=240, diff --git a/esphome/components/mipi_spi/models/commands.py b/esphome/components/mipi_spi/models/commands.py deleted file mode 100644 index 032a6e6b2b..0000000000 --- a/esphome/components/mipi_spi/models/commands.py +++ /dev/null @@ -1,82 +0,0 @@ -# MIPI DBI commands - -NOP = 0x00 -SWRESET = 0x01 -RDDID = 0x04 -RDDST = 0x09 -RDMODE = 0x0A -RDMADCTL = 0x0B -RDPIXFMT = 0x0C -RDIMGFMT = 0x0D -RDSELFDIAG = 0x0F -SLEEP_IN = 0x10 -SLPIN = 0x10 -SLEEP_OUT = 0x11 -SLPOUT = 0x11 -PTLON = 0x12 -NORON = 0x13 -INVERT_OFF = 0x20 -INVOFF = 0x20 -INVERT_ON = 0x21 -INVON = 0x21 -ALL_ON = 0x23 -WRAM = 0x24 -GAMMASET = 0x26 -MIPI = 0x26 -DISPOFF = 0x28 -DISPON = 0x29 -CASET = 0x2A -PASET = 0x2B -RASET = 0x2B -RAMWR = 0x2C -WDATA = 0x2C -RAMRD = 0x2E -PTLAR = 0x30 -VSCRDEF = 0x33 -TEON = 0x35 -MADCTL = 0x36 -MADCTL_CMD = 0x36 -VSCRSADD = 0x37 -IDMOFF = 0x38 -IDMON = 0x39 -COLMOD = 0x3A -PIXFMT = 0x3A -GETSCANLINE = 0x45 -BRIGHTNESS = 0x51 -WRDISBV = 0x51 -RDDISBV = 0x52 -WRCTRLD = 0x53 -SWIRE1 = 0x5A -SWIRE2 = 0x5B -IFMODE = 0xB0 -FRMCTR1 = 0xB1 -FRMCTR2 = 0xB2 -FRMCTR3 = 0xB3 -INVCTR = 0xB4 -DFUNCTR = 0xB6 -ETMOD = 0xB7 -PWCTR1 = 0xC0 -PWCTR2 = 0xC1 -PWCTR3 = 0xC2 -PWCTR4 = 0xC3 -PWCTR5 = 0xC4 -VMCTR1 = 0xC5 -IFCTR = 0xC6 -VMCTR2 = 0xC7 -GMCTR = 0xC8 -SETEXTC = 0xC8 -PWSET = 0xD0 -VMCTR = 0xD1 -PWSETN = 0xD2 -RDID4 = 0xD3 -RDINDEX = 0xD9 -RDID1 = 0xDA -RDID2 = 0xDB -RDID3 = 0xDC -RDIDX = 0xDD -GMCTRP1 = 0xE0 -GMCTRN1 = 0xE1 -CSCON = 0xF0 -PWCTR6 = 0xF6 -ADJCTL3 = 0xF7 -PAGESEL = 0xFE diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index cc12b38f5d..0102c0f665 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -1,8 +1,4 @@ -from esphome.components.spi import TYPE_OCTAL - -from .. import MODE_RGB -from . import DriverChip, delay -from .commands import ( +from esphome.components.mipi import ( ADJCTL3, CSCON, DFUNCTR, @@ -18,6 +14,7 @@ from .commands import ( IFCTR, IFMODE, INVCTR, + MODE_RGB, NORON, PWCTR1, PWCTR2, @@ -32,7 +29,10 @@ from .commands import ( VMCTR1, VMCTR2, VSCRSADD, + DriverChip, + delay, ) +from esphome.components.spi import TYPE_OCTAL DriverChip( "M5CORE", diff --git a/esphome/components/mipi_spi/models/jc.py b/esphome/components/mipi_spi/models/jc.py index 449c5b87ae..f1f046a427 100644 --- a/esphome/components/mipi_spi/models/jc.py +++ b/esphome/components/mipi_spi/models/jc.py @@ -1,10 +1,8 @@ +from esphome.components.mipi import MODE_RGB, DriverChip from esphome.components.spi import TYPE_QUAD import esphome.config_validation as cv from esphome.const import CONF_IGNORE_STRAPPING_WARNING, CONF_NUMBER -from .. import MODE_RGB -from . import DriverChip - AXS15231 = DriverChip( "AXS15231", draw_rounding=8, diff --git a/esphome/components/mipi_spi/models/lilygo.py b/esphome/components/mipi_spi/models/lilygo.py index dd6f9c02f7..13ddc67465 100644 --- a/esphome/components/mipi_spi/models/lilygo.py +++ b/esphome/components/mipi_spi/models/lilygo.py @@ -1,6 +1,6 @@ +from esphome.components.mipi import MODE_BGR from esphome.components.spi import TYPE_OCTAL -from .. import MODE_BGR from .ili import ST7789V, ST7796 ST7789V.extend( diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index 726718aaf6..002f81f3a6 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -1,6 +1,6 @@ +from esphome.components.mipi import DriverChip import esphome.config_validation as cv -from . import DriverChip from .ili import ILI9488_A DriverChip( diff --git a/esphome/components/rpi_dpi_rgb/display.py b/esphome/components/rpi_dpi_rgb/display.py index c26143d63e..556ee5eeb4 100644 --- a/esphome/components/rpi_dpi_rgb/display.py +++ b/esphome/components/rpi_dpi_rgb/display.py @@ -2,6 +2,18 @@ from esphome import pins import esphome.codegen as cg from esphome.components import display from esphome.components.esp32 import const, only_on_variant +from esphome.components.mipi import ( + CONF_DE_PIN, + CONF_HSYNC_BACK_PORCH, + CONF_HSYNC_FRONT_PORCH, + CONF_HSYNC_PULSE_WIDTH, + CONF_PCLK_FREQUENCY, + CONF_PCLK_INVERTED, + CONF_PCLK_PIN, + CONF_VSYNC_BACK_PORCH, + CONF_VSYNC_FRONT_PORCH, + CONF_VSYNC_PULSE_WIDTH, +) import esphome.config_validation as cv from esphome.const import ( CONF_BLUE, @@ -27,18 +39,6 @@ from esphome.const import ( DEPENDENCIES = ["esp32"] -CONF_DE_PIN = "de_pin" -CONF_PCLK_PIN = "pclk_pin" - -CONF_HSYNC_FRONT_PORCH = "hsync_front_porch" -CONF_HSYNC_PULSE_WIDTH = "hsync_pulse_width" -CONF_HSYNC_BACK_PORCH = "hsync_back_porch" -CONF_VSYNC_FRONT_PORCH = "vsync_front_porch" -CONF_VSYNC_PULSE_WIDTH = "vsync_pulse_width" -CONF_VSYNC_BACK_PORCH = "vsync_back_porch" -CONF_PCLK_FREQUENCY = "pclk_frequency" -CONF_PCLK_INVERTED = "pclk_inverted" - rpi_dpi_rgb_ns = cg.esphome_ns.namespace("rpi_dpi_rgb") RPI_DPI_RGB = rpi_dpi_rgb_ns.class_("RpiDpiRgb", display.Display, cg.Component) ColorOrder = display.display_ns.enum("ColorMode") diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp index 91eb947a3e..1706a7e59d 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp @@ -23,7 +23,6 @@ void RpiDpiRgb::setup() { config.timings.flags.pclk_active_neg = this->pclk_inverted_; config.timings.pclk_hz = this->pclk_frequency_; config.clk_src = LCD_CLK_SRC_PLL160M; - config.psram_trans_align = 64; size_t data_pin_count = sizeof(this->data_pins_) / sizeof(this->data_pins_[0]); for (size_t i = 0; i != data_pin_count; i++) { config.data_gpio_nums[i] = this->data_pins_[i]->get_pin(); diff --git a/esphome/components/st7701s/display.py b/esphome/components/st7701s/display.py index c6ad43c14c..e2452a4c55 100644 --- a/esphome/components/st7701s/display.py +++ b/esphome/components/st7701s/display.py @@ -2,9 +2,17 @@ from esphome import pins import esphome.codegen as cg from esphome.components import display, spi from esphome.components.esp32 import const, only_on_variant -from esphome.components.rpi_dpi_rgb.display import ( +from esphome.components.mipi import ( + CONF_DE_PIN, + CONF_HSYNC_BACK_PORCH, + CONF_HSYNC_FRONT_PORCH, + CONF_HSYNC_PULSE_WIDTH, CONF_PCLK_FREQUENCY, CONF_PCLK_INVERTED, + CONF_PCLK_PIN, + CONF_VSYNC_BACK_PORCH, + CONF_VSYNC_FRONT_PORCH, + CONF_VSYNC_PULSE_WIDTH, ) import esphome.config_validation as cv from esphome.const import ( @@ -36,16 +44,6 @@ from esphome.core import TimePeriod from .init_sequences import ST7701S_INITS, cmd -CONF_DE_PIN = "de_pin" -CONF_PCLK_PIN = "pclk_pin" - -CONF_HSYNC_PULSE_WIDTH = "hsync_pulse_width" -CONF_HSYNC_BACK_PORCH = "hsync_back_porch" -CONF_HSYNC_FRONT_PORCH = "hsync_front_porch" -CONF_VSYNC_PULSE_WIDTH = "vsync_pulse_width" -CONF_VSYNC_BACK_PORCH = "vsync_back_porch" -CONF_VSYNC_FRONT_PORCH = "vsync_front_porch" - DEPENDENCIES = ["spi", "esp32"] st7701s_ns = cg.esphome_ns.namespace("st7701s") diff --git a/esphome/components/st7701s/st7701s.cpp b/esphome/components/st7701s/st7701s.cpp index 46509a7f9f..2af88515c7 100644 --- a/esphome/components/st7701s/st7701s.cpp +++ b/esphome/components/st7701s/st7701s.cpp @@ -25,7 +25,6 @@ void ST7701S::setup() { config.timings.flags.pclk_active_neg = this->pclk_inverted_; config.timings.pclk_hz = this->pclk_frequency_; config.clk_src = LCD_CLK_SRC_PLL160M; - config.psram_trans_align = 64; size_t data_pin_count = sizeof(this->data_pins_) / sizeof(this->data_pins_[0]); for (size_t i = 0; i != data_pin_count; i++) { config.data_gpio_nums[i] = this->data_pins_[i]->get_pin(); diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index 9824852653..c4c93866ca 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -16,9 +16,9 @@ from esphome.components.esp32 import ( VARIANTS, ) from esphome.components.esp32.gpio import validate_gpio_pin +from esphome.components.mipi import CONF_NATIVE_HEIGHT from esphome.components.mipi_spi.display import ( CONF_BUS_MODE, - CONF_NATIVE_HEIGHT, CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA, MODELS,