diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 2244a316b7..1d6c8277e8 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -393,6 +393,16 @@ class DriverChip: return {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} return {CONF_MIRROR_X, CONF_MIRROR_Y} + def has_hardware_transform(self, config) -> bool: + """ + Check if the model supports hardware transforms for the given configuration. + """ + return config.get(CONF_TRANSFORM) != CONF_DISABLED and self.transforms == { + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_SWAP_XY, + } + def option(self, name, fallback=False) -> cv.Optional: return cv.Optional(name, default=self.get_default(name, fallback)) @@ -423,10 +433,15 @@ class DriverChip: :return: A tuple (width, height, offset_width, offset_height, pad_width, pad_height). """ + transform = self.get_transform(config) if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is dimensions = config[CONF_DIMENSIONS] if isinstance(dimensions, dict): + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) + if transform.get(CONF_SWAP_XY) is True: + native_width, native_height = native_height, native_width width = dimensions[CONF_WIDTH] height = dimensions[CONF_HEIGHT] offset_width = dimensions[CONF_OFFSET_WIDTH] @@ -434,23 +449,19 @@ class DriverChip: if CONF_PAD_WIDTH in dimensions: pad_width = dimensions[CONF_PAD_WIDTH] native_width = width + offset_width + pad_width + elif native_width == 0: + pad_width = 0 + native_width = width + offset_width else: - native_width = self.get_default(CONF_NATIVE_WIDTH, 0) - if native_width == 0: - pad_width = 0 - native_width = width + offset_width - else: - pad_width = native_width - width - offset_width + pad_width = native_width - width - offset_width if CONF_PAD_HEIGHT in dimensions: pad_height = dimensions[CONF_PAD_HEIGHT] native_height = height + offset_height + pad_height + elif native_height == 0: + pad_height = 0 + native_height = height + offset_height else: - native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) - if native_height == 0: - pad_height = 0 - native_height = height + offset_height - else: - pad_height = native_height - height - offset_height + pad_height = native_height - height - offset_height if ( pad_width + offset_width >= native_width or pad_height + offset_height >= native_height @@ -466,7 +477,6 @@ class DriverChip: return width, height, 0, 0, 0, 0 # Default dimensions, use model defaults - transform = self.get_transform(config) width = self.get_default(CONF_WIDTH) height = self.get_default(CONF_HEIGHT) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 0231d12529..4162459058 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -295,13 +295,7 @@ def customise_schema(config): raise cv.Invalid(f"DC pin is required in {bus_mode} mode") denominator(config) model = MODELS[config[CONF_MODEL]] - has_hardware_transform = config.get( - CONF_TRANSFORM - ) != CONF_DISABLED and model.transforms == { - CONF_MIRROR_X, - CONF_MIRROR_Y, - CONF_SWAP_XY, - } + has_hardware_transform = model.has_hardware_transform(config) width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( model.get_dimensions(config, not has_hardware_transform) ) @@ -366,13 +360,7 @@ def get_instance(config): :return: type, template arguments """ model = MODELS[config[CONF_MODEL]] - has_hardware_transform = config.get( - CONF_TRANSFORM - ) != CONF_DISABLED and model.transforms == { - CONF_MIRROR_X, - CONF_MIRROR_Y, - CONF_SWAP_XY, - } + has_hardware_transform = model.has_hardware_transform(config) width, height, offset_width, offset_height, pad_width, pad_height = ( model.get_dimensions(config, not has_hardware_transform) ) diff --git a/tests/component_tests/mipi_spi/test_padding_and_offsets.py b/tests/component_tests/mipi_spi/test_padding_and_offsets.py index 82adf88b7e..7ae6f0e61f 100644 --- a/tests/component_tests/mipi_spi/test_padding_and_offsets.py +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -13,6 +13,16 @@ from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32S3, ) +from esphome.components.mipi import ( + CONF_DIMENSIONS, + CONF_HEIGHT, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_OFFSET_HEIGHT, + CONF_OFFSET_WIDTH, + CONF_SWAP_XY, + CONF_WIDTH, +) from esphome.components.mipi_spi.display import ( CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA, @@ -20,7 +30,13 @@ from esphome.components.mipi_spi.display import ( get_instance, ) from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE -from esphome.const import CONF_CS_PIN, CONF_DC_PIN, PlatformFramework +from esphome.const import ( + CONF_CS_PIN, + CONF_DC_PIN, + CONF_DISABLED, + CONF_TRANSFORM, + PlatformFramework, +) from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable @@ -432,3 +448,152 @@ class TestUserConfiguredPadding: assert config["dimensions"]["width"] == 240 assert config["dimensions"]["height"] == 240 assert config["dimensions"]["pad_height"] == 16 + + +class TestHasHardwareTransform: + """Test DriverChip.has_hardware_transform().""" + + def test_full_transform_model_without_transform_key(self) -> None: + """A model supporting swap_xy uses a hardware transform by default.""" + model = MODELS["ST7789V"] + assert model.has_hardware_transform({}) is True + + def test_full_transform_model_with_transform_dict(self) -> None: + """A configured (non-disabled) transform still uses the hardware path.""" + model = MODELS["ST7789V"] + assert ( + model.has_hardware_transform({CONF_TRANSFORM: {CONF_SWAP_XY: True}}) is True + ) + + def test_full_transform_model_with_transform_disabled(self) -> None: + """Disabling the transform falls back to software transforms.""" + model = MODELS["ST7789V"] + assert model.has_hardware_transform({CONF_TRANSFORM: CONF_DISABLED}) is False + + def test_model_without_swap_xy_support(self) -> None: + """Models that cannot swap axes never use a hardware transform.""" + # AXS15231 only supports mirror_x/mirror_y, not swap_xy. + model = MODELS["AXS15231"] + assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y} + assert model.has_hardware_transform({}) is False + + +class TestSwapXYNativeDimensions: + """Test that native dimensions are swapped when a swap_xy transform is active. + + When explicit dimensions are given in the swapped (rotated) orientation and the + model applies a hardware swap_xy transform, the model's native_width/native_height + defaults must be swapped to match, otherwise padding is computed against the wrong + axis and validation fails. + """ + + def test_explicit_swapped_dimensions_with_swap_xy_transform( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Explicit landscape dimensions on a portrait-native model with swap_xy.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # ST7789V is natively 240x320 (portrait). Provide landscape dimensions + # together with a swap_xy transform. + model = MODELS["ST7789V"] + assert model.get_default("native_width") == 240 + assert model.get_default("native_height") == 320 + + config = { + "model": "ST7789V", + CONF_DIMENSIONS: { + CONF_WIDTH: 320, + CONF_HEIGHT: 240, + CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + }, + CONF_TRANSFORM: { + CONF_SWAP_XY: True, + CONF_MIRROR_X: False, + CONF_MIRROR_Y: False, + }, + } + + # swap=False because the buffer is laid out in the requested orientation. + width, height, offset_w, offset_h, pad_w, pad_h = model.get_dimensions( + config, swap=False + ) + # Native dims are swapped to 320x240, so padding works out to zero rather + # than going negative (which previously raised "Invalid offsets"). + assert (width, height) == (320, 240) + assert (offset_w, offset_h) == (0, 0) + assert (pad_w, pad_h) == (0, 0) + + def test_explicit_dimensions_without_swap_keeps_native_orientation( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Without swap_xy the native dimensions keep their original orientation.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + model = MODELS["ST7789V"] + config = { + "model": "ST7789V", + CONF_DIMENSIONS: { + CONF_WIDTH: 240, + CONF_HEIGHT: 320, + CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + }, + CONF_TRANSFORM: { + CONF_SWAP_XY: False, + CONF_MIRROR_X: False, + CONF_MIRROR_Y: False, + }, + } + + width, height, offset_w, offset_h, pad_w, pad_h = model.get_dimensions( + config, swap=False + ) + assert (width, height) == (240, 320) + assert (offset_w, offset_h) == (0, 0) + assert (pad_w, pad_h) == (0, 0) + + def test_swapped_native_dimensions_compute_padding( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Padding is derived from the swapped native size when swap_xy is active.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # ILI9341 is natively 240x320. Request a 300x240 area in landscape; the + # swapped native size is 320x240, leaving 20px of horizontal padding. + model = MODELS["ILI9341"] + assert model.get_default("native_width") == 240 + assert model.get_default("native_height") == 320 + + config = { + "model": "ILI9341", + CONF_DIMENSIONS: { + CONF_WIDTH: 300, + CONF_HEIGHT: 240, + CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + }, + CONF_TRANSFORM: { + CONF_SWAP_XY: True, + CONF_MIRROR_X: False, + CONF_MIRROR_Y: False, + }, + } + + width, height, _, _, pad_w, pad_h = model.get_dimensions(config, swap=False) + assert (width, height) == (300, 240) + # native_width swapped to 320 -> pad_width = 320 - 300 - 0 = 20 + assert pad_w == 20 + assert pad_h == 0