mirror of
https://github.com/esphome/esphome.git
synced 2025-08-02 16:37:46 +00:00
[image] Improve schemas (#9791)
This commit is contained in:
parent
412f4ac341
commit
549b0d12b6
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
|
@ -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
|
||||||
|
@ -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(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user