[image] Add byte order option and unit tests (#9326)

This commit is contained in:
Clyde Stubbs 2025-07-08 12:28:00 +10:00 committed by GitHub
parent 51377b2625
commit 4648804db6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 385 additions and 80 deletions

View File

@ -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"

View File

@ -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
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
BASE_SCHEMA = cv.Schema(
{
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.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
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(
{
**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()

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 B

View File

@ -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

View File

@ -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
)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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