mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 03:16:37 +00:00
[image] Restore defaults:/files: support for platform entries (#18032)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
co-authored by
Claude Sonnet 5
Copilot Autofix powered by AI
J. Nick Koston
parent
7b7107556f
commit
470226ca03
@@ -23,6 +23,7 @@ from esphome.components.image import (
|
||||
get_image_type_enum,
|
||||
get_transparency_enum,
|
||||
is_svg_file,
|
||||
validate_byte_order,
|
||||
validate_settings,
|
||||
validate_transparency,
|
||||
validate_type,
|
||||
@@ -200,7 +201,7 @@ OPTIONS_SCHEMA = {
|
||||
"NONE", "FLOYDSTEINBERG", upper=True
|
||||
),
|
||||
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): validate_byte_order,
|
||||
cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(),
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,14 @@ from PIL import Image, UnidentifiedImageError
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_BYTE_ORDER, KEY_METADATA
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_DEFAULTS, CONF_FILE, CONF_ID, CONF_PLATFORM, CONF_TYPE
|
||||
from esphome.const import (
|
||||
CONF_DEFAULTS,
|
||||
CONF_FILE,
|
||||
CONF_FILES,
|
||||
CONF_ID,
|
||||
CONF_PLATFORM,
|
||||
CONF_TYPE,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -48,6 +55,9 @@ TRANSPARENCY_TYPES = (
|
||||
CONF_ALPHA_CHANNEL,
|
||||
)
|
||||
|
||||
# Shared validator for the image platform schemas and `_drop_incompatible_byte_order`.
|
||||
validate_byte_order = cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True)
|
||||
|
||||
|
||||
def get_image_type_enum(type):
|
||||
return getattr(ImageType, f"IMAGE_TYPE_{type.upper()}")
|
||||
@@ -404,6 +414,120 @@ def get_image_metadata(image_id: str) -> ImageMetaData | None:
|
||||
return get_all_image_metadata().get(image_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# `defaults:`/`files:` expansion: a `platform:` entry merges shared `defaults:`
|
||||
# into every `files:` entry; the platform's CONFIG_SCHEMA validates each.
|
||||
# Permanent, unlike the legacy migration below.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _drop_incompatible_byte_order(
|
||||
merged: dict, explicit: dict, *, index: int | None = None
|
||||
) -> dict:
|
||||
"""Drop `byte_order` when the resolved type doesn't support it, unless written directly on `explicit`.
|
||||
|
||||
With `index`, inherited values are validated before being dropped (the legacy flattener always drops).
|
||||
"""
|
||||
if CONF_BYTE_ORDER in explicit:
|
||||
return merged
|
||||
type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper())
|
||||
if (
|
||||
CONF_BYTE_ORDER in merged
|
||||
and isinstance(type_class, type)
|
||||
and issubclass(type_class, ImageEncoder)
|
||||
and not type_class.is_endian()
|
||||
):
|
||||
if index is not None:
|
||||
try:
|
||||
validate_byte_order(merged[CONF_BYTE_ORDER])
|
||||
except cv.Invalid as exc:
|
||||
exc.prepend([index])
|
||||
raise
|
||||
del merged[CONF_BYTE_ORDER]
|
||||
return merged
|
||||
|
||||
|
||||
def _expand_platform_entry(index: int, entry: dict) -> list[dict]:
|
||||
if CONF_FILES not in entry:
|
||||
if CONF_DEFAULTS in entry:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_DEFAULTS}' may only be used together with '{CONF_FILES}'",
|
||||
path=[index],
|
||||
)
|
||||
return [entry]
|
||||
|
||||
extra_keys = set(entry) - {CONF_PLATFORM, CONF_DEFAULTS, CONF_FILES}
|
||||
if extra_keys:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_FILES}' cannot be combined with "
|
||||
f"{', '.join(sorted(extra_keys))} on the same entry",
|
||||
path=[index],
|
||||
)
|
||||
|
||||
files = entry[CONF_FILES]
|
||||
if files is None:
|
||||
raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index])
|
||||
if not isinstance(files, list):
|
||||
raise cv.Invalid(f"'{CONF_FILES}' must be a list", path=[index])
|
||||
if not files:
|
||||
raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index])
|
||||
|
||||
defaults = entry.get(CONF_DEFAULTS, {})
|
||||
if defaults is None:
|
||||
defaults = {}
|
||||
if not isinstance(defaults, dict):
|
||||
raise cv.Invalid(f"'{CONF_DEFAULTS}' must be a mapping", path=[index])
|
||||
# Neither `id:` nor `platform:` makes sense inside `defaults:`.
|
||||
for disallowed in (CONF_ID, CONF_PLATFORM):
|
||||
if disallowed in defaults:
|
||||
raise cv.Invalid(
|
||||
f"'{disallowed}' is not allowed inside '{CONF_DEFAULTS}'",
|
||||
path=[index],
|
||||
)
|
||||
|
||||
from esphome import yaml_util
|
||||
|
||||
platform = entry[CONF_PLATFORM]
|
||||
result: list[dict] = []
|
||||
for file_entry in files:
|
||||
if not isinstance(file_entry, dict):
|
||||
raise cv.Invalid(
|
||||
f"each entry in '{CONF_FILES}' must be a mapping", path=[index]
|
||||
)
|
||||
# The platform is chosen by the entry's own `platform:` key, not per file.
|
||||
if CONF_PLATFORM in file_entry:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_PLATFORM}' is not allowed inside '{CONF_FILES}'",
|
||||
path=[index],
|
||||
)
|
||||
# Keep the `files:` item's source range so whole-entry errors anchor there;
|
||||
# `make_data_base` needs a real ESPHomeDataBase, so skip it for plain dicts.
|
||||
source = (
|
||||
file_entry if isinstance(file_entry, yaml_util.ESPHomeDataBase) else None
|
||||
)
|
||||
merged = yaml_util.make_data_base(
|
||||
{CONF_PLATFORM: platform, **defaults, **file_entry}, source
|
||||
)
|
||||
result.append(_drop_incompatible_byte_order(merged, file_entry, index=index))
|
||||
return result
|
||||
|
||||
|
||||
def expand_platform_config(config: list) -> list:
|
||||
"""Expand `defaults:`/`files:` entries; the platform's own CONFIG_SCHEMA validates each result."""
|
||||
result = []
|
||||
for i, entry in enumerate(config):
|
||||
if isinstance(entry, dict) and CONF_PLATFORM in entry:
|
||||
result.extend(_expand_platform_entry(i, entry))
|
||||
else:
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
EXPAND_PLATFORM_CONFIG = expand_platform_config
|
||||
|
||||
# --------------------- end defaults/files expansion -------------------------
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy top-level component -> `image:` platform deprecation helpers
|
||||
# -- REMOVE after 2027.1.0 together with the `animation:`/`online_image:` shims.
|
||||
@@ -496,11 +620,17 @@ def _is_legacy_image_format(config: object) -> bool:
|
||||
proper error instead of the migration silently dropping the input.
|
||||
"""
|
||||
if isinstance(config, list):
|
||||
# A bare list of (not-yet-platform-tagged) image dicts.
|
||||
# Exclude `files:` entries -- the list branch would otherwise silently
|
||||
# migrate them to `platform: file` instead of raising the missing-platform error.
|
||||
return bool(config) and all(
|
||||
isinstance(entry, dict) and CONF_PLATFORM not in entry for entry in config
|
||||
isinstance(entry, dict)
|
||||
and CONF_PLATFORM not in entry
|
||||
and CONF_FILES not in entry
|
||||
for entry in config
|
||||
)
|
||||
if not isinstance(config, dict):
|
||||
if not isinstance(config, dict) or CONF_PLATFORM in config or CONF_FILES in config:
|
||||
# `platform:`/`files:` dicts are new-format (left for list-wrapping +
|
||||
# expansion); the legacy flattener has no `files:` branch and would drop them.
|
||||
return False
|
||||
# A single image dict, or the grouped `defaults:`/`images:`/type-key form.
|
||||
return (
|
||||
@@ -532,18 +662,8 @@ def _flatten_legacy_image_config(config: object) -> list[dict]:
|
||||
|
||||
def _add(entry: dict, extra: dict) -> None:
|
||||
merged = {**defaults, **extra, **entry}
|
||||
# The legacy `defaults:`/type-grouped forms only applied `byte_order` to
|
||||
# types that support it. Replicate that so an endian default merged into
|
||||
# e.g. a binary image stays valid.
|
||||
type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper())
|
||||
if (
|
||||
CONF_BYTE_ORDER in merged
|
||||
and isinstance(type_class, type)
|
||||
and issubclass(type_class, ImageEncoder)
|
||||
and not type_class.is_endian()
|
||||
):
|
||||
del merged[CONF_BYTE_ORDER]
|
||||
result.append(merged)
|
||||
# Always drop, matching the pre-platform behavior -- see `_drop_incompatible_byte_order`.
|
||||
result.append(_drop_incompatible_byte_order(merged, {}))
|
||||
|
||||
def _add_entries(entries: object, extra: dict) -> None:
|
||||
# `entries` may be a single image dict or a list of them; non-dict
|
||||
|
||||
@@ -5,6 +5,7 @@ from esphome.components.const import CONF_BYTE_ORDER
|
||||
from esphome.components.image import (
|
||||
IMAGE_TYPE,
|
||||
Image_,
|
||||
validate_byte_order,
|
||||
validate_settings,
|
||||
validate_transparency,
|
||||
validate_type,
|
||||
@@ -128,9 +129,7 @@ def runtime_image_schema(image_class: cg.MockObjClass = RuntimeImage) -> cv.Sche
|
||||
cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, upper=True),
|
||||
cv.Optional(CONF_RESIZE): cv.dimensions,
|
||||
cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE),
|
||||
cv.Optional(CONF_BYTE_ORDER): cv.one_of(
|
||||
"BIG_ENDIAN", "LITTLE_ENDIAN", upper=True
|
||||
),
|
||||
cv.Optional(CONF_BYTE_ORDER): validate_byte_order,
|
||||
cv.Optional(CONF_TRANSPARENCY, default="OPAQUE"): validate_transparency(),
|
||||
cv.Optional(CONF_PLACEHOLDER): cv.use_id(Image_),
|
||||
}
|
||||
|
||||
@@ -620,6 +620,23 @@ class LoadValidationStep(ConfigValidationStep):
|
||||
elif not isinstance(self.conf, list):
|
||||
result[self.domain] = self.conf = [self.conf]
|
||||
|
||||
# Permanent expansion hook: a platform-tagged entry may expand into
|
||||
# several (e.g. `image`'s `defaults:`/`files:`), for `platform:`-tagged dicts only.
|
||||
if (expand := component.expand_platform_config) is not None and all(
|
||||
isinstance(entry, dict) and CONF_PLATFORM in entry
|
||||
for entry in self.conf
|
||||
):
|
||||
with result.catch_error(path):
|
||||
expanded = expand(self.conf)
|
||||
if not isinstance(expanded, list):
|
||||
# A non-list return is a component bug (not a user error):
|
||||
# raise explicitly (survives -O/-OO) so it escapes catch_error.
|
||||
raise TypeError(
|
||||
f"{self.domain}: EXPAND_PLATFORM_CONFIG must "
|
||||
f"return a list, got {type(expanded).__name__}"
|
||||
)
|
||||
result[self.domain] = self.conf = expanded
|
||||
|
||||
# Process AUTO_LOAD
|
||||
_process_auto_load(result, component, path)
|
||||
|
||||
|
||||
@@ -164,6 +164,14 @@ class ComponentManifest:
|
||||
"""
|
||||
return getattr(self.module, "LEGACY_CONFIG_MIGRATE", None)
|
||||
|
||||
@property
|
||||
def expand_platform_config(
|
||||
self,
|
||||
) -> Callable[[list[ConfigType]], list[ConfigType]] | None:
|
||||
"""Optional `EXPAND_PLATFORM_CONFIG` callable; runs on the normalized `platform:`-tagged
|
||||
entry list before per-entry CONFIG_SCHEMA. Must return a list (raise `cv.Invalid` for user errors)."""
|
||||
return getattr(self.module, "EXPAND_PLATFORM_CONFIG", None)
|
||||
|
||||
@property
|
||||
def resources(self) -> list[FileResource]:
|
||||
"""Return a list of all file resources defined in the package of this component.
|
||||
|
||||
Reference in New Issue
Block a user