[image] Improve schemas (#9791)

This commit is contained in:
Clyde Stubbs 2025-08-01 11:19:32 +10:00 committed by GitHub
parent 412f4ac341
commit 549b0d12b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 196 additions and 104 deletions

View File

@ -34,7 +34,8 @@ SetFrameAction = animation_ns.class_(
"AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_)
)
CONFIG_SCHEMA = espImage.IMAGE_SCHEMA.extend(
CONFIG_SCHEMA = cv.All(
espImage.IMAGE_SCHEMA.extend(
{
cv.Required(CONF_ID): cv.declare_id(Animation_),
cv.Optional(CONF_LOOP): cv.All(
@ -45,6 +46,8 @@ CONFIG_SCHEMA = espImage.IMAGE_SCHEMA.extend(
}
),
},
),
espImage.validate_settings,
)

View File

@ -108,6 +108,24 @@ class ImageEncoder:
:return:
"""
@classmethod
def is_endian(cls) -> bool:
"""
Check if the image encoder supports endianness configuration
"""
return getattr(cls, "set_big_endian", None) is not None
@classmethod
def get_options(cls) -> list[str]:
"""
Get the available options for this image encoder
"""
options = [*OPTIONS]
if not cls.is_endian():
options.remove(CONF_BYTE_ORDER)
options.append(CONF_RAW_DATA_ID)
return options
def is_alpha_only(image: Image):
"""
@ -446,13 +464,14 @@ def validate_type(image_types):
return validate
def validate_settings(value):
def validate_settings(value, path=()):
"""
Validate the settings for a single image configuration.
"""
conf_type = value[CONF_TYPE]
type_class = IMAGE_TYPE[conf_type]
transparency = value[CONF_TRANSPARENCY].lower()
transparency = value.get(CONF_TRANSPARENCY, CONF_OPAQUE).lower()
if transparency not in type_class.allow_config:
raise cv.Invalid(
f"Image format '{conf_type}' cannot have transparency: {transparency}"
@ -464,11 +483,10 @@ def validate_settings(value):
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)
):
if value.get(CONF_BYTE_ORDER) is not None and not type_class.is_endian():
raise cv.Invalid(
f"Image format '{conf_type}' does not support byte order configuration"
f"Image format '{conf_type}' does not support byte order configuration",
path=path,
)
if file := value.get(CONF_FILE):
file = Path(file)
@ -479,7 +497,7 @@ def validate_settings(value):
Image.open(file)
except UnidentifiedImageError as exc:
raise cv.Invalid(
f"File can't be opened as image: {file.absolute()}"
f"File can't be opened as image: {file.absolute()}", path=path
) from exc
return value
@ -499,6 +517,10 @@ OPTIONS_SCHEMA = {
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(),
}
DEFAULTS_SCHEMA = {
**OPTIONS_SCHEMA,
cv.Optional(CONF_TYPE): validate_type(IMAGE_TYPE),
}
@ -510,47 +532,61 @@ IMAGE_SCHEMA_NO_DEFAULTS = {
**{cv.Optional(key): OPTIONS_SCHEMA[key] for key in OPTIONS},
}
BASE_SCHEMA = cv.Schema(
IMAGE_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),
}
)
def validate_defaults(value):
def apply_defaults(image, defaults, path):
"""
Validate the options for images with defaults
Apply defaults to an image configuration
"""
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 is required either in the image config or in the defaults", path=path
)
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: image.get(key, defaults.get(key)) for key in type_class.get_options()},
**{key.schema: image[key.schema] for key in IMAGE_ID_SCHEMA},
CONF_TYPE: image.get(CONF_TYPE, defaults.get(CONF_TYPE)),
}
validate_settings(config)
result.append(config)
validate_settings(config, path)
return config
def validate_defaults(value):
"""
Apply defaults to the images in the configuration and flatten to a single list.
"""
defaults = value[CONF_DEFAULTS]
result = []
# Apply defaults to the images: list and add the list entries to the result
for index, image in enumerate(value.get(CONF_IMAGES, [])):
result.append(apply_defaults(image, defaults, [CONF_IMAGES, index]))
# Apply defaults to images under the type keys and add them to the result
for image_type, type_config in value.items():
type_upper = image_type.upper()
if type_upper not in IMAGE_TYPE:
continue
type_class = IMAGE_TYPE[type_upper]
if isinstance(type_config, list):
# If the type is a list, apply defaults to each entry
for index, image in enumerate(type_config):
result.append(apply_defaults(image, defaults, [image_type, index]))
else:
# Handle transparency options for the type
for trans_type in set(type_class.allow_config).intersection(type_config):
for index, image in enumerate(type_config[trans_type]):
result.append(
apply_defaults(image, defaults, [image_type, trans_type, index])
)
return result
@ -562,8 +598,13 @@ def typed_image_schema(image_type):
cv.Schema(
{
cv.Optional(t.lower()): cv.ensure_list(
BASE_SCHEMA.extend(
{
**IMAGE_ID_SCHEMA,
**{
cv.Optional(key): OPTIONS_SCHEMA[key]
for key in OPTIONS
if key != CONF_TRANSPARENCY
},
cv.Optional(
CONF_TRANSPARENCY, default=t
): validate_transparency((t,)),
@ -572,7 +613,6 @@ def typed_image_schema(image_type):
),
}
)
)
for t in IMAGE_TYPE[image_type].allow_config.intersection(
TRANSPARENCY_TYPES
)
@ -580,46 +620,44 @@ def typed_image_schema(image_type):
),
# Allow a default configuration with no transparency preselected
cv.ensure_list(
BASE_SCHEMA.extend(
{
cv.Optional(
CONF_TRANSPARENCY, default=CONF_OPAQUE
): validate_transparency(),
**IMAGE_SCHEMA_NO_DEFAULTS,
cv.Optional(CONF_TYPE, default=image_type): validate_type(
(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
# or a dictionary with keys `defaults:` and `images:`
# or a dictionary with optional keys `defaults:`, `images:` and the image types
def _config_schema(config):
if isinstance(config, list):
return cv.Schema([IMAGE_SCHEMA])(config)
if not isinstance(config, dict):
def _config_schema(value):
if isinstance(value, list) or (
isinstance(value, dict) and (CONF_ID in value or CONF_FILE in value)
):
return cv.ensure_list(cv.All(IMAGE_SCHEMA, validate_settings))(value)
if not isinstance(value, dict):
raise cv.Invalid(
"Badly formed image configuration, expected a list or a dictionary"
"Badly formed image configuration, expected a list or a dictionary",
)
if CONF_DEFAULTS in config or CONF_IMAGES in config:
return validate_defaults(
return cv.All(
cv.Schema(
{
cv.Required(CONF_DEFAULTS): OPTIONS_SCHEMA,
cv.Required(CONF_IMAGES): cv.ensure_list(IMAGE_SCHEMA_NO_DEFAULTS),
cv.Optional(CONF_DEFAULTS, default={}): DEFAULTS_SCHEMA,
cv.Optional(CONF_IMAGES, default=[]): cv.ensure_list(
{
**IMAGE_SCHEMA_NO_DEFAULTS,
cv.Optional(CONF_TYPE): validate_type(IMAGE_TYPE),
}
)(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)
),
**{cv.Optional(t.lower()): typed_image_schema(t) for t in IMAGE_TYPE},
}
),
validate_defaults,
)(value)
CONFIG_SCHEMA = _config_schema
@ -668,7 +706,7 @@ async def write_image(config, all_frames=False):
else Image.Dither.FLOYDSTEINBERG
)
type = config[CONF_TYPE]
transparency = config[CONF_TRANSPARENCY]
transparency = config.get(CONF_TRANSPARENCY, CONF_OPAQUE)
invert_alpha = config[CONF_INVERT_ALPHA]
frame_count = 1
if all_frames:
@ -699,14 +737,9 @@ async def write_image(config, all_frames=False):
async def to_code(config):
if isinstance(config, list):
# By now the config should be a simple list.
for entry in config:
await to_code(entry)
elif CONF_ID not in config:
for entry in config.values():
await to_code(entry)
else:
prog_arr, width, height, image_type, trans_value, _ = await write_image(config)
prog_arr, width, height, image_type, trans_value, _ = await write_image(entry)
cg.new_Pvariable(
config[CONF_ID], prog_arr, width, height, image_type, trans_value
entry[CONF_ID], prog_arr, width, height, image_type, trans_value
)

View File

@ -5,10 +5,12 @@ esp32:
board: esp32s3box
image:
- file: image.png
byte_order: little_endian
id: cat_img
defaults:
type: rgb565
byte_order: little_endian
images:
- file: image.png
id: cat_img
spi:
mosi_pin: 6

View File

@ -9,7 +9,8 @@ from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.image import CONFIG_SCHEMA
from esphome.components.image import CONF_TRANSPARENCY, CONFIG_SCHEMA
from esphome.const import CONF_ID, CONF_RAW_DATA_ID, CONF_TYPE
@pytest.mark.parametrize(
@ -22,12 +23,12 @@ from esphome.components.image import CONFIG_SCHEMA
),
pytest.param(
{"id": "image_id", "type": "rgb565"},
r"required key not provided @ data\[0\]\['file'\]",
r"required key not provided @ data\['file'\]",
id="missing_file",
),
pytest.param(
{"file": "image.png", "type": "rgb565"},
r"required key not provided @ data\[0\]\['id'\]",
r"required key not provided @ data\['id'\]",
id="missing_id",
),
pytest.param(
@ -160,13 +161,66 @@ def test_image_configuration_errors(
},
id="type_based_organization",
),
pytest.param(
{
"defaults": {
"type": "binary",
"transparency": "chroma_key",
"byte_order": "little_endian",
"dither": "FloydSteinberg",
"resize": "100x100",
"invert_alpha": False,
},
"rgb565": {
"alpha_channel": [
{
"id": "image_id",
"file": "image.png",
"transparency": "alpha_channel",
"dither": "none",
}
]
},
"binary": [
{
"id": "image_id",
"file": "image.png",
"transparency": "opaque",
}
],
},
id="type_based_with_defaults",
),
pytest.param(
{
"defaults": {
"type": "rgb565",
"transparency": "alpha_channel",
},
"binary": {
"opaque": [
{
"id": "image_id",
"file": "image.png",
}
],
},
},
id="binary_with_defaults",
),
],
)
def test_image_configuration_success(
config: dict[str, Any] | list[dict[str, Any]],
) -> None:
"""Test successful configuration validation."""
CONFIG_SCHEMA(config)
result = CONFIG_SCHEMA(config)
# All valid configurations should return a list of images
assert isinstance(result, list)
for key in (CONF_TYPE, CONF_ID, CONF_TRANSPARENCY, CONF_RAW_DATA_ID):
assert all(key in x for x in result), (
f"Missing key {key} in image configuration"
)
def test_image_generation(