[image] Restructure into a platform component (#17416)

This commit is contained in:
Jesse Hills
2026-07-09 07:38:58 +12:00
committed by GitHub
parent 26f48ee9ea
commit dd0d0942f5
39 changed files with 1827 additions and 883 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

@@ -0,0 +1,30 @@
# New `image:` `platform: animation` form. Exercises animation/image.py through
# the real platform loader and codegen pipeline.
esphome:
name: test
esp32:
board: esp32s3box
image:
- platform: animation
id: test_animation
file: anim.gif
type: rgb565
loop:
start_frame: 0
end_frame: 2
repeat: 3
- platform: animation
id: test_animation_no_loop
file: anim.gif
type: rgb565
spi:
mosi_pin: 6
clk_pin: 7
display:
- platform: mipi_spi
id: lcd_display
model: s3box
@@ -0,0 +1,25 @@
# Legacy top-level `animation:` form. Exercises the deprecation shim and the
# shared codegen path through the real read_config/codegen pipeline.
esphome:
name: test
esp32:
board: esp32s3box
animation:
- id: test_animation
file: anim.gif
type: rgb565
loop:
start_frame: 0
end_frame: 2
repeat: 3
spi:
mosi_pin: 6
clk_pin: 7
display:
- platform: mipi_spi
id: lcd_display
model: s3box
@@ -0,0 +1,81 @@
"""Tests for the animation image platform and the legacy `animation:` shim."""
from __future__ import annotations
from collections.abc import Callable
import logging
from pathlib import Path
import pytest
from esphome.components.animation import (
DOMAIN,
LEGACY_REMOVAL_VERSION,
_capture_legacy_entry,
_warn_legacy_animation,
)
from esphome.core import CORE
from esphome.types import ConfigType
# ---------------------------------------------------------------------------
# Legacy top-level `animation:` deprecation shim -- REMOVE these tests after
# 2027.1.0 together with the shim in esphome/components/animation/__init__.py.
# ---------------------------------------------------------------------------
def test_warn_legacy_animation_warns_once(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The deprecation warning fires exactly once and never mutates the config."""
config: ConfigType = {"id": "test_animation", "file": "anim.gif", "type": "rgb565"}
# A per-entry capture (CONFIG_SCHEMA step) records the raw entry so the
# one-shot warning can print a pasteable migrated block.
assert _capture_legacy_entry(config) is config
with caplog.at_level(logging.WARNING):
# First call: flag not yet set -> warns and records the flag.
assert _warn_legacy_animation(config) is config
# Second call: flag already set -> stays silent (the dedup branch).
assert _warn_legacy_animation(config) is config
assert CORE.data[DOMAIN]["legacy_warning_shown"] is True
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1
assert "deprecated" in caplog.text
assert "platform: animation" in caplog.text
assert LEGACY_REMOVAL_VERSION in caplog.text
def test_legacy_animation_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
) -> None:
"""The legacy `animation:` block validates, warns, and generates codegen
through the real read_config/codegen pipeline."""
with caplog.at_level(logging.WARNING):
main_cpp = generate_main(component_config_path("animation_test.yaml"))
# Deprecation warning surfaced through the real validation pipeline.
assert "animation" in caplog.text
assert "deprecated" in caplog.text
# setup_animation ran: Animation object constructed and loop configured.
assert "new(test_animation) animation::Animation(" in main_cpp
assert "test_animation->set_loop(0, 2, 3);" in main_cpp
def test_animation_platform_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The `image:` `platform: animation` form generates codegen through the
real platform loader (animation/image.py) without any deprecation warning."""
main_cpp = generate_main(component_config_path("animation_platform_test.yaml"))
assert "new(test_animation) animation::Animation(" in main_cpp
assert "test_animation->set_loop(0, 2, 3);" in main_cpp
# The loop-less entry constructs the object but never configures a loop.
assert "new(test_animation_no_loop) animation::Animation(" in main_cpp
assert "test_animation_no_loop->set_loop(" not in main_cpp
+303 -143
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Callable
import logging
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
@@ -11,28 +12,36 @@ from PIL import Image as PILImage
import pytest
from esphome import config_validation as cv
from esphome.components.const import CONF_BYTE_ORDER
from esphome.components.file import image as file_image
from esphome.components.file.image import validate_image_final, write_image
from esphome.components.image import (
CONF_ALPHA_CHANNEL,
CONF_INVERT_ALPHA,
CONF_OPAQUE,
CONF_TRANSPARENCY,
CONFIG_SCHEMA,
PLATFORM_FILE,
_flatten_legacy_image_config,
_is_legacy_image_format,
_is_new_image_format,
_migrate_legacy_image_config,
get_all_image_metadata,
get_image_metadata,
write_image,
)
from esphome.const import CONF_DITHER, CONF_FILE, CONF_ID, CONF_RAW_DATA_ID, CONF_TYPE
from esphome.const import (
CONF_DITHER,
CONF_FILE,
CONF_ID,
CONF_PLATFORM,
CONF_RAW_DATA_ID,
CONF_TYPE,
)
from esphome.core import CORE
@pytest.mark.parametrize(
("config", "error_match"),
[
pytest.param(
"a string",
"Badly formed image configuration, expected a list or a dictionary",
id="invalid_string_config",
),
pytest.param(
{"id": "image_id", "type": "rgb565"},
r"required key not provided @ data\['file'\]",
@@ -43,6 +52,11 @@ from esphome.core import CORE
r"required key not provided @ data\['id'\]",
id="missing_id",
),
pytest.param(
{"id": "image_id", "file": "image.png"},
r"required key not provided @ data\['type'\]",
id="missing_type",
),
pytest.param(
{"id": "mdi_id", "file": "mdi:weather-##", "type": "rgb565"},
"Could not parse mdi icon name",
@@ -84,155 +98,301 @@ from esphome.core import CORE
"File can't be opened as image",
id="invalid_image_file",
),
pytest.param(
{"defaults": {}, "images": [{"id": "image_id", "file": "image.png"}]},
"Type is required either in the image config or in the defaults",
id="missing_type_in_defaults",
),
],
)
def test_image_configuration_errors(
def test_file_platform_configuration_errors(
config: Any,
error_match: str,
) -> None:
"""Test detection of invalid configuration."""
"""Invalid single-entry ``platform: file`` configs are rejected."""
with pytest.raises(cv.Invalid, match=error_match):
CONFIG_SCHEMA(config)
file_image.CONFIG_SCHEMA(config)
def test_file_platform_configuration_success() -> None:
"""A fully-specified ``platform: file`` entry validates and keeps its keys."""
result = file_image.CONFIG_SCHEMA(
{
"id": "image_id",
"file": "image.png",
"type": "rgb565",
"transparency": "chroma_key",
"byte_order": "little_endian",
"dither": "FloydSteinberg",
"resize": "100x100",
"invert_alpha": False,
}
)
for key in (CONF_TYPE, CONF_ID, CONF_TRANSPARENCY, CONF_RAW_DATA_ID):
assert key in result, f"Missing key {key} in validated image configuration"
# ---------------------------------------------------------------------------
# Legacy `image:` config migration -- REMOVE these tests after 2027.1.0 together
# with the migration shim in esphome/components/image/__init__.py.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("config", "expected"),
[
pytest.param(
[{CONF_PLATFORM: "file", "id": "a"}], True, id="new_platform_list"
),
pytest.param([], True, id="empty_list"),
pytest.param([{"id": "a", "file": "x.png"}], False, id="legacy_bare_list"),
pytest.param([{CONF_PLATFORM: "file"}, {"id": "a"}], False, id="mixed_list"),
pytest.param(
[{CONF_PLATFORM: "file"}, "not-a-dict"], False, id="non_dict_entry"
),
pytest.param({"defaults": {}}, False, id="legacy_dict"),
],
)
def test_is_new_image_format(config: object, expected: bool) -> None:
assert _is_new_image_format(config) is expected
def test_flatten_bare_list_filters_non_dicts() -> None:
out = _flatten_legacy_image_config(
[{"id": "a", "file": "x.png", "type": "binary"}, "not-a-dict"]
)
assert out == [{"id": "a", "file": "x.png", "type": "binary"}]
def test_flatten_non_dict_non_list_yields_nothing() -> None:
assert _flatten_legacy_image_config("a string") == []
def test_flatten_single_dict_with_id() -> None:
config = {"id": "a", "file": "x.png", "type": "binary"}
assert _flatten_legacy_image_config(config) == [config]
def test_flatten_single_dict_with_file_only() -> None:
config = {"file": "x.png", "type": "binary"}
assert _flatten_legacy_image_config(config) == [config]
def test_flatten_defaults_images_list() -> None:
out = _flatten_legacy_image_config(
{
"defaults": {"type": "rgb565", "byte_order": "little_endian"},
"images": [{"id": "a", "file": "x.png"}],
}
)
assert out == [
{
"id": "a",
"file": "x.png",
"type": "rgb565",
"byte_order": "little_endian",
}
]
def test_flatten_defaults_images_single_dict() -> None:
out = _flatten_legacy_image_config(
{
"defaults": {"type": "rgb565"},
"images": {"id": "a", "file": "x.png"},
}
)
assert out == [{"id": "a", "file": "x.png", "type": "rgb565"}]
def test_flatten_type_grouped_list() -> None:
out = _flatten_legacy_image_config({"binary": [{"id": "a", "file": "x.png"}]})
assert out == [{"id": "a", "file": "x.png", "type": "binary"}]
def test_flatten_type_grouped_transparency_list() -> None:
out = _flatten_legacy_image_config(
{"rgb565": {"alpha_channel": [{"id": "a", "file": "x.png"}]}}
)
assert out == [
{
"id": "a",
"file": "x.png",
"type": "rgb565",
"transparency": "alpha_channel",
}
]
def test_flatten_type_grouped_transparency_single_dict() -> None:
out = _flatten_legacy_image_config(
{"rgb565": {"alpha_channel": {"id": "a", "file": "x.png"}}}
)
assert out == [
{
"id": "a",
"file": "x.png",
"type": "rgb565",
"transparency": "alpha_channel",
}
]
def test_flatten_type_grouped_dict_without_transparency() -> None:
out = _flatten_legacy_image_config({"binary": {"id": "a", "file": "x.png"}})
assert out == [{"id": "a", "file": "x.png", "type": "binary"}]
def test_flatten_drops_byte_order_for_non_endian_type() -> None:
out = _flatten_legacy_image_config(
{
"defaults": {"byte_order": "little_endian"},
"binary": [{"id": "a", "file": "x.png"}],
}
)
assert out == [{"id": "a", "file": "x.png", "type": "binary"}]
assert CONF_BYTE_ORDER not in out[0]
def test_flatten_keeps_byte_order_for_endian_type() -> None:
out = _flatten_legacy_image_config(
{
"defaults": {"byte_order": "little_endian"},
"rgb565": [{"id": "a", "file": "x.png"}],
}
)
assert out[0][CONF_BYTE_ORDER] == "little_endian"
def test_flatten_skips_meta_and_unknown_keys() -> None:
out = _flatten_legacy_image_config(
{
"defaults": {"type": "binary"},
"images": [],
"not_a_type": [{"id": "a", "file": "x.png"}],
}
)
assert out == []
def test_flatten_images_list_skips_non_dict_entries() -> None:
out = _flatten_legacy_image_config(
{
"defaults": {"type": "binary"},
"images": [{"id": "a", "file": "x.png"}, "not-a-dict"],
}
)
assert out == [{"id": "a", "file": "x.png", "type": "binary"}]
def test_flatten_type_grouped_list_skips_non_dict_entries() -> None:
out = _flatten_legacy_image_config(
{"binary": [{"id": "a", "file": "x.png"}, "not-a-dict"]}
)
assert out == [{"id": "a", "file": "x.png", "type": "binary"}]
def test_flatten_type_grouped_scalar_value_is_ignored() -> None:
# A known type key whose value is neither a list nor a dict yields nothing.
assert _flatten_legacy_image_config({"binary": "not-a-list-or-dict"}) == []
def test_flatten_type_grouped_transparency_skips_non_dict_entries() -> None:
out = _flatten_legacy_image_config(
{"rgb565": {"alpha_channel": [{"id": "a", "file": "x.png"}, "not-a-dict"]}}
)
assert out == [
{
"id": "a",
"file": "x.png",
"type": "rgb565",
"transparency": "alpha_channel",
}
]
def test_migrate_returns_none_for_new_format() -> None:
assert _migrate_legacy_image_config([{CONF_PLATFORM: "file", "id": "a"}]) is None
def test_migrate_legacy_warns_and_prepends_platform(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING):
out = _migrate_legacy_image_config(
[{"id": "a", "file": "x.png", "type": "binary"}]
)
assert out == [
{CONF_PLATFORM: PLATFORM_FILE, "id": "a", "file": "x.png", "type": "binary"}
]
assert "deprecated" in caplog.text
assert f"platform: {PLATFORM_FILE}" in caplog.text
@pytest.mark.parametrize(
("config", "expected"),
[
# Recognised legacy shapes -> migrate.
pytest.param([{"id": "a", "file": "x.png"}], True, id="bare_list_of_dicts"),
pytest.param({"id": "a", "file": "x.png"}, True, id="single_image_dict"),
pytest.param({"file": "x.png"}, True, id="single_dict_file_only"),
pytest.param({"defaults": {}, "images": []}, True, id="defaults_images"),
pytest.param({"rgb565": [{"id": "a"}]}, True, id="type_grouped"),
# Shapes the legacy schema never accepted -> not migrated.
pytest.param([], False, id="empty_list"),
pytest.param(["bad"], False, id="list_with_non_dict"),
pytest.param([{"id": "a"}, "bad"], False, id="list_mixed_dict_and_non_dict"),
pytest.param(
[{CONF_PLATFORM: "file", "id": "a"}], False, id="already_platform_tagged"
),
pytest.param({"foo": 1}, False, id="dict_unknown_keys"),
pytest.param("a string", False, id="scalar"),
],
)
def test_is_legacy_image_format(config: object, expected: bool) -> None:
assert _is_legacy_image_format(config) is expected
@pytest.mark.parametrize(
"config",
[
pytest.param(
{
"id": "image_id",
"file": "image.png",
"type": "rgb565",
"transparency": "chroma_key",
"byte_order": "little_endian",
"dither": "FloydSteinberg",
"resize": "100x100",
"invert_alpha": False,
},
id="single_image_all_options",
),
pytest.param(
[
{
"id": "image_id",
"file": "image.png",
"type": "binary",
}
],
id="list_of_images",
),
pytest.param(
{
"defaults": {
"type": "rgb565",
"transparency": "chroma_key",
"byte_order": "little_endian",
"dither": "FloydSteinberg",
"resize": "100x100",
"invert_alpha": False,
},
"images": [
{
"id": "image_id",
"file": "image.png",
}
],
},
id="images_with_defaults",
),
pytest.param(
{
"rgb565": {
"alpha_channel": [
{
"id": "image_id",
"file": "image.png",
"transparency": "alpha_channel",
"byte_order": "little_endian",
"dither": "FloydSteinberg",
"resize": "100x100",
"invert_alpha": False,
}
]
},
"binary": [
{
"id": "image_id",
"file": "image.png",
"transparency": "opaque",
"dither": "FloydSteinberg",
"resize": "100x100",
"invert_alpha": False,
}
],
},
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",
),
pytest.param(["bad"], id="list_with_non_dict"),
pytest.param([{"id": "a"}, "bad"], id="list_mixed"),
pytest.param({"foo": 1}, id="dict_unknown_keys"),
],
)
def test_image_configuration_success(
config: dict[str, Any] | list[dict[str, Any]],
def test_migrate_returns_none_for_invalid_legacy_shapes(
config: object, caplog: pytest.LogCaptureFixture
) -> None:
"""Test successful configuration validation."""
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"
"""Unrecognised shapes are not migrated (and emit no warning) so normal
platform validation surfaces a proper error instead of silently dropping
the offending input."""
with caplog.at_level(logging.WARNING):
assert _migrate_legacy_image_config(config) is None
assert "deprecated" not in caplog.text
# --------------------------- end legacy migration --------------------------
def test_validate_image_final_defaults_to_little_endian() -> None:
out = validate_image_final({CONF_FILE: "x.png"})
assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN"
def test_validate_image_final_keeps_little_endian(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING):
out = validate_image_final(
{CONF_FILE: "x.png", CONF_BYTE_ORDER: "LITTLE_ENDIAN"}
)
assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN"
assert "big-endian" not in caplog.text
def test_validate_image_final_warns_on_big_endian(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING):
out = validate_image_final({CONF_FILE: "x.png", CONF_BYTE_ORDER: "BIG_ENDIAN"})
assert out[CONF_BYTE_ORDER] == "BIG_ENDIAN"
assert "big-endian" in caplog.text
def test_image_generation(
@@ -369,7 +529,7 @@ def test_get_all_image_metadata_empty() -> None:
@pytest.fixture
def mock_progmem_array():
"""Mock progmem_array to avoid needing a proper ID object in tests."""
with patch("esphome.components.image.cg.progmem_array") as mock_progmem:
with patch("esphome.components.file.image.cg.progmem_array") as mock_progmem:
mock_progmem.return_value = MagicMock()
yield mock_progmem
@@ -0,0 +1,30 @@
# New `image:` `platform: online_image` form. Exercises online_image/image.py
# through the real platform loader and codegen pipeline.
esphome:
name: test
esp32:
board: esp32s3box
wifi:
ssid: MySSID
password: password1
http_request:
verify_ssl: false
image:
- platform: online_image
id: test_online_image
url: http://example.com/image.png
format: png
type: rgb565
spi:
mosi_pin: 6
clk_pin: 7
display:
- platform: mipi_spi
id: lcd_display
model: s3box
@@ -0,0 +1,29 @@
# Legacy top-level `online_image:` form. Exercises the deprecation shim and the
# shared codegen path through the real read_config/codegen pipeline.
esphome:
name: test
esp32:
board: esp32s3box
wifi:
ssid: MySSID
password: password1
http_request:
verify_ssl: false
online_image:
- id: test_online_image
url: http://example.com/image.png
format: png
type: rgb565
spi:
mosi_pin: 6
clk_pin: 7
display:
- platform: mipi_spi
id: lcd_display
model: s3box
@@ -0,0 +1,76 @@
"""Tests for the online_image platform and the legacy `online_image:` shim."""
from __future__ import annotations
from collections.abc import Callable
import logging
from pathlib import Path
import pytest
from esphome.components.online_image import (
DOMAIN,
LEGACY_REMOVAL_VERSION,
_capture_legacy_entry,
_warn_legacy_online_image,
)
from esphome.core import CORE
from esphome.types import ConfigType
# ---------------------------------------------------------------------------
# Legacy top-level `online_image:` deprecation shim -- REMOVE these tests after
# 2027.1.0 together with the shim in esphome/components/online_image/__init__.py.
# ---------------------------------------------------------------------------
def test_warn_legacy_online_image_warns_once(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The deprecation warning fires exactly once and never mutates the config."""
config: ConfigType = {"id": "test_online_image", "url": "http://example.com/i.png"}
# A per-entry capture (CONFIG_SCHEMA step) records the raw entry so the
# one-shot warning can print a pasteable migrated block.
assert _capture_legacy_entry(config) is config
with caplog.at_level(logging.WARNING):
# First call: flag not yet set -> warns and records the flag.
assert _warn_legacy_online_image(config) is config
# Second call: flag already set -> stays silent (the dedup branch).
assert _warn_legacy_online_image(config) is config
assert CORE.data[DOMAIN]["legacy_warning_shown"] is True
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1
assert "deprecated" in caplog.text
assert "platform: online_image" in caplog.text
assert LEGACY_REMOVAL_VERSION in caplog.text
def test_legacy_online_image_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
) -> None:
"""The legacy `online_image:` block validates, warns, and generates codegen
through the real read_config/codegen pipeline."""
with caplog.at_level(logging.WARNING):
main_cpp = generate_main(component_config_path("online_image_test.yaml"))
# Deprecation warning surfaced through the real validation pipeline.
assert "online_image" in caplog.text
assert "deprecated" in caplog.text
# setup_online_image ran: OnlineImage object constructed and parented.
assert "new(test_online_image) online_image::OnlineImage(" in main_cpp
def test_online_image_platform_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The `image:` `platform: online_image` form generates codegen through the
real platform loader (online_image/image.py) without a deprecation warning."""
main_cpp = generate_main(component_config_path("online_image_platform_test.yaml"))
assert "new(test_online_image) online_image::OnlineImage(" in main_cpp
+11 -8
View File
@@ -1,23 +1,26 @@
animation:
- id: rgb565_animation
image:
- platform: animation
id: rgb565_animation
file: $component_dir/anim.gif
type: RGB565
transparency: opaque
resize: 50x50
- id: rgb_animation
- platform: animation
id: rgb_animation
file: $component_dir/anim.apng
type: RGB
transparency: chroma_key
resize: 50x50
- id: grayscale_animation
- platform: animation
id: grayscale_animation
file: $component_dir/anim.apng
type: grayscale
display:
lambda: |-
id(rgb565_animation).next_frame();
id(rgb_animation1).next_frame();
id(grayscale_animation2).next_frame();
id(rgb_animation).next_frame();
id(grayscale_animation).next_frame();
it.image(0, 0, rgb565_animation);
it.image(120, 0, rgb_animation1);
it.image(240, 0, grayscale_animation2);
it.image(120, 0, rgb_animation);
it.image(240, 0, grayscale_animation);
@@ -0,0 +1,16 @@
# Legacy top-level `animation:` form (deprecated; migrates to
# `platform: animation`). Config-only test exercising the deprecation path.
display:
- platform: sdl
id: animation_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
animation:
- id: legacy_animation
file: $component_dir/anim.gif
type: RGB565
transparency: opaque
resize: 50x50
+17
View File
@@ -0,0 +1,17 @@
image:
- platform: file
id: file_binary_image
file: ../../pnglogo.png
type: BINARY
dither: FloydSteinberg
- platform: file
id: file_rgb565_image
file: ../../pnglogo.png
type: RGB565
transparency: alpha_channel
resize: 50x50
- platform: file
id: file_mdi_image
file: mdi:alert-circle-outline
type: BINARY
resize: 24x24
+14
View File
@@ -0,0 +1,14 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
display:
- platform: ili9xxx
id: file_main_lcd
spi_id: spi_bus
model: ili9342
cs_pin: 15
dc_pin: 13
reset_pin: 21
invert_colors: true
<<: !include common.yaml
+9
View File
@@ -0,0 +1,9 @@
display:
- platform: sdl
id: file_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
<<: !include common.yaml
+38 -19
View File
@@ -1,85 +1,104 @@
image:
- id: binary_image
- platform: file
id: binary_image
file: ../../pnglogo.png
type: BINARY
dither: FloydSteinberg
- id: transparent_transparent_image
- platform: file
id: transparent_transparent_image
file: ../../pnglogo.png
type: BINARY
transparency: chroma_key
- id: rgba_image
- platform: file
id: rgba_image
file: ../../pnglogo.png
type: RGB
transparency: alpha_channel
resize: 50x50
- id: rgb24_image
- platform: file
id: rgb24_image
file: ../../pnglogo.png
type: RGB
transparency: chroma_key
- id: rgb_image
- platform: file
id: rgb_image
file: ../../pnglogo.png
type: RGB
transparency: opaque
- id: rgb565_image
- platform: file
id: rgb565_image
file: ../../pnglogo.png
type: RGB565
transparency: opaque
- id: rgb565_ck_image
- platform: file
id: rgb565_ck_image
file: ../../pnglogo.png
type: RGB565
transparency: chroma_key
- id: rgb565_alpha_image
- platform: file
id: rgb565_alpha_image
file: ../../pnglogo.png
type: RGB565
transparency: alpha_channel
- id: grayscale_alpha_image
- platform: file
id: grayscale_alpha_image
file: ../../pnglogo.png
type: grayscale
transparency: alpha_channel
resize: 50x50
- id: grayscale_ck_image
- platform: file
id: grayscale_ck_image
file: ../../pnglogo.png
type: grayscale
transparency: chroma_key
- id: grayscale_image
- platform: file
id: grayscale_image
file: ../../pnglogo.png
type: grayscale
transparency: opaque
- id: web_svg_image
- platform: file
id: web_svg_image
file: https://media.esphome.io/logo/logo.svg
resize: 256x48
type: BINARY
transparency: chroma_key
- id: web_tiff_image
- platform: file
id: web_tiff_image
file: https://media.esphome.io/tests/images/SIPI_Jelly_Beans_4.1.07.tiff
type: RGB
resize: 48x48
- id: web_redirect_image
- platform: file
id: web_redirect_image
file: https://media.esphome.io/logo/logo.png
type: RGB
resize: 48x48
- id: mdi_alert
- platform: file
id: mdi_alert
type: BINARY
file: mdi:alert-circle-outline
resize: 50x50
- id: another_alert_icon
- platform: file
id: another_alert_icon
file: mdi:alert-outline
type: BINARY
- file: mdil:arrange-bring-to-front
- platform: file
file: mdil:arrange-bring-to-front
id: mdil_id
resize: 50x50
type: binary
transparency: chroma_key
- file: mdi:beer
- platform: file
file: mdi:beer
id: mdi_id
resize: 50x50
type: binary
transparency: chroma_key
- file: memory:alert-octagon
- platform: file
file: memory:alert-octagon
id: memory_id
resize: 50x50
type: binary
+3 -4
View File
@@ -12,12 +12,11 @@ display:
invert_colors: true
image:
defaults:
- platform: file
id: test_image
file: ../../pnglogo.png
type: rgb565
transparency: opaque
byte_order: little_endian
resize: 50x50
dither: FloydSteinberg
images:
- id: test_image
file: ../../pnglogo.png
+57 -40
View File
@@ -7,43 +7,60 @@ display:
height: 480
image:
binary:
- id: binary_image
file: ../../pnglogo.png
dither: FloydSteinberg
- id: transparent_transparent_image
file: ../../pnglogo.png
transparency: chroma_key
rgb:
alpha_channel:
- id: rgba_image
file: ../../pnglogo.png
resize: 50x50
chroma_key:
- id: rgb24_image
file: ../../pnglogo.png
type: RGB
opaque:
- id: rgb_image
file: ../../pnglogo.png
rgb565:
- id: rgb565_image
file: ../../pnglogo.png
transparency: opaque
- id: rgb565_ck_image
file: ../../pnglogo.png
transparency: chroma_key
- id: rgb565_alpha_image
file: ../../pnglogo.png
transparency: alpha_channel
grayscale:
- id: grayscale_alpha_image
file: ../../pnglogo.png
transparency: alpha_channel
resize: 50x50
- id: grayscale_ck_image
file: ../../pnglogo.png
transparency: chroma_key
- id: grayscale_image
file: ../../pnglogo.png
transparency: opaque
- platform: file
id: binary_image
file: ../../pnglogo.png
type: binary
dither: FloydSteinberg
- platform: file
id: transparent_transparent_image
file: ../../pnglogo.png
type: binary
transparency: chroma_key
- platform: file
id: rgba_image
file: ../../pnglogo.png
type: rgb
transparency: alpha_channel
resize: 50x50
- platform: file
id: rgb24_image
file: ../../pnglogo.png
type: RGB
transparency: chroma_key
- platform: file
id: rgb_image
file: ../../pnglogo.png
type: rgb
transparency: opaque
- platform: file
id: rgb565_image
file: ../../pnglogo.png
type: rgb565
transparency: opaque
- platform: file
id: rgb565_ck_image
file: ../../pnglogo.png
type: rgb565
transparency: chroma_key
- platform: file
id: rgb565_alpha_image
file: ../../pnglogo.png
type: rgb565
transparency: alpha_channel
- platform: file
id: grayscale_alpha_image
file: ../../pnglogo.png
type: grayscale
transparency: alpha_channel
resize: 50x50
- platform: file
id: grayscale_ck_image
file: ../../pnglogo.png
type: grayscale
transparency: chroma_key
- platform: file
id: grayscale_image
file: ../../pnglogo.png
type: grayscale
transparency: opaque
@@ -0,0 +1,25 @@
# Legacy top-level `image:` defaults/images form (deprecated; migrates to
# `platform: file`). Config-only test exercising the deprecation/migration path,
# including the per-type byte_order drop when an entry overrides to a non-endian
# type (binary).
display:
- platform: sdl
id: image_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
image:
defaults:
type: rgb565
transparency: opaque
byte_order: little_endian
resize: 50x50
dither: FloydSteinberg
images:
- id: legacy_defaults_image
file: ../../pnglogo.png
- id: legacy_defaults_binary
file: ../../pnglogo.png
type: binary
@@ -0,0 +1,24 @@
# Legacy top-level `image:` structured form using single-dict (non-list) values
# for `images:`, a type group, and a transparency group -- the old `ensure_list`
# accepted a bare dict in each of these places. Deprecated; migrates to
# `platform: file`. Config-only test exercising the deprecation/migration path.
display:
- platform: sdl
id: image_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
image:
images:
id: legacy_images_single_dict
file: ../../pnglogo.png
type: rgb565
rgb565:
id: legacy_grouped_type_single_dict
file: ../../pnglogo.png
rgb:
alpha_channel:
id: legacy_grouped_transparency_single_dict
file: ../../pnglogo.png
@@ -0,0 +1,25 @@
# Legacy top-level `image:` type-grouped form (deprecated; migrates to
# `platform: file`). Config-only test exercising the deprecation/migration path.
display:
- platform: sdl
id: image_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
image:
binary:
- id: legacy_grouped_binary
file: ../../pnglogo.png
rgb:
alpha_channel:
- id: legacy_grouped_rgba
file: ../../pnglogo.png
opaque:
- id: legacy_grouped_rgb
file: ../../pnglogo.png
rgb565:
- id: legacy_grouped_rgb565
file: ../../pnglogo.png
transparency: chroma_key
@@ -0,0 +1,16 @@
# Legacy top-level `image:` single-dict form (a bare image dict instead of a
# list; deprecated, migrates to `platform: file`). Config-only test exercising
# the deprecation/migration path.
display:
- platform: sdl
id: image_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
image:
id: legacy_single_image
file: ../../pnglogo.png
type: RGB565
transparency: opaque
+18
View File
@@ -0,0 +1,18 @@
# Legacy top-level `image:` list form (deprecated; migrates to `platform: file`).
# Config-only test exercising the deprecation/migration path.
display:
- platform: sdl
id: image_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
image:
- id: legacy_list_binary
file: ../../pnglogo.png
type: BINARY
- id: legacy_list_rgb565
file: ../../pnglogo.png
type: RGB565
transparency: alpha_channel
+17 -12
View File
@@ -2,11 +2,9 @@ wifi:
ssid: MySSID
password: password1
# Purposely test that `online_image:` does auto-load `image:`
# Keep the `image:` undefined.
# image:
online_image:
- id: online_binary_image
image:
- platform: online_image
id: online_binary_image
url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png
format: PNG
type: BINARY
@@ -21,34 +19,41 @@ online_image:
} else {
ESP_LOGD("online_image", "Cache miss: fresh download");
}
- id: online_binary_transparent_image
- platform: online_image
id: online_binary_transparent_image
url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png
type: BINARY
transparency: chroma_key
format: png
- id: online_rgba_image
- platform: online_image
id: online_rgba_image
url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png
format: PNG
type: RGB
transparency: alpha_channel
- id: online_rgb24_image
- platform: online_image
id: online_rgb24_image
url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png
format: PNG
type: RGB
transparency: chroma_key
- id: online_binary_bmp
- platform: online_image
id: online_binary_bmp
url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp
format: BMP
type: BINARY
- id: online_rgb_bmp_8bit
- platform: online_image
id: online_rgb_bmp_8bit
url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp
format: BMP
type: RGB
- id: online_jpeg_image
- platform: online_image
id: online_jpeg_image
url: http://www.faqs.org/images/library.jpg
format: JPEG
type: RGB
- id: online_jpg_image
- platform: online_image
id: online_jpg_image
url: http://www.faqs.org/images/library.jpg
format: JPG
type: RGB565
@@ -0,0 +1,22 @@
# Legacy top-level `online_image:` form (deprecated; migrates to
# `platform: online_image`). Config-only test exercising the deprecation path.
wifi:
ssid: MySSID
password: password1
http_request:
display:
- platform: sdl
id: online_image_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
online_image:
- id: legacy_online_image
url: http://www.example.org/example.png
format: PNG
type: RGB565
resize: 50x50
+83 -2
View File
@@ -1,6 +1,6 @@
"""Unit tests for esphome.config module."""
from collections.abc import Generator
from collections.abc import Callable, Generator
import logging
from pathlib import Path
from unittest.mock import MagicMock, Mock, patch
@@ -8,7 +8,8 @@ from unittest.mock import MagicMock, Mock, patch
import pytest
from esphome import config, yaml_util
from esphome.core import CORE
from esphome.core import CORE, AutoLoad
from esphome.types import ConfigType
@pytest.fixture
@@ -116,6 +117,86 @@ def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None:
assert "web_server" in platforms, f"Expected web_server platform in {platforms}"
# ---------------------------------------------------------------------------
# LEGACY_CONFIG_MIGRATE hook on LoadValidationStep -- the removable shim that
# lets a platform component rewrite a pre-platform top-level config.
# ---------------------------------------------------------------------------
def _run_load_step(
domain: str,
conf: object,
migrate: Callable[[ConfigType], list | None] | None,
) -> config.Config:
"""Run a LoadValidationStep for a platform component with a given migrate hook."""
component = Mock()
component.is_platform_component = True
component.multi_conf_no_default = False
component.legacy_config_migrate = migrate
result = config.Config()
with (
patch("esphome.config.get_component", return_value=component),
patch("esphome.config._process_auto_load"),
patch("esphome.config._process_platform_config"),
):
config.LoadValidationStep(domain, conf).run(result)
return result
def test_legacy_migrate_rewrites_conf() -> None:
"""A legacy config that the hook migrates is replaced with the new list."""
migrated = [{"platform": "file", "id": "a"}]
migrate = Mock(return_value=migrated)
result = _run_load_step("image", [{"id": "a", "file": "x.png"}], migrate)
migrate.assert_called_once_with([{"id": "a", "file": "x.png"}])
assert result["image"] == migrated
def test_legacy_migrate_none_keeps_new_format() -> None:
"""When the hook returns None the already-new config is left untouched."""
new_format = [{"platform": "file", "id": "a"}]
migrate = Mock(return_value=None)
result = _run_load_step("image", new_format, migrate)
migrate.assert_called_once_with(new_format)
assert result["image"] == new_format
def test_legacy_migrate_absent_hook_is_noop() -> None:
"""A platform component without the hook normalizes without migration."""
result = _run_load_step("image", {"id": "a"}, None)
# Bare dict still gets wrapped into a list by the normal normalization path.
assert result["image"] == [{"id": "a"}]
def test_legacy_migrate_skipped_for_empty_conf() -> None:
"""An empty config short-circuits before the hook is consulted."""
migrate = Mock(return_value=[{"platform": "file"}])
result = _run_load_step("image", [], migrate)
migrate.assert_not_called()
assert result["image"] == []
def test_legacy_migrate_skipped_for_autoload() -> None:
"""An auto-loaded (AutoLoad) config is never migrated."""
migrate = Mock(return_value=[{"platform": "file"}])
auto = AutoLoad()
auto["id"] = "a"
result = _run_load_step("image", auto, migrate)
migrate.assert_not_called()
# AutoLoad is dict-like, so normalization wraps it into a single-entry list.
assert result["image"] == [auto]
def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path:
"""Create a config where two `<<` includes both define `logger:`.