mirror of
https://github.com/esphome/esphome.git
synced 2025-08-02 08:27:47 +00:00
[image] Improve schemas (#9791)
This commit is contained in:
parent
412f4ac341
commit
549b0d12b6
@ -34,17 +34,20 @@ SetFrameAction = animation_ns.class_(
|
||||
"AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_)
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = espImage.IMAGE_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(Animation_),
|
||||
cv.Optional(CONF_LOOP): cv.All(
|
||||
{
|
||||
cv.Optional(CONF_START_FRAME, default=0): cv.positive_int,
|
||||
cv.Optional(CONF_END_FRAME): cv.positive_int,
|
||||
cv.Optional(CONF_REPEAT): cv.positive_int,
|
||||
}
|
||||
),
|
||||
},
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
espImage.IMAGE_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(Animation_),
|
||||
cv.Optional(CONF_LOOP): cv.All(
|
||||
{
|
||||
cv.Optional(CONF_START_FRAME, default=0): cv.positive_int,
|
||||
cv.Optional(CONF_END_FRAME): cv.positive_int,
|
||||
cv.Optional(CONF_REPEAT): cv.positive_int,
|
||||
}
|
||||
),
|
||||
},
|
||||
),
|
||||
espImage.validate_settings,
|
||||
)
|
||||
|
||||
|
||||
|
@ -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 apply_defaults(image, defaults, path):
|
||||
"""
|
||||
Apply defaults to an image configuration
|
||||
"""
|
||||
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=path
|
||||
)
|
||||
type_class = IMAGE_TYPE[type]
|
||||
config = {
|
||||
**{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, path)
|
||||
return config
|
||||
|
||||
|
||||
def validate_defaults(value):
|
||||
"""
|
||||
Validate the options for images with defaults
|
||||
Apply defaults to the images in the configuration and flatten to a single list.
|
||||
"""
|
||||
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)
|
||||
# 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,16 +598,20 @@ def typed_image_schema(image_type):
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(t.lower()): cv.ensure_list(
|
||||
BASE_SCHEMA.extend(
|
||||
{
|
||||
cv.Optional(
|
||||
CONF_TRANSPARENCY, default=t
|
||||
): validate_transparency((t,)),
|
||||
cv.Optional(CONF_TYPE, default=image_type): validate_type(
|
||||
(image_type,)
|
||||
),
|
||||
}
|
||||
)
|
||||
{
|
||||
**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,)),
|
||||
cv.Optional(CONF_TYPE, default=image_type): validate_type(
|
||||
(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(),
|
||||
cv.Optional(CONF_TYPE, default=image_type): validate_type(
|
||||
(image_type,)
|
||||
),
|
||||
}
|
||||
)
|
||||
{
|
||||
**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(
|
||||
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)
|
||||
return cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
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),
|
||||
}
|
||||
),
|
||||
**{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):
|
||||
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)
|
||||
# By now the config should be a simple list.
|
||||
for entry in 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
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
Loading…
x
Reference in New Issue
Block a user