[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_) "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.Required(CONF_ID): cv.declare_id(Animation_),
cv.Optional(CONF_LOOP): cv.All( 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: :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): def is_alpha_only(image: Image):
""" """
@ -446,13 +464,14 @@ def validate_type(image_types):
return validate return validate
def validate_settings(value): def validate_settings(value, path=()):
""" """
Validate the settings for a single image configuration. Validate the settings for a single image configuration.
""" """
conf_type = value[CONF_TYPE] conf_type = value[CONF_TYPE]
type_class = IMAGE_TYPE[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: if transparency not in type_class.allow_config:
raise cv.Invalid( raise cv.Invalid(
f"Image format '{conf_type}' cannot have transparency: {transparency}" 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 and CONF_INVERT_ALPHA not in type_class.allow_config
): ):
raise cv.Invalid("No alpha channel to invert") raise cv.Invalid("No alpha channel to invert")
if value.get(CONF_BYTE_ORDER) is not None and not callable( if value.get(CONF_BYTE_ORDER) is not None and not type_class.is_endian():
getattr(type_class, "set_big_endian", None)
):
raise cv.Invalid( 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): if file := value.get(CONF_FILE):
file = Path(file) file = Path(file)
@ -479,7 +497,7 @@ def validate_settings(value):
Image.open(file) Image.open(file)
except UnidentifiedImageError as exc: except UnidentifiedImageError as exc:
raise cv.Invalid( 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 ) from exc
return value return value
@ -499,6 +517,10 @@ OPTIONS_SCHEMA = {
cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean, 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_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True),
cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(), cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(),
}
DEFAULTS_SCHEMA = {
**OPTIONS_SCHEMA,
cv.Optional(CONF_TYPE): validate_type(IMAGE_TYPE), 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}, **{cv.Optional(key): OPTIONS_SCHEMA[key] for key in OPTIONS},
} }
BASE_SCHEMA = cv.Schema( IMAGE_SCHEMA = cv.Schema(
{ {
**IMAGE_ID_SCHEMA, **IMAGE_ID_SCHEMA,
**OPTIONS_SCHEMA, **OPTIONS_SCHEMA,
}
).add_extra(validate_settings)
IMAGE_SCHEMA = BASE_SCHEMA.extend(
{
cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE), 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)) type = image.get(CONF_TYPE, defaults.get(CONF_TYPE))
if type is None: if type is None:
raise cv.Invalid( raise cv.Invalid(
"Type is required either in the image config or in the defaults", "Type is required either in the image config or in the defaults", path=path
path=[CONF_IMAGES, index],
) )
type_class = IMAGE_TYPE[type] 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 = { 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}, **{key.schema: image[key.schema] for key in IMAGE_ID_SCHEMA},
CONF_TYPE: image.get(CONF_TYPE, defaults.get(CONF_TYPE)),
} }
validate_settings(config) validate_settings(config, path)
result.append(config) 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 return result
@ -562,8 +598,13 @@ def typed_image_schema(image_type):
cv.Schema( cv.Schema(
{ {
cv.Optional(t.lower()): cv.ensure_list( 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( cv.Optional(
CONF_TRANSPARENCY, default=t CONF_TRANSPARENCY, default=t
): validate_transparency((t,)), ): validate_transparency((t,)),
@ -572,7 +613,6 @@ def typed_image_schema(image_type):
), ),
} }
) )
)
for t in IMAGE_TYPE[image_type].allow_config.intersection( for t in IMAGE_TYPE[image_type].allow_config.intersection(
TRANSPARENCY_TYPES TRANSPARENCY_TYPES
) )
@ -580,46 +620,44 @@ def typed_image_schema(image_type):
), ),
# Allow a default configuration with no transparency preselected # Allow a default configuration with no transparency preselected
cv.ensure_list( cv.ensure_list(
BASE_SCHEMA.extend(
{ {
cv.Optional( **IMAGE_SCHEMA_NO_DEFAULTS,
CONF_TRANSPARENCY, default=CONF_OPAQUE
): validate_transparency(),
cv.Optional(CONF_TYPE, default=image_type): validate_type( cv.Optional(CONF_TYPE, default=image_type): validate_type(
(image_type,) (image_type,)
), ),
} }
)
), ),
) )
# The config schema can be a (possibly empty) single list of images, # 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 optional keys `defaults:`, `images:` and the image types
# or a dictionary with keys `defaults:` and `images:`
def _config_schema(config): def _config_schema(value):
if isinstance(config, list): if isinstance(value, list) or (
return cv.Schema([IMAGE_SCHEMA])(config) isinstance(value, dict) and (CONF_ID in value or CONF_FILE in value)
if not isinstance(config, dict): ):
return cv.ensure_list(cv.All(IMAGE_SCHEMA, validate_settings))(value)
if not isinstance(value, dict):
raise cv.Invalid( 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 cv.All(
return validate_defaults(
cv.Schema( cv.Schema(
{ {
cv.Required(CONF_DEFAULTS): OPTIONS_SCHEMA, cv.Optional(CONF_DEFAULTS, default={}): DEFAULTS_SCHEMA,
cv.Required(CONF_IMAGES): cv.ensure_list(IMAGE_SCHEMA_NO_DEFAULTS), cv.Optional(CONF_IMAGES, default=[]): cv.ensure_list(
{
**IMAGE_SCHEMA_NO_DEFAULTS,
cv.Optional(CONF_TYPE): validate_type(IMAGE_TYPE),
} }
)(config) ),
) **{cv.Optional(t.lower()): typed_image_schema(t) for t in IMAGE_TYPE},
if CONF_ID in config or CONF_FILE in config: }
return cv.ensure_list(IMAGE_SCHEMA)([config]) ),
return cv.Schema( validate_defaults,
{cv.Optional(t.lower()): typed_image_schema(t) for t in IMAGE_TYPE} )(value)
)(config)
CONFIG_SCHEMA = _config_schema CONFIG_SCHEMA = _config_schema
@ -668,7 +706,7 @@ async def write_image(config, all_frames=False):
else Image.Dither.FLOYDSTEINBERG else Image.Dither.FLOYDSTEINBERG
) )
type = config[CONF_TYPE] type = config[CONF_TYPE]
transparency = config[CONF_TRANSPARENCY] transparency = config.get(CONF_TRANSPARENCY, CONF_OPAQUE)
invert_alpha = config[CONF_INVERT_ALPHA] invert_alpha = config[CONF_INVERT_ALPHA]
frame_count = 1 frame_count = 1
if all_frames: if all_frames:
@ -699,14 +737,9 @@ async def write_image(config, all_frames=False):
async def to_code(config): async def to_code(config):
if isinstance(config, list): # By now the config should be a simple list.
for entry in config: for entry in config:
await to_code(entry) prog_arr, width, height, image_type, trans_value, _ = await write_image(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)
cg.new_Pvariable( 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 board: esp32s3box
image: image:
- file: image.png defaults:
byte_order: little_endian
id: cat_img
type: rgb565 type: rgb565
byte_order: little_endian
images:
- file: image.png
id: cat_img
spi: spi:
mosi_pin: 6 mosi_pin: 6

View File

@ -9,7 +9,8 @@ from typing import Any
import pytest import pytest
from esphome import config_validation as cv 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( @pytest.mark.parametrize(
@ -22,12 +23,12 @@ from esphome.components.image import CONFIG_SCHEMA
), ),
pytest.param( pytest.param(
{"id": "image_id", "type": "rgb565"}, {"id": "image_id", "type": "rgb565"},
r"required key not provided @ data\[0\]\['file'\]", r"required key not provided @ data\['file'\]",
id="missing_file", id="missing_file",
), ),
pytest.param( pytest.param(
{"file": "image.png", "type": "rgb565"}, {"file": "image.png", "type": "rgb565"},
r"required key not provided @ data\[0\]\['id'\]", r"required key not provided @ data\['id'\]",
id="missing_id", id="missing_id",
), ),
pytest.param( pytest.param(
@ -160,13 +161,66 @@ def test_image_configuration_errors(
}, },
id="type_based_organization", 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( def test_image_configuration_success(
config: dict[str, Any] | list[dict[str, Any]], config: dict[str, Any] | list[dict[str, Any]],
) -> None: ) -> None:
"""Test successful configuration validation.""" """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( def test_image_generation(