Merge branch 'dev' into espidf_parallel_tool_prefetch

This commit is contained in:
J. Nick Koston
2026-08-19 14:41:44 -05:00
committed by GitHub
10 changed files with 658 additions and 26 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.11.5
RUN uv pip install --no-cache-dir esphome-device-builder==1.12.0
RUN \
platformio settings set enable_telemetry No \
+2 -1
View File
@@ -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(),
}
+136 -16
View File
@@ -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
+2 -3
View File
@@ -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_),
}
+17
View File
@@ -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)
+8
View File
@@ -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.
+325 -3
View File
@@ -21,16 +21,20 @@ from esphome.components.image import (
CONF_OPAQUE,
CONF_TRANSPARENCY,
PLATFORM_FILE,
_expand_platform_entry,
_flatten_legacy_image_config,
_is_legacy_image_format,
_is_new_image_format,
_migrate_legacy_image_config,
expand_platform_config,
get_all_image_metadata,
get_image_metadata,
)
from esphome.const import (
CONF_DEFAULTS,
CONF_DITHER,
CONF_FILE,
CONF_FILES,
CONF_ID,
CONF_PLATFORM,
CONF_RAW_DATA_ID,
@@ -259,6 +263,15 @@ def test_flatten_keeps_byte_order_for_endian_type() -> None:
assert out[0][CONF_BYTE_ORDER] == "little_endian"
def test_flatten_drops_byte_order_written_directly_on_legacy_entry() -> None:
"""The legacy flattener drops an incompatible byte_order even when written directly on the entry."""
out = _flatten_legacy_image_config(
{"binary": [{"id": "a", "file": "x.png", "byte_order": "little_endian"}]}
)
assert out == [{"id": "a", "file": "x.png", "type": "binary"}]
assert CONF_BYTE_ORDER not in out[0]
def test_flatten_skips_meta_and_unknown_keys() -> None:
out = _flatten_legacy_image_config(
{
@@ -342,6 +355,42 @@ def test_migrate_legacy_warns_and_prepends_platform(
),
pytest.param({"foo": 1}, False, id="dict_unknown_keys"),
pytest.param("a string", False, id="scalar"),
# A `platform:`-tagged dict is the new format written without list brackets.
pytest.param(
{CONF_PLATFORM: "file", "id": "a", "file": "x.png"},
False,
id="platform_tagged_flat_dict",
),
pytest.param(
{
CONF_PLATFORM: "file",
"defaults": {"type": "rgb565"},
"files": [{"id": "a", "file": "x.png"}],
},
False,
id="platform_tagged_defaults_files_dict",
),
# `files:` without `platform:` is not legacy either -- the flattener has no branch for it.
pytest.param(
{
"defaults": {"type": "rgb565"},
"files": [{"id": "a", "file": "x.png"}],
},
False,
id="defaults_files_dict_without_platform",
),
# Same as above in a list -- without this exclusion it would be silently
# migrated to a hard-coded `platform: file` instead of raising the error.
pytest.param(
[
{
"defaults": {"type": "rgb565"},
"files": [{"id": "a", "file": "x.png"}],
}
],
False,
id="defaults_files_list_entry_without_platform",
),
],
)
def test_is_legacy_image_format(config: object, expected: bool) -> None:
@@ -359,17 +408,290 @@ def test_is_legacy_image_format(config: object, expected: bool) -> None:
def test_migrate_returns_none_for_invalid_legacy_shapes(
config: object, caplog: pytest.LogCaptureFixture
) -> None:
"""Unrecognised shapes are not migrated (and emit no warning) so normal
platform validation surfaces a proper error instead of silently dropping
the offending input."""
"""Unrecognised shapes are not migrated (and emit no warning), so normal platform validation reports them."""
with caplog.at_level(logging.WARNING):
assert _migrate_legacy_image_config(config) is None
assert "deprecated" not in caplog.text
def test_migrate_returns_none_for_mapping_form_defaults_files() -> None:
"""A `platform:`-tagged `defaults:`/`files:` mapping must not be swallowed by the legacy migrator."""
config = {
CONF_PLATFORM: "file",
"defaults": {"type": "rgb565"},
"files": [{"id": "a", "file": "a.png"}],
}
assert _migrate_legacy_image_config(config) is None
def test_migrate_returns_none_for_defaults_files_dict_without_platform() -> None:
"""`defaults:`/`files:` without `platform:` must not be swallowed either -- the flattener has
no `files:` branch and would silently return `[]`."""
config = {
"defaults": {"type": "rgb565"},
"files": [{"id": "a", "file": "a.png"}],
}
assert _migrate_legacy_image_config(config) is None
def test_migrate_returns_none_for_defaults_files_list_entry_without_platform() -> None:
"""Same, in a list -- previously the list branch migrated it to a hard-coded
`platform: file` instead of raising a missing-platform error."""
config = [
{
"defaults": {"type": "rgb565"},
"files": [{"id": "a", "file": "a.png"}],
}
]
assert _migrate_legacy_image_config(config) is None
# --------------------------- end legacy migration --------------------------
def test_expand_platform_entry_passes_through_plain_entry() -> None:
entry = {CONF_PLATFORM: "file", "id": "a", "file": "x.png"}
assert _expand_platform_entry(0, entry) == [entry]
def test_expand_platform_entry_expands_files_with_defaults() -> None:
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "RGB565", "transparency": "opaque"},
CONF_FILES: [
{"id": "img1", "file": "foo.png"},
{"id": "img2", "file": "bar.png", "type": "GRAYSCALE"},
],
}
assert _expand_platform_entry(0, entry) == [
{
CONF_PLATFORM: "file",
"id": "img1",
"file": "foo.png",
"type": "RGB565",
"transparency": "opaque",
},
{
CONF_PLATFORM: "file",
"id": "img2",
"file": "bar.png",
"type": "GRAYSCALE",
"transparency": "opaque",
},
]
def test_expand_platform_entry_files_without_defaults() -> None:
entry = {
CONF_PLATFORM: "file",
CONF_FILES: [{"id": "img1", "file": "foo.png"}],
}
assert _expand_platform_entry(0, entry) == [
{CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"}
]
def test_expand_platform_entry_preserves_source_range() -> None:
"""A merged entry keeps the source range of its `files:` item so whole-entry errors anchor there."""
from esphome import yaml_util
file_entry = yaml_util.make_data_base({"id": "img1", "file": "foo.png"})
file_entry._esp_range = "sentinel-range"
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "RGB565"},
CONF_FILES: [file_entry],
}
[out] = _expand_platform_entry(0, entry)
assert isinstance(out, yaml_util.ESPHomeDataBase)
assert out.esp_range == "sentinel-range"
def test_expand_platform_entry_plain_dict_file_entry_has_no_source_range() -> None:
"""Plain-dict `files:` items must not crash -- `from_database` reads `.esp_range` unconditionally."""
entry = {
CONF_PLATFORM: "file",
CONF_FILES: [{"id": "img1", "file": "foo.png"}],
}
[out] = _expand_platform_entry(0, entry)
assert out == {CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"}
def test_expand_platform_entry_per_file_overrides_win() -> None:
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "RGB565"},
CONF_FILES: [{"id": "img1", "file": "foo.png", "type": "BINARY"}],
}
[out] = _expand_platform_entry(0, entry)
assert out["type"] == "BINARY"
def test_expand_platform_entry_drops_byte_order_for_non_endian_override() -> None:
"""A `byte_order` default merged into a non-endian override is dropped, as the legacy flattener did."""
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_endian"},
CONF_FILES: [
{"id": "a", "file": "x.png"},
{"id": "b", "file": "y.png", "type": "binary"},
],
}
out = _expand_platform_entry(0, entry)
assert out[0]["byte_order"] == "little_endian"
assert "byte_order" not in out[1]
def test_expand_platform_entry_invalid_byte_order_in_defaults_raises() -> None:
"""A dropped `byte_order` inherited from `defaults:` is still validated, so a typo raises."""
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_andian"},
CONF_FILES: [{"id": "a", "file": "x.png", "type": "binary"}],
}
with pytest.raises(cv.Invalid, match="did you mean") as excinfo:
_expand_platform_entry(0, entry)
assert excinfo.value.path == [0]
def test_expand_platform_entry_keeps_byte_order_for_endian_override() -> None:
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "rgb565", "byte_order": "big_endian"},
CONF_FILES: [{"id": "a", "file": "x.png", "type": "rgb565"}],
}
[out] = _expand_platform_entry(0, entry)
assert out["byte_order"] == "big_endian"
def test_expand_platform_entry_keeps_explicit_byte_order_conflict() -> None:
"""A `byte_order` written directly on the entry is kept so validate_settings raises the normal error."""
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "rgb565"},
CONF_FILES: [
{
"id": "a",
"file": "x.png",
"type": "binary",
"byte_order": "little_endian",
}
],
}
[out] = _expand_platform_entry(0, entry)
assert out["byte_order"] == "little_endian"
def test_expand_platform_entry_defaults_without_files_raises() -> None:
entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}}
with pytest.raises(cv.Invalid, match="may only be used together with") as excinfo:
_expand_platform_entry(0, entry)
assert excinfo.value.path == [0]
def test_expand_platform_entry_null_files_raises_not_empty() -> None:
"""A `files:` key with no value parses to `None` and must be reported clearly."""
entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}, CONF_FILES: None}
with pytest.raises(cv.Invalid, match="must not be empty"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_empty_files_list_raises_not_empty() -> None:
"""An explicit `files: []` must not silently drop the whole platform entry."""
entry = {CONF_PLATFORM: "file", CONF_FILES: []}
with pytest.raises(cv.Invalid, match="must not be empty"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_files_with_stray_key_raises() -> None:
entry = {
CONF_PLATFORM: "file",
CONF_FILES: [{"id": "a", "file": "x.png"}],
"extra": 1,
}
with pytest.raises(cv.Invalid, match="cannot be combined with"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_id_in_defaults_raises() -> None:
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {CONF_ID: "a"},
CONF_FILES: [{"file": "x.png"}],
}
with pytest.raises(cv.Invalid, match="not allowed inside"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_platform_in_defaults_raises() -> None:
"""`platform:` inside `defaults:` would silently reassign every file's platform."""
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {CONF_PLATFORM: "animation"},
CONF_FILES: [{"id": "a", "file": "x.png"}],
}
with pytest.raises(cv.Invalid, match="not allowed inside"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_platform_in_file_entry_raises() -> None:
"""`platform:` on a `files:` item must not silently override the entry's platform."""
entry = {
CONF_PLATFORM: "file",
CONF_FILES: [{"id": "a", "file": "x.png", CONF_PLATFORM: "animation"}],
}
with pytest.raises(cv.Invalid, match="not allowed inside"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_files_not_list_raises() -> None:
entry = {CONF_PLATFORM: "file", CONF_FILES: "not-a-list"}
with pytest.raises(cv.Invalid, match="must be a list"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_defaults_not_mapping_raises() -> None:
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: "not-a-mapping",
CONF_FILES: [{"id": "a", "file": "x.png"}],
}
with pytest.raises(cv.Invalid, match="must be a mapping"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_file_item_not_mapping_raises() -> None:
entry = {CONF_PLATFORM: "file", CONF_FILES: [1, 2]}
with pytest.raises(cv.Invalid, match="must be a mapping"):
_expand_platform_entry(0, entry)
def test_expand_platform_config_mixes_plain_and_expanded_entries() -> None:
config = [
{
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "RGB565"},
CONF_FILES: [
{"id": "img1", "file": "foo.png"},
{"id": "img2", "file": "bar.png"},
],
},
{CONF_PLATFORM: "file", "id": "plain", "file": "baz.png", "type": "BINARY"},
]
out = expand_platform_config(config)
assert [entry["id"] for entry in out] == ["img1", "img2", "plain"]
def test_expand_platform_config_ignores_non_platform_entries() -> None:
# Not expanded here -- legacy_config_migrate runs before this hook and is
# responsible for tagging/flattening pre-platform shapes.
config = ["not-a-platform-entry"]
assert expand_platform_config(config) == config
# --------------------- end defaults/files expansion -------------------------
def test_validate_image_final_defaults_to_little_endian() -> None:
config = {CONF_FILE: "x.png"}
validate_image_final(config)
@@ -0,0 +1,21 @@
# `platform: animation` entry exercising the shared `defaults:`/`files:` expansion.
display:
- platform: sdl
id: animation_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
image:
- platform: animation
defaults:
type: rgb565
transparency: opaque
resize: 50x50
files:
- id: platform_defaults_animation
file: $component_dir/anim.gif
- id: platform_defaults_animation_rgb
file: $component_dir/anim.apng
type: rgb
@@ -0,0 +1,24 @@
# `platform: file` entry using the `defaults:`/`files:` shape, including the
# per-type byte_order drop when an entry overrides to a non-endian type.
display:
- platform: sdl
id: image_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
image:
- platform: file
defaults:
type: rgb565
transparency: opaque
byte_order: little_endian
resize: 50x50
dither: FloydSteinberg
files:
- id: platform_defaults_image
file: ../../pnglogo.png
- id: platform_defaults_binary
file: ../../pnglogo.png
type: binary
+122 -2
View File
@@ -7,7 +7,7 @@ from unittest.mock import MagicMock, Mock, patch
import pytest
from esphome import config, yaml_util
from esphome import config, config_validation as cv, yaml_util
from esphome.core import CORE, AutoLoad
from esphome.types import ConfigType
@@ -127,12 +127,14 @@ def _run_load_step(
domain: str,
conf: object,
migrate: Callable[[ConfigType], list | None] | None,
expand: Callable[[list], list] | None = None,
) -> config.Config:
"""Run a LoadValidationStep for a platform component with a given migrate hook."""
"""Run a LoadValidationStep for a platform component with given hooks."""
component = Mock()
component.is_platform_component = True
component.multi_conf_no_default = False
component.legacy_config_migrate = migrate
component.expand_platform_config = expand
result = config.Config()
with (
@@ -197,6 +199,124 @@ def test_legacy_migrate_skipped_for_autoload() -> None:
assert result["image"] == [auto]
# ---------------------------------------------------------------------------
# EXPAND_PLATFORM_CONFIG hook on LoadValidationStep -- permanent counterpart
# to legacy_config_migrate; runs after legacy migration/list normalization.
# ---------------------------------------------------------------------------
def test_expand_hook_rewrites_conf() -> None:
"""A config the expand hook rewrites is replaced with the expanded list."""
expanded = [{"platform": "file", "id": "a"}, {"platform": "file", "id": "b"}]
expand = Mock(return_value=expanded)
result = _run_load_step("image", [{"platform": "file", "id": "a"}], None, expand)
expand.assert_called_once_with([{"platform": "file", "id": "a"}])
assert result["image"] == expanded
def test_expand_hook_absent_is_noop() -> None:
"""A platform component without the hook is left as normalized by the
existing list-wrapping logic."""
result = _run_load_step("image", [{"platform": "file", "id": "a"}], None, None)
assert result["image"] == [{"platform": "file", "id": "a"}]
def test_expand_hook_runs_after_legacy_migrate() -> None:
"""The expand hook sees the already-migrated list, not the raw legacy conf."""
migrated = [{"platform": "file", "id": "a"}]
migrate = Mock(return_value=migrated)
expand = Mock(side_effect=lambda conf: conf)
_run_load_step("image", [{"id": "a", "file": "x.png"}], migrate, expand)
expand.assert_called_once_with(migrated)
def test_expand_hook_skipped_for_non_dict_entry() -> None:
"""Malformed entries are left alone; the hook only sees `platform:`-tagged dicts."""
expand = Mock(side_effect=lambda conf: conf)
result = _run_load_step("image", ["not-a-dict"], None, expand)
expand.assert_not_called()
assert result["image"] == ["not-a-dict"]
def test_expand_hook_skipped_for_entry_missing_platform_key() -> None:
"""A dict entry missing the `platform:` key is left alone -- the normal
per-entry error reporting further down catches this case instead."""
expand = Mock(side_effect=lambda conf: conf)
result = _run_load_step("image", [{"id": "a"}], None, expand)
expand.assert_not_called()
assert result["image"] == [{"id": "a"}]
def test_expand_hook_skipped_for_autoload() -> None:
"""A non-empty AutoLoad reaching the hook stage is left alone."""
expand = Mock(side_effect=lambda conf: conf)
auto = AutoLoad()
auto["id"] = "a"
result = _run_load_step("image", auto, None, expand)
expand.assert_not_called()
assert result["image"] == [auto]
def test_expand_hook_runs_when_all_entries_are_platform_tagged_dicts() -> None:
"""The guard does not block the normal, well-formed case."""
expand = Mock(side_effect=lambda conf: conf)
conf = [{"platform": "file", "id": "a"}, {"platform": "animation", "id": "b"}]
result = _run_load_step("image", conf, None, expand)
expand.assert_called_once_with(conf)
assert result["image"] == conf
def test_expand_hook_invalid_reports_single_error_at_domain_path() -> None:
"""A `cv.Invalid` from the hook is reported once with the domain path prepended; no further validation runs."""
expand = Mock(side_effect=cv.Invalid("bad shape"))
pre_expand_conf = [{"platform": "file", "id": "a"}]
result = _run_load_step("image", pre_expand_conf, None, expand)
assert len(result.errors) == 1
assert result.errors[0].path == ["image"]
assert "bad shape" in str(result.errors[0])
assert result["image"] == pre_expand_conf
def test_expand_hook_final_external_invalid_reports_without_path_prepend() -> None:
"""`cv.FinalExternalInvalid` keeps its already-resolved path (no domain path prepended)."""
already_resolved_error = cv.FinalExternalInvalid(
"bad shape", path=["image", 3, "files"]
)
expand = Mock(side_effect=already_resolved_error)
pre_expand_conf = [{"platform": "file", "id": "a"}]
result = _run_load_step("image", pre_expand_conf, None, expand)
assert len(result.errors) == 1
assert result.errors[0] is already_resolved_error
assert result.errors[0].path == ["image", 3, "files"]
assert result["image"] == pre_expand_conf
def test_expand_hook_non_list_return_raises_type_error() -> None:
"""A non-list return is a component bug: it escapes as an uncaught TypeError
(explicit raise survives -O/-OO)."""
expand = Mock(return_value={"not": "a list"})
with pytest.raises(TypeError, match="must return a list"):
_run_load_step("image", [{"platform": "file", "id": "a"}], None, expand)
def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path:
"""Create a config where two `<<` includes both define `logger:`.