diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 66a5fe5d81..b084622f4c 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -2,6 +2,7 @@ CODEOWNERS = ["@esphome/core"] +CONF_BYTE_ORDER = "byte_order" CONF_DRAW_ROUNDING = "draw_rounding" CONF_ON_STATE_CHANGE = "on_state_change" CONF_REQUEST_HEADERS = "request_headers" diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 5d593ac3d4..f6d8673a08 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -10,8 +10,10 @@ from PIL import Image, UnidentifiedImageError from esphome import core, external_files import esphome.codegen as cg +from esphome.components.const import CONF_BYTE_ORDER import esphome.config_validation as cv from esphome.const import ( + CONF_DEFAULTS, CONF_DITHER, CONF_FILE, CONF_ICON, @@ -38,6 +40,7 @@ CONF_OPAQUE = "opaque" CONF_CHROMA_KEY = "chroma_key" CONF_ALPHA_CHANNEL = "alpha_channel" CONF_INVERT_ALPHA = "invert_alpha" +CONF_IMAGES = "images" TRANSPARENCY_TYPES = ( CONF_OPAQUE, @@ -188,6 +191,10 @@ class ImageRGB565(ImageEncoder): dither, invert_alpha, ) + self.big_endian = True + + def set_big_endian(self, big_endian: bool) -> None: + self.big_endian = big_endian def convert(self, image, path): return image.convert("RGBA") @@ -205,10 +212,16 @@ class ImageRGB565(ImageEncoder): g = 1 b = 0 rgb = (r << 11) | (g << 5) | b - self.data[self.index] = rgb >> 8 - self.index += 1 - self.data[self.index] = rgb & 0xFF - self.index += 1 + if self.big_endian: + self.data[self.index] = rgb >> 8 + self.index += 1 + self.data[self.index] = rgb & 0xFF + self.index += 1 + else: + self.data[self.index] = rgb & 0xFF + self.index += 1 + self.data[self.index] = rgb >> 8 + self.index += 1 if self.transparency == CONF_ALPHA_CHANNEL: if self.invert_alpha: a ^= 0xFF @@ -364,7 +377,7 @@ def validate_file_shorthand(value): value = cv.string_strict(value) parts = value.strip().split(":") if len(parts) == 2 and parts[0] in MDI_SOURCES: - match = re.match(r"[a-zA-Z0-9\-]+", parts[1]) + match = re.match(r"^[a-zA-Z0-9\-]+$", parts[1]) if match is None: raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.") return download_gh_svg(parts[1], parts[0]) @@ -434,20 +447,29 @@ def validate_type(image_types): def validate_settings(value): - type = value[CONF_TYPE] + """ + Validate the settings for a single image configuration. + """ + conf_type = value[CONF_TYPE] + type_class = IMAGE_TYPE[conf_type] transparency = value[CONF_TRANSPARENCY].lower() - allow_config = IMAGE_TYPE[type].allow_config - if transparency not in allow_config: + if transparency not in type_class.allow_config: raise cv.Invalid( - f"Image format '{type}' cannot have transparency: {transparency}" + f"Image format '{conf_type}' cannot have transparency: {transparency}" ) invert_alpha = value.get(CONF_INVERT_ALPHA, False) if ( invert_alpha and transparency != CONF_ALPHA_CHANNEL - and CONF_INVERT_ALPHA not in allow_config + and CONF_INVERT_ALPHA not in type_class.allow_config ): raise cv.Invalid("No alpha channel to invert") + if value.get(CONF_BYTE_ORDER) is not None and not callable( + getattr(type_class, "set_big_endian", None) + ): + raise cv.Invalid( + f"Image format '{conf_type}' does not support byte order configuration" + ) if file := value.get(CONF_FILE): file = Path(file) if is_svg_file(file): @@ -456,31 +478,82 @@ def validate_settings(value): try: Image.open(file) except UnidentifiedImageError as exc: - raise cv.Invalid(f"File can't be opened as image: {file}") from exc + raise cv.Invalid( + f"File can't be opened as image: {file.absolute()}" + ) from exc return value +IMAGE_ID_SCHEMA = { + cv.Required(CONF_ID): cv.declare_id(Image_), + cv.Required(CONF_FILE): cv.Any(validate_file_shorthand, TYPED_FILE_SCHEMA), + cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), +} + + +OPTIONS_SCHEMA = { + cv.Optional(CONF_RESIZE): cv.dimensions, + cv.Optional(CONF_DITHER, default="NONE"): cv.one_of( + "NONE", "FLOYDSTEINBERG", upper=True + ), + cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean, + cv.Optional(CONF_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True), + cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(), + cv.Optional(CONF_TYPE): validate_type(IMAGE_TYPE), +} + +OPTIONS = [key.schema for key in OPTIONS_SCHEMA] + +# image schema with no defaults, used with `CONF_IMAGES` in the config +IMAGE_SCHEMA_NO_DEFAULTS = { + **IMAGE_ID_SCHEMA, + **{cv.Optional(key): OPTIONS_SCHEMA[key] for key in OPTIONS}, +} + BASE_SCHEMA = cv.Schema( { - cv.Required(CONF_ID): cv.declare_id(Image_), - cv.Required(CONF_FILE): cv.Any(validate_file_shorthand, TYPED_FILE_SCHEMA), - cv.Optional(CONF_RESIZE): cv.dimensions, - cv.Optional(CONF_DITHER, default="NONE"): cv.one_of( - "NONE", "FLOYDSTEINBERG", upper=True - ), - cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean, - cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), + **IMAGE_ID_SCHEMA, + **OPTIONS_SCHEMA, } ).add_extra(validate_settings) IMAGE_SCHEMA = BASE_SCHEMA.extend( { cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE), - cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(), } ) +def validate_defaults(value): + """ + Validate the options for images with defaults + """ + defaults = value[CONF_DEFAULTS] + result = [] + for index, image in enumerate(value[CONF_IMAGES]): + type = image.get(CONF_TYPE, defaults.get(CONF_TYPE)) + if type is None: + raise cv.Invalid( + "Type is required either in the image config or in the defaults", + path=[CONF_IMAGES, index], + ) + type_class = IMAGE_TYPE[type] + # A default byte order should be simply ignored if the type does not support it + available_options = [*OPTIONS] + if ( + not callable(getattr(type_class, "set_big_endian", None)) + and CONF_BYTE_ORDER not in image + ): + available_options.remove(CONF_BYTE_ORDER) + config = { + **{key: image.get(key, defaults.get(key)) for key in available_options}, + **{key.schema: image[key.schema] for key in IMAGE_ID_SCHEMA}, + } + validate_settings(config) + result.append(config) + return result + + def typed_image_schema(image_type): """ Construct a schema for a specific image type, allowing transparency options @@ -523,10 +596,33 @@ def typed_image_schema(image_type): # The config schema can be a (possibly empty) single list of images, # or a dictionary of image types each with a list of images -CONFIG_SCHEMA = cv.Any( - cv.Schema({cv.Optional(t.lower()): typed_image_schema(t) for t in IMAGE_TYPE}), - cv.ensure_list(IMAGE_SCHEMA), -) +# or a dictionary with keys `defaults:` and `images:` + + +def _config_schema(config): + if isinstance(config, list): + return cv.Schema([IMAGE_SCHEMA])(config) + if not isinstance(config, dict): + raise cv.Invalid( + "Badly formed image configuration, expected a list or a dictionary" + ) + if CONF_DEFAULTS in config or CONF_IMAGES in config: + return validate_defaults( + cv.Schema( + { + cv.Required(CONF_DEFAULTS): OPTIONS_SCHEMA, + cv.Required(CONF_IMAGES): cv.ensure_list(IMAGE_SCHEMA_NO_DEFAULTS), + } + )(config) + ) + if CONF_ID in config or CONF_FILE in config: + return cv.ensure_list(IMAGE_SCHEMA)([config]) + return cv.Schema( + {cv.Optional(t.lower()): typed_image_schema(t) for t in IMAGE_TYPE} + )(config) + + +CONFIG_SCHEMA = _config_schema async def write_image(config, all_frames=False): @@ -585,6 +681,9 @@ async def write_image(config, all_frames=False): total_rows = height * frame_count encoder = IMAGE_TYPE[type](width, total_rows, transparency, dither, invert_alpha) + if byte_order := config.get(CONF_BYTE_ORDER): + # Check for valid type has already been done in validate_settings + encoder.set_big_endian(byte_order == "BIG_ENDIAN") for frame_index in range(frame_count): image.seek(frame_index) pixels = encoder.convert(image.resize((width, height)), path).getdata() diff --git a/tests/component_tests/conftest.py b/tests/component_tests/conftest.py index 7aa7dfe698..b1e0eaa200 100644 --- a/tests/component_tests/conftest.py +++ b/tests/component_tests/conftest.py @@ -1,29 +1,71 @@ """Fixtures for component tests.""" +from __future__ import annotations + +from collections.abc import Callable, Generator from pathlib import Path import sys +import pytest + # Add package root to python path here = Path(__file__).parent package_root = here.parent.parent sys.path.insert(0, package_root.as_posix()) -import pytest # noqa: E402 - from esphome.__main__ import generate_cpp_contents # noqa: E402 from esphome.config import read_config # noqa: E402 from esphome.core import CORE # noqa: E402 +@pytest.fixture(autouse=True) +def config_path(request: pytest.FixtureRequest) -> Generator[None]: + """Set CORE.config_path to the component's config directory and reset it after the test.""" + original_path = CORE.config_path + config_dir = Path(request.fspath).parent / "config" + + # Check if config directory exists, if not use parent directory + if config_dir.exists(): + # Set config_path to a dummy yaml file in the config directory + # This ensures CORE.config_dir points to the config directory + CORE.config_path = str(config_dir / "dummy.yaml") + else: + CORE.config_path = str(Path(request.fspath).parent / "dummy.yaml") + + yield + CORE.config_path = original_path + + @pytest.fixture -def generate_main(): +def component_fixture_path(request: pytest.FixtureRequest) -> Callable[[str], Path]: + """Return a function to get absolute paths relative to the component's fixtures directory.""" + + def _get_path(file_name: str) -> Path: + """Get the absolute path of a file relative to the component's fixtures directory.""" + return (Path(request.fspath).parent / "fixtures" / file_name).absolute() + + return _get_path + + +@pytest.fixture +def component_config_path(request: pytest.FixtureRequest) -> Callable[[str], Path]: + """Return a function to get absolute paths relative to the component's config directory.""" + + def _get_path(file_name: str) -> Path: + """Get the absolute path of a file relative to the component's config directory.""" + return (Path(request.fspath).parent / "config" / file_name).absolute() + + return _get_path + + +@pytest.fixture +def generate_main() -> Generator[Callable[[str | Path], str]]: """Generates the C++ main.cpp file and returns it in string form.""" - def generator(path: str) -> str: - CORE.config_path = path + def generator(path: str | Path) -> str: + CORE.config_path = str(path) CORE.config = read_config({}) generate_cpp_contents(CORE.config) - print(CORE.cpp_main_section) return CORE.cpp_main_section yield generator diff --git a/tests/component_tests/image/config/bad.png b/tests/component_tests/image/config/bad.png new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/image/config/image.png b/tests/component_tests/image/config/image.png new file mode 100644 index 0000000000..bd2fd54783 Binary files /dev/null and b/tests/component_tests/image/config/image.png differ diff --git a/tests/component_tests/image/config/image_test.yaml b/tests/component_tests/image/config/image_test.yaml new file mode 100644 index 0000000000..3ff1260bd0 --- /dev/null +++ b/tests/component_tests/image/config/image_test.yaml @@ -0,0 +1,20 @@ +esphome: + name: test + +esp32: + board: esp32s3box + +image: + - file: image.png + byte_order: little_endian + id: cat_img + type: rgb565 + +spi: + mosi_pin: 6 + clk_pin: 7 + +display: + - platform: mipi_spi + id: lcd_display + model: s3box diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py new file mode 100644 index 0000000000..d8a883d32f --- /dev/null +++ b/tests/component_tests/image/test_init.py @@ -0,0 +1,183 @@ +"""Tests for image configuration validation.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.image import CONFIG_SCHEMA + + +@pytest.mark.parametrize( + ("config", "error_match"), + [ + pytest.param( + "a string", + "Badly formed image configuration, expected a list or a dictionary", + id="invalid_string_config", + ), + pytest.param( + {"id": "image_id", "type": "rgb565"}, + r"required key not provided @ data\[0\]\['file'\]", + id="missing_file", + ), + pytest.param( + {"file": "image.png", "type": "rgb565"}, + r"required key not provided @ data\[0\]\['id'\]", + id="missing_id", + ), + pytest.param( + {"id": "mdi_id", "file": "mdi:weather-##", "type": "rgb565"}, + "Could not parse mdi icon name", + id="invalid_mdi_icon", + ), + pytest.param( + { + "id": "image_id", + "file": "image.png", + "type": "binary", + "transparency": "alpha_channel", + }, + "Image format 'BINARY' cannot have transparency", + id="binary_with_transparency", + ), + pytest.param( + { + "id": "image_id", + "file": "image.png", + "type": "rgb565", + "transparency": "chroma_key", + "invert_alpha": True, + }, + "No alpha channel to invert", + id="invert_alpha_without_alpha_channel", + ), + pytest.param( + { + "id": "image_id", + "file": "image.png", + "type": "binary", + "byte_order": "big_endian", + }, + "Image format 'BINARY' does not support byte order configuration", + id="binary_with_byte_order", + ), + pytest.param( + {"id": "image_id", "file": "bad.png", "type": "binary"}, + "File can't be opened as image", + id="invalid_image_file", + ), + pytest.param( + {"defaults": {}, "images": [{"id": "image_id", "file": "image.png"}]}, + "Type is required either in the image config or in the defaults", + id="missing_type_in_defaults", + ), + ], +) +def test_image_configuration_errors( + config: Any, + error_match: str, +) -> None: + """Test detection of invalid configuration.""" + with pytest.raises(cv.Invalid, match=error_match): + CONFIG_SCHEMA(config) + + +@pytest.mark.parametrize( + "config", + [ + pytest.param( + { + "id": "image_id", + "file": "image.png", + "type": "rgb565", + "transparency": "chroma_key", + "byte_order": "little_endian", + "dither": "FloydSteinberg", + "resize": "100x100", + "invert_alpha": False, + }, + id="single_image_all_options", + ), + pytest.param( + [ + { + "id": "image_id", + "file": "image.png", + "type": "binary", + } + ], + id="list_of_images", + ), + pytest.param( + { + "defaults": { + "type": "rgb565", + "transparency": "chroma_key", + "byte_order": "little_endian", + "dither": "FloydSteinberg", + "resize": "100x100", + "invert_alpha": False, + }, + "images": [ + { + "id": "image_id", + "file": "image.png", + } + ], + }, + id="images_with_defaults", + ), + pytest.param( + { + "rgb565": { + "alpha_channel": [ + { + "id": "image_id", + "file": "image.png", + "transparency": "alpha_channel", + "byte_order": "little_endian", + "dither": "FloydSteinberg", + "resize": "100x100", + "invert_alpha": False, + } + ] + }, + "binary": [ + { + "id": "image_id", + "file": "image.png", + "transparency": "opaque", + "dither": "FloydSteinberg", + "resize": "100x100", + "invert_alpha": False, + } + ], + }, + id="type_based_organization", + ), + ], +) +def test_image_configuration_success( + config: dict[str, Any] | list[dict[str, Any]], +) -> None: + """Test successful configuration validation.""" + CONFIG_SCHEMA(config) + + +def test_image_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Test image generation configuration.""" + + main_cpp = generate_main(component_config_path("image_test.yaml")) + assert "uint8_t_id[] PROGMEM = {0x24, 0x21, 0x24, 0x21" in main_cpp + assert ( + "cat_img = new image::Image(uint8_t_id, 32, 24, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE);" + in main_cpp + ) diff --git a/tests/components/image/test.esp32-ard.yaml b/tests/components/image/test.esp32-ard.yaml deleted file mode 100644 index 818e720221..0000000000 --- a/tests/components/image/test.esp32-ard.yaml +++ /dev/null @@ -1,17 +0,0 @@ -spi: - - id: spi_main_lcd - clk_pin: 16 - mosi_pin: 17 - miso_pin: 32 - -display: - - platform: ili9xxx - id: main_lcd - model: ili9342 - cs_pin: 14 - dc_pin: 13 - reset_pin: 21 - invert_colors: true - -<<: !include common.yaml - diff --git a/tests/components/image/test.esp32-c3-ard.yaml b/tests/components/image/test.esp32-c3-ard.yaml deleted file mode 100644 index 4dae9cd5ec..0000000000 --- a/tests/components/image/test.esp32-c3-ard.yaml +++ /dev/null @@ -1,16 +0,0 @@ -spi: - - id: spi_main_lcd - clk_pin: 6 - mosi_pin: 7 - miso_pin: 5 - -display: - - platform: ili9xxx - id: main_lcd - model: ili9342 - cs_pin: 3 - dc_pin: 11 - reset_pin: 10 - invert_colors: true - -<<: !include common.yaml diff --git a/tests/components/image/test.esp32-c3-idf.yaml b/tests/components/image/test.esp32-c3-idf.yaml deleted file mode 100644 index 4dae9cd5ec..0000000000 --- a/tests/components/image/test.esp32-c3-idf.yaml +++ /dev/null @@ -1,16 +0,0 @@ -spi: - - id: spi_main_lcd - clk_pin: 6 - mosi_pin: 7 - miso_pin: 5 - -display: - - platform: ili9xxx - id: main_lcd - model: ili9342 - cs_pin: 3 - dc_pin: 11 - reset_pin: 10 - invert_colors: true - -<<: !include common.yaml diff --git a/tests/components/image/test.esp8266-ard.yaml b/tests/components/image/test.esp8266-ard.yaml index f963022ff4..626076d44e 100644 --- a/tests/components/image/test.esp8266-ard.yaml +++ b/tests/components/image/test.esp8266-ard.yaml @@ -13,4 +13,13 @@ display: reset_pin: 16 invert_colors: true -<<: !include common.yaml +image: + defaults: + type: rgb565 + transparency: opaque + byte_order: little_endian + resize: 50x50 + dither: FloydSteinberg + images: + - id: test_image + file: ../../pnglogo.png