mirror of
https://github.com/esphome/esphome.git
synced 2026-07-06 23:19:36 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 014131233e | |||
| cb0c428d6a | |||
| 1d49daa90c | |||
| 0b1e3283d5 | |||
| d1938a87aa | |||
| dc5e6a20af |
@@ -1,3 +1,5 @@
|
||||
# Normalize line endings to LF in the repository
|
||||
* text eol=lf
|
||||
*.png binary
|
||||
*.gif binary
|
||||
*.apng binary
|
||||
|
||||
@@ -187,6 +187,7 @@ esphome/components/ezo_pmp/* @carlos-sarmiento
|
||||
esphome/components/factory_reset/* @anatoly-savchenkov
|
||||
esphome/components/fastled_base/* @OttoWinter
|
||||
esphome/components/feedback/* @ianchi
|
||||
esphome/components/file/* @esphome/core
|
||||
esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund
|
||||
esphome/components/font/* @clydebarrow @esphome/core
|
||||
esphome/components/fs3000/* @kahrendt
|
||||
|
||||
@@ -1,114 +1,36 @@
|
||||
import logging
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy top-level `animation:` deprecation shim -- REMOVE this whole file after
|
||||
# 2027.1.0.
|
||||
#
|
||||
# Animations are now a platform of the `image:` component (`platform:
|
||||
# animation`); the real schema, actions and codegen live in `image.py`. This
|
||||
# module only keeps the deprecated top-level `animation:` key working during the
|
||||
# deprecation window: it reuses that schema/codegen and adds a one-shot
|
||||
# deprecation warning (with a pasteable migrated `image:` block) at validation
|
||||
# time. Deleting this file drops the top-level form entirely.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_LOOP
|
||||
import esphome.components.image as espImage
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_REPEAT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .image import ANIMATION_CONFIG_SCHEMA, setup_animation
|
||||
|
||||
AUTO_LOAD = ["image"]
|
||||
AUTO_LOAD = ["image", "file"]
|
||||
CODEOWNERS = ["@syndlex"]
|
||||
DEPENDENCIES = ["display"]
|
||||
MULTI_CONF = True
|
||||
MULTI_CONF_NO_DEFAULT = True
|
||||
|
||||
CONF_START_FRAME = "start_frame"
|
||||
CONF_END_FRAME = "end_frame"
|
||||
CONF_FRAME = "frame"
|
||||
DOMAIN = "animation"
|
||||
|
||||
animation_ns = cg.esphome_ns.namespace("animation")
|
||||
LEGACY_REMOVAL_VERSION = "2027.1.0"
|
||||
|
||||
Animation_ = animation_ns.class_("Animation", espImage.Image_)
|
||||
|
||||
# Actions
|
||||
NextFrameAction = animation_ns.class_(
|
||||
"AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_)
|
||||
)
|
||||
PrevFrameAction = animation_ns.class_(
|
||||
"AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_)
|
||||
)
|
||||
SetFrameAction = animation_ns.class_(
|
||||
"AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_)
|
||||
_capture_legacy_entry, _warn_legacy_animation = (
|
||||
espImage.legacy_platform_migration_warning(DOMAIN, DOMAIN, LEGACY_REMOVAL_VERSION)
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
espImage.IMAGE_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(Animation_),
|
||||
cv.Optional(CONF_LOOP): cv.All(
|
||||
{
|
||||
cv.Optional(CONF_START_FRAME, default=0): cv.positive_int,
|
||||
cv.Optional(CONF_END_FRAME): cv.positive_int,
|
||||
cv.Optional(CONF_REPEAT): cv.positive_int,
|
||||
}
|
||||
),
|
||||
},
|
||||
),
|
||||
espImage.validate_settings,
|
||||
)
|
||||
CONFIG_SCHEMA = cv.All(_capture_legacy_entry, ANIMATION_CONFIG_SCHEMA)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _warn_legacy_animation
|
||||
|
||||
NEXT_FRAME_SCHEMA = automation.maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(Animation_),
|
||||
}
|
||||
)
|
||||
PREV_FRAME_SCHEMA = automation.maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(Animation_),
|
||||
}
|
||||
)
|
||||
SET_FRAME_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(Animation_),
|
||||
cv.Required(CONF_FRAME): cv.uint16_t,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
@automation.register_action(
|
||||
"animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
@automation.register_action(
|
||||
"animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
async def animation_action_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
if (frame := config.get(CONF_FRAME)) is not None:
|
||||
template_ = await cg.templatable(frame, args, cg.uint16)
|
||||
cg.add(var.set_frame(template_))
|
||||
return var
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
(
|
||||
prog_arr,
|
||||
width,
|
||||
height,
|
||||
image_type,
|
||||
trans_value,
|
||||
frame_count,
|
||||
) = await espImage.write_image(config, all_frames=True)
|
||||
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
prog_arr,
|
||||
width,
|
||||
height,
|
||||
frame_count,
|
||||
image_type,
|
||||
trans_value,
|
||||
)
|
||||
if loop_config := config.get(CONF_LOOP):
|
||||
start = loop_config[CONF_START_FRAME]
|
||||
end = loop_config.get(CONF_END_FRAME, frame_count)
|
||||
count = loop_config.get(CONF_REPEAT, -1)
|
||||
cg.add(var.set_loop(start, end, count))
|
||||
to_code = setup_animation
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_LOOP
|
||||
from esphome.components.file.image import image_schema, write_image
|
||||
from esphome.components.image import Image_, validate_settings
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_REPEAT
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@syndlex"]
|
||||
AUTO_LOAD = ["file"]
|
||||
DEPENDENCIES = ["display"]
|
||||
|
||||
CONF_START_FRAME = "start_frame"
|
||||
CONF_END_FRAME = "end_frame"
|
||||
CONF_FRAME = "frame"
|
||||
|
||||
animation_ns = cg.esphome_ns.namespace("animation")
|
||||
|
||||
Animation_ = animation_ns.class_("Animation", Image_)
|
||||
|
||||
# Actions
|
||||
NextFrameAction = animation_ns.class_(
|
||||
"AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_)
|
||||
)
|
||||
PrevFrameAction = animation_ns.class_(
|
||||
"AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_)
|
||||
)
|
||||
SetFrameAction = animation_ns.class_(
|
||||
"AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_)
|
||||
)
|
||||
|
||||
ANIMATION_SCHEMA = image_schema(Animation_).extend(
|
||||
{
|
||||
cv.Optional(CONF_LOOP): cv.All(
|
||||
{
|
||||
cv.Optional(CONF_START_FRAME, default=0): cv.positive_int,
|
||||
cv.Optional(CONF_END_FRAME): cv.positive_int,
|
||||
cv.Optional(CONF_REPEAT): cv.positive_int,
|
||||
}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# Shared schema used by both the (deprecated) top-level `animation:` key and the
|
||||
# `image:` `platform: animation` entry.
|
||||
ANIMATION_CONFIG_SCHEMA = cv.All(ANIMATION_SCHEMA, validate_settings)
|
||||
|
||||
|
||||
NEXT_FRAME_SCHEMA = automation.maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(Animation_),
|
||||
}
|
||||
)
|
||||
PREV_FRAME_SCHEMA = automation.maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(Animation_),
|
||||
}
|
||||
)
|
||||
SET_FRAME_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(Animation_),
|
||||
cv.Required(CONF_FRAME): cv.uint16_t,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
@automation.register_action(
|
||||
"animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
@automation.register_action(
|
||||
"animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
async def animation_action_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
if (frame := config.get(CONF_FRAME)) is not None:
|
||||
template_ = await cg.templatable(frame, args, cg.uint16)
|
||||
cg.add(var.set_frame(template_))
|
||||
return var
|
||||
|
||||
|
||||
async def setup_animation(config: ConfigType) -> None:
|
||||
(
|
||||
prog_arr,
|
||||
width,
|
||||
height,
|
||||
image_type,
|
||||
trans_value,
|
||||
frame_count,
|
||||
) = await write_image(config, all_frames=True)
|
||||
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
prog_arr,
|
||||
width,
|
||||
height,
|
||||
frame_count,
|
||||
image_type,
|
||||
trans_value,
|
||||
)
|
||||
if loop_config := config.get(CONF_LOOP):
|
||||
start = loop_config[CONF_START_FRAME]
|
||||
end = loop_config.get(CONF_END_FRAME, frame_count)
|
||||
count = loop_config.get(CONF_REPEAT, -1)
|
||||
cg.add(var.set_loop(start, end, count))
|
||||
|
||||
|
||||
CONFIG_SCHEMA = ANIMATION_CONFIG_SCHEMA
|
||||
|
||||
to_code = setup_animation
|
||||
@@ -0,0 +1 @@
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
@@ -0,0 +1,315 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from esphome import core, external_files
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_BYTE_ORDER
|
||||
from esphome.components.image import (
|
||||
CONF_INVERT_ALPHA,
|
||||
CONF_OPAQUE,
|
||||
CONF_TRANSPARENCY,
|
||||
DOMAIN,
|
||||
IMAGE_TYPE,
|
||||
Image_,
|
||||
ImageEncoder,
|
||||
add_metadata,
|
||||
get_image_type_enum,
|
||||
get_transparency_enum,
|
||||
is_svg_file,
|
||||
validate_settings,
|
||||
validate_transparency,
|
||||
validate_type,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_DITHER,
|
||||
CONF_FILE,
|
||||
CONF_ICON,
|
||||
CONF_ID,
|
||||
CONF_PATH,
|
||||
CONF_RAW_DATA_ID,
|
||||
CONF_RESIZE,
|
||||
CONF_SOURCE,
|
||||
CONF_TYPE,
|
||||
CONF_URL,
|
||||
)
|
||||
from esphome.core import CORE, HexInt
|
||||
from esphome.cpp_generator import MockObj, MockObjClass
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# If the MDI file cannot be downloaded within this time, abort.
|
||||
IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds
|
||||
|
||||
SOURCE_LOCAL = "local"
|
||||
SOURCE_WEB = "web"
|
||||
|
||||
SOURCE_MDI = "mdi"
|
||||
SOURCE_MDIL = "mdil"
|
||||
SOURCE_MEMORY = "memory"
|
||||
|
||||
MDI_SOURCES = {
|
||||
SOURCE_MDI: "https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/",
|
||||
SOURCE_MDIL: "https://raw.githubusercontent.com/Pictogrammers/MaterialDesignLight/refs/heads/master/svg/",
|
||||
SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/",
|
||||
}
|
||||
|
||||
|
||||
def compute_local_image_path(value) -> Path:
|
||||
url = value[CONF_URL] if isinstance(value, dict) else value
|
||||
h = hashlib.new("sha256")
|
||||
h.update(url.encode())
|
||||
key = h.hexdigest()[:8]
|
||||
# Downloaded files are cached under the shared `image` domain directory so
|
||||
# the cache location is unaffected by which platform requested the file.
|
||||
base_dir = external_files.compute_local_file_dir(DOMAIN)
|
||||
return base_dir / key
|
||||
|
||||
|
||||
def local_path(value):
|
||||
value = value[CONF_PATH] if isinstance(value, dict) else value
|
||||
return str(CORE.relative_config_path(value))
|
||||
|
||||
|
||||
def download_file(url, path):
|
||||
external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT)
|
||||
return str(path)
|
||||
|
||||
|
||||
def download_gh_svg(value, source):
|
||||
mdi_id = value[CONF_ICON] if isinstance(value, dict) else value
|
||||
base_dir = external_files.compute_local_file_dir(DOMAIN) / source
|
||||
path = base_dir / f"{mdi_id}.svg"
|
||||
|
||||
url = MDI_SOURCES[source] + mdi_id + ".svg"
|
||||
return download_file(url, path)
|
||||
|
||||
|
||||
def download_image(value):
|
||||
value = value[CONF_URL] if isinstance(value, dict) else value
|
||||
return download_file(value, compute_local_image_path(value))
|
||||
|
||||
|
||||
def validate_file_shorthand(value):
|
||||
value = cv.string_strict(value)
|
||||
parts = value.strip().split(":")
|
||||
if len(parts) == 2 and parts[0] in MDI_SOURCES:
|
||||
match = re.match(r"^[a-zA-Z0-9\-]+$", parts[1])
|
||||
if match is None:
|
||||
raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.")
|
||||
return download_gh_svg(parts[1], parts[0])
|
||||
|
||||
if value.startswith(("http://", "https://")):
|
||||
return download_image(value)
|
||||
|
||||
value = cv.file_(value)
|
||||
return local_path(value)
|
||||
|
||||
|
||||
LOCAL_SCHEMA = cv.All(
|
||||
{
|
||||
cv.Required(CONF_PATH): cv.file_,
|
||||
},
|
||||
local_path,
|
||||
)
|
||||
|
||||
|
||||
def mdi_schema(source):
|
||||
def validate_mdi(value):
|
||||
return download_gh_svg(value, source)
|
||||
|
||||
return cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ICON): cv.string,
|
||||
}
|
||||
),
|
||||
validate_mdi,
|
||||
)
|
||||
|
||||
|
||||
WEB_SCHEMA = cv.All(
|
||||
{
|
||||
cv.Required(CONF_URL): cv.string,
|
||||
},
|
||||
download_image,
|
||||
)
|
||||
|
||||
|
||||
TYPED_FILE_SCHEMA = cv.typed_schema(
|
||||
{
|
||||
SOURCE_LOCAL: LOCAL_SCHEMA,
|
||||
SOURCE_WEB: WEB_SCHEMA,
|
||||
}
|
||||
| {source: mdi_schema(source) for source in MDI_SOURCES},
|
||||
key=CONF_SOURCE,
|
||||
)
|
||||
|
||||
|
||||
OPTIONS_SCHEMA = {
|
||||
cv.Optional(CONF_RESIZE): cv.dimensions,
|
||||
cv.Optional(CONF_DITHER, default="NONE"): cv.one_of(
|
||||
"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_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(),
|
||||
}
|
||||
|
||||
|
||||
def image_schema(class_: MockObjClass = Image_) -> cv.Schema:
|
||||
"""Build the validation schema for a single file-backed image entry.
|
||||
|
||||
Shared by the built-in ``file`` image platform and the ``animation``
|
||||
platform (which extends it). Platforms that source their pixels elsewhere
|
||||
(e.g. ``online_image``) provide their own schema instead.
|
||||
|
||||
:param class_: The declared C++ class for the generated image instance.
|
||||
"""
|
||||
return cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(class_),
|
||||
cv.Required(CONF_FILE): cv.Any(validate_file_shorthand, TYPED_FILE_SCHEMA),
|
||||
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
|
||||
**OPTIONS_SCHEMA,
|
||||
cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def validate_image_final(config: ConfigType) -> ConfigType:
|
||||
"""Per-entry final validation, shared by file-backed image platforms.
|
||||
|
||||
For LVGL 9 the default byte order for RGB565 images is little-endian, so
|
||||
fill in that default when the user did not specify a byte order and warn
|
||||
when big-endian was explicitly requested.
|
||||
"""
|
||||
if byte_order := config.get(CONF_BYTE_ORDER):
|
||||
if byte_order == "BIG_ENDIAN":
|
||||
_LOGGER.warning(
|
||||
"The image '%s' is configured with big-endian byte order, little-endian is expected",
|
||||
config.get(CONF_FILE),
|
||||
)
|
||||
else:
|
||||
config[CONF_BYTE_ORDER] = "LITTLE_ENDIAN"
|
||||
return config
|
||||
|
||||
|
||||
async def new_image(config: ConfigType) -> MockObj:
|
||||
"""Generate a single file-backed ``image::Image`` instance.
|
||||
|
||||
Used by the built-in ``file`` platform; encodes the image data, registers
|
||||
the C++ variable and records its metadata for other components to consume.
|
||||
"""
|
||||
prog_arr, width, height, image_type, trans_value, _ = await write_image(config)
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID], prog_arr, width, height, image_type, trans_value
|
||||
)
|
||||
add_metadata(
|
||||
config[CONF_ID], width, height, config[CONF_TYPE], config[CONF_TRANSPARENCY]
|
||||
)
|
||||
return var
|
||||
|
||||
|
||||
async def write_image(config, all_frames=False):
|
||||
path = Path(config[CONF_FILE])
|
||||
if not path.is_file():
|
||||
raise core.EsphomeError(f"Could not load image file {path}")
|
||||
|
||||
resize = config.get(CONF_RESIZE)
|
||||
try:
|
||||
if is_svg_file(path):
|
||||
import resvg_py
|
||||
|
||||
resize = resize or (None, None)
|
||||
image_data = resvg_py.svg_to_bytes(
|
||||
svg_path=str(path), width=resize[0], height=resize[1], dpi=100
|
||||
)
|
||||
|
||||
# Convert bytes to Pillow Image
|
||||
image = Image.open(io.BytesIO(image_data))
|
||||
width, height = image.size
|
||||
|
||||
else:
|
||||
image = Image.open(path)
|
||||
width, height = image.size
|
||||
if resize:
|
||||
# Preserve aspect ratio
|
||||
new_width_max = min(width, resize[0])
|
||||
new_height_max = min(height, resize[1])
|
||||
ratio = min(new_width_max / width, new_height_max / height)
|
||||
width, height = int(width * ratio), int(height * ratio)
|
||||
except (OSError, UnidentifiedImageError, ValueError) as exc:
|
||||
raise core.EsphomeError(f"Could not read image file {path}: {exc}") from exc
|
||||
|
||||
if not resize and (width > 500 or height > 500):
|
||||
_LOGGER.warning(
|
||||
'The image "%s" you requested is very big. Please consider'
|
||||
" using the resize parameter.",
|
||||
path,
|
||||
)
|
||||
|
||||
dither = (
|
||||
Image.Dither.NONE
|
||||
if config[CONF_DITHER] == "NONE"
|
||||
else Image.Dither.FLOYDSTEINBERG
|
||||
)
|
||||
type = config[CONF_TYPE]
|
||||
transparency = config.get(CONF_TRANSPARENCY, CONF_OPAQUE)
|
||||
invert_alpha = config[CONF_INVERT_ALPHA]
|
||||
frame_count = 1
|
||||
if all_frames:
|
||||
with contextlib.suppress(AttributeError):
|
||||
frame_count = image.n_frames
|
||||
if frame_count <= 1:
|
||||
_LOGGER.warning("Image file %s has no animation frames", path)
|
||||
|
||||
# Encode each frame with its own encoder and concatenate. This keeps every
|
||||
# frame self-contained on disk (e.g. RGB565+alpha emits [RGB plane | alpha plane]
|
||||
# per frame) so animation frame stepping in image.cpp / animation.cpp stays
|
||||
# correct without needing to know the total frame count.
|
||||
byte_order = config.get(CONF_BYTE_ORDER)
|
||||
combined_data: list[int] = []
|
||||
encoder: ImageEncoder | None = None
|
||||
for frame_index in range(frame_count):
|
||||
image.seek(frame_index)
|
||||
encoder = IMAGE_TYPE[type](width, height, transparency, dither, invert_alpha)
|
||||
if byte_order is not None:
|
||||
# Check for valid type has already been done in validate_settings
|
||||
encoder.set_big_endian(byte_order == "BIG_ENDIAN")
|
||||
pixels = encoder.convert(image.resize((width, height)), path).getdata()
|
||||
for row in range(height):
|
||||
for col in range(width):
|
||||
encoder.encode(pixels[row * width + col])
|
||||
encoder.end_row()
|
||||
encoder.end_image()
|
||||
combined_data.extend(encoder.data)
|
||||
|
||||
rhs = [HexInt(x) for x in combined_data]
|
||||
prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs)
|
||||
image_type = get_image_type_enum(type)
|
||||
trans_value = get_transparency_enum(encoder.transparency)
|
||||
|
||||
return prog_arr, width, height, image_type, trans_value, frame_count
|
||||
|
||||
|
||||
# The built-in static-image platform: pixels embedded at compile time from a
|
||||
# local file, a downloaded web image, or a Material Design Icon.
|
||||
CONFIG_SCHEMA = cv.All(image_schema(Image_), validate_settings)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = validate_image_final
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
await new_image(config)
|
||||
@@ -1,38 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from esphome import core, external_files
|
||||
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_DITHER,
|
||||
CONF_FILE,
|
||||
CONF_ICON,
|
||||
CONF_ID,
|
||||
CONF_PATH,
|
||||
CONF_RAW_DATA_ID,
|
||||
CONF_RESIZE,
|
||||
CONF_SOURCE,
|
||||
CONF_TYPE,
|
||||
CONF_URL,
|
||||
)
|
||||
from esphome.core import CORE, HexInt
|
||||
from esphome.const import CONF_DEFAULTS, CONF_FILE, CONF_ID, CONF_PLATFORM, CONF_TYPE
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "image"
|
||||
DEPENDENCIES = ["display"]
|
||||
IS_PLATFORM_COMPONENT = True
|
||||
|
||||
# Name of the built-in static-image platform (local file / web / MDI sources).
|
||||
PLATFORM_FILE = "file"
|
||||
|
||||
image_ns = cg.esphome_ns.namespace("image")
|
||||
|
||||
@@ -135,17 +124,6 @@ class ImageEncoder:
|
||||
"""
|
||||
return False
|
||||
|
||||
@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):
|
||||
"""
|
||||
@@ -338,60 +316,11 @@ TransparencyType = image_ns.enum("TransparencyType")
|
||||
|
||||
CONF_TRANSPARENCY = "transparency"
|
||||
|
||||
# If the MDI file cannot be downloaded within this time, abort.
|
||||
IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds
|
||||
|
||||
SOURCE_LOCAL = "local"
|
||||
SOURCE_WEB = "web"
|
||||
|
||||
SOURCE_MDI = "mdi"
|
||||
SOURCE_MDIL = "mdil"
|
||||
SOURCE_MEMORY = "memory"
|
||||
|
||||
MDI_SOURCES = {
|
||||
SOURCE_MDI: "https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/",
|
||||
SOURCE_MDIL: "https://raw.githubusercontent.com/Pictogrammers/MaterialDesignLight/refs/heads/master/svg/",
|
||||
SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/",
|
||||
}
|
||||
|
||||
Image_ = image_ns.class_("Image")
|
||||
|
||||
INSTANCE_TYPE = Image_
|
||||
|
||||
|
||||
def compute_local_image_path(value) -> Path:
|
||||
url = value[CONF_URL] if isinstance(value, dict) else value
|
||||
h = hashlib.new("sha256")
|
||||
h.update(url.encode())
|
||||
key = h.hexdigest()[:8]
|
||||
base_dir = external_files.compute_local_file_dir(DOMAIN)
|
||||
return base_dir / key
|
||||
|
||||
|
||||
def local_path(value):
|
||||
value = value[CONF_PATH] if isinstance(value, dict) else value
|
||||
return str(CORE.relative_config_path(value))
|
||||
|
||||
|
||||
def download_file(url, path):
|
||||
external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT)
|
||||
return str(path)
|
||||
|
||||
|
||||
def download_gh_svg(value, source):
|
||||
mdi_id = value[CONF_ICON] if isinstance(value, dict) else value
|
||||
base_dir = external_files.compute_local_file_dir(DOMAIN) / source
|
||||
path = base_dir / f"{mdi_id}.svg"
|
||||
|
||||
url = MDI_SOURCES[source] + mdi_id + ".svg"
|
||||
return download_file(url, path)
|
||||
|
||||
|
||||
def download_image(value):
|
||||
value = value[CONF_URL] if isinstance(value, dict) else value
|
||||
return download_file(value, compute_local_image_path(value))
|
||||
|
||||
|
||||
def is_svg_file(file):
|
||||
if not file:
|
||||
return False
|
||||
@@ -399,62 +328,6 @@ def is_svg_file(file):
|
||||
return "<svg" in str(f.read(1024))
|
||||
|
||||
|
||||
def validate_file_shorthand(value):
|
||||
value = cv.string_strict(value)
|
||||
parts = value.strip().split(":")
|
||||
if len(parts) == 2 and parts[0] in MDI_SOURCES:
|
||||
match = re.match(r"^[a-zA-Z0-9\-]+$", parts[1])
|
||||
if match is None:
|
||||
raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.")
|
||||
return download_gh_svg(parts[1], parts[0])
|
||||
|
||||
if value.startswith(("http://", "https://")):
|
||||
return download_image(value)
|
||||
|
||||
value = cv.file_(value)
|
||||
return local_path(value)
|
||||
|
||||
|
||||
LOCAL_SCHEMA = cv.All(
|
||||
{
|
||||
cv.Required(CONF_PATH): cv.file_,
|
||||
},
|
||||
local_path,
|
||||
)
|
||||
|
||||
|
||||
def mdi_schema(source):
|
||||
def validate_mdi(value):
|
||||
return download_gh_svg(value, source)
|
||||
|
||||
return cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ICON): cv.string,
|
||||
}
|
||||
),
|
||||
validate_mdi,
|
||||
)
|
||||
|
||||
|
||||
WEB_SCHEMA = cv.All(
|
||||
{
|
||||
cv.Required(CONF_URL): cv.string,
|
||||
},
|
||||
download_image,
|
||||
)
|
||||
|
||||
|
||||
TYPED_FILE_SCHEMA = cv.typed_schema(
|
||||
{
|
||||
SOURCE_LOCAL: LOCAL_SCHEMA,
|
||||
SOURCE_WEB: WEB_SCHEMA,
|
||||
}
|
||||
| {source: mdi_schema(source) for source in MDI_SOURCES},
|
||||
key=CONF_SOURCE,
|
||||
)
|
||||
|
||||
|
||||
def validate_transparency(choices=TRANSPARENCY_TYPES):
|
||||
def validate(value):
|
||||
if isinstance(value, bool):
|
||||
@@ -508,271 +381,6 @@ def validate_settings(value, path=()):
|
||||
return value
|
||||
|
||||
|
||||
IMAGE_ID_SCHEMA = {
|
||||
cv.Required(CONF_ID): cv.declare_id(Image_),
|
||||
cv.Required(CONF_FILE): cv.Any(validate_file_shorthand, TYPED_FILE_SCHEMA),
|
||||
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
|
||||
}
|
||||
|
||||
|
||||
OPTIONS_SCHEMA = {
|
||||
cv.Optional(CONF_RESIZE): cv.dimensions,
|
||||
cv.Optional(CONF_DITHER, default="NONE"): cv.one_of(
|
||||
"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_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(),
|
||||
}
|
||||
|
||||
DEFAULTS_SCHEMA = {
|
||||
**OPTIONS_SCHEMA,
|
||||
cv.Optional(CONF_TYPE): validate_type(IMAGE_TYPE),
|
||||
}
|
||||
|
||||
OPTIONS = [key.schema for key in OPTIONS_SCHEMA]
|
||||
|
||||
# image schema with no defaults, used with `CONF_IMAGES` in the config
|
||||
IMAGE_SCHEMA_NO_DEFAULTS = {
|
||||
**IMAGE_ID_SCHEMA,
|
||||
**{cv.Optional(key): OPTIONS_SCHEMA[key] for key in OPTIONS},
|
||||
}
|
||||
|
||||
IMAGE_SCHEMA = cv.Schema(
|
||||
{
|
||||
**IMAGE_ID_SCHEMA,
|
||||
**OPTIONS_SCHEMA,
|
||||
cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def apply_defaults(image, defaults, path):
|
||||
"""
|
||||
Apply defaults to an image configuration
|
||||
"""
|
||||
type = image.get(CONF_TYPE, defaults.get(CONF_TYPE))
|
||||
if type is None:
|
||||
raise cv.Invalid(
|
||||
"Type is required either in the image config or in the defaults", path=path
|
||||
)
|
||||
type_class = IMAGE_TYPE[type]
|
||||
config = {
|
||||
**{key: image.get(key, defaults.get(key)) for key in type_class.get_options()},
|
||||
**{key.schema: image[key.schema] for key in IMAGE_ID_SCHEMA},
|
||||
CONF_TYPE: image.get(CONF_TYPE, defaults.get(CONF_TYPE)),
|
||||
}
|
||||
validate_settings(config, path)
|
||||
return config
|
||||
|
||||
|
||||
def validate_defaults(value):
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
def typed_image_schema(image_type):
|
||||
"""
|
||||
Construct a schema for a specific image type, allowing transparency options
|
||||
"""
|
||||
return cv.Any(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(t.lower()): cv.ensure_list(
|
||||
{
|
||||
**IMAGE_ID_SCHEMA,
|
||||
**{
|
||||
cv.Optional(key): OPTIONS_SCHEMA[key]
|
||||
for key in OPTIONS
|
||||
if key != CONF_TRANSPARENCY
|
||||
},
|
||||
cv.Optional(
|
||||
CONF_TRANSPARENCY, default=t
|
||||
): validate_transparency((t,)),
|
||||
cv.Optional(CONF_TYPE, default=image_type): validate_type(
|
||||
(image_type,)
|
||||
),
|
||||
}
|
||||
)
|
||||
for t in IMAGE_TYPE[image_type].allow_config.intersection(
|
||||
TRANSPARENCY_TYPES
|
||||
)
|
||||
}
|
||||
),
|
||||
# Allow a default configuration with no transparency preselected
|
||||
cv.ensure_list(
|
||||
{
|
||||
**IMAGE_SCHEMA_NO_DEFAULTS,
|
||||
cv.Optional(CONF_TYPE, default=image_type): validate_type(
|
||||
(image_type,)
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# The config schema can be a (possibly empty) single list of images,
|
||||
# or a dictionary with optional keys `defaults:`, `images:` and the image types
|
||||
|
||||
|
||||
def _config_schema(value):
|
||||
if isinstance(value, list) or (
|
||||
isinstance(value, dict) and (CONF_ID in value or CONF_FILE in value)
|
||||
):
|
||||
return cv.ensure_list(cv.All(IMAGE_SCHEMA, validate_settings))(value)
|
||||
if not isinstance(value, dict):
|
||||
raise cv.Invalid(
|
||||
"Badly formed image configuration, expected a list or a dictionary",
|
||||
)
|
||||
return cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_DEFAULTS, default={}): DEFAULTS_SCHEMA,
|
||||
cv.Optional(CONF_IMAGES, default=[]): cv.ensure_list(
|
||||
{
|
||||
**IMAGE_SCHEMA_NO_DEFAULTS,
|
||||
cv.Optional(CONF_TYPE): validate_type(IMAGE_TYPE),
|
||||
}
|
||||
),
|
||||
**{cv.Optional(t.lower()): typed_image_schema(t) for t in IMAGE_TYPE},
|
||||
}
|
||||
),
|
||||
validate_defaults,
|
||||
)(value)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = _config_schema
|
||||
|
||||
|
||||
def _final_validate(config):
|
||||
"""
|
||||
For LVGL 9 the default byte order for RGB565 images is little-endian
|
||||
:param config:
|
||||
:return:
|
||||
"""
|
||||
config = config.copy()
|
||||
for c in config:
|
||||
if byte_order := c.get(CONF_BYTE_ORDER):
|
||||
if byte_order == "BIG_ENDIAN":
|
||||
_LOGGER.warning(
|
||||
"The image '%s' is configured with big-endian byte order, little-endian is expected",
|
||||
c.get(CONF_FILE),
|
||||
)
|
||||
else:
|
||||
c[CONF_BYTE_ORDER] = "LITTLE_ENDIAN"
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
async def write_image(config, all_frames=False):
|
||||
path = Path(config[CONF_FILE])
|
||||
if not path.is_file():
|
||||
raise core.EsphomeError(f"Could not load image file {path}")
|
||||
|
||||
resize = config.get(CONF_RESIZE)
|
||||
try:
|
||||
if is_svg_file(path):
|
||||
import resvg_py
|
||||
|
||||
resize = resize or (None, None)
|
||||
image_data = resvg_py.svg_to_bytes(
|
||||
svg_path=str(path), width=resize[0], height=resize[1], dpi=100
|
||||
)
|
||||
|
||||
# Convert bytes to Pillow Image
|
||||
image = Image.open(io.BytesIO(image_data))
|
||||
width, height = image.size
|
||||
|
||||
else:
|
||||
image = Image.open(path)
|
||||
width, height = image.size
|
||||
if resize:
|
||||
# Preserve aspect ratio
|
||||
new_width_max = min(width, resize[0])
|
||||
new_height_max = min(height, resize[1])
|
||||
ratio = min(new_width_max / width, new_height_max / height)
|
||||
width, height = int(width * ratio), int(height * ratio)
|
||||
except (OSError, UnidentifiedImageError, ValueError) as exc:
|
||||
raise core.EsphomeError(f"Could not read image file {path}: {exc}") from exc
|
||||
|
||||
if not resize and (width > 500 or height > 500):
|
||||
_LOGGER.warning(
|
||||
'The image "%s" you requested is very big. Please consider'
|
||||
" using the resize parameter.",
|
||||
path,
|
||||
)
|
||||
|
||||
dither = (
|
||||
Image.Dither.NONE
|
||||
if config[CONF_DITHER] == "NONE"
|
||||
else Image.Dither.FLOYDSTEINBERG
|
||||
)
|
||||
type = config[CONF_TYPE]
|
||||
transparency = config.get(CONF_TRANSPARENCY, CONF_OPAQUE)
|
||||
invert_alpha = config[CONF_INVERT_ALPHA]
|
||||
frame_count = 1
|
||||
if all_frames:
|
||||
with contextlib.suppress(AttributeError):
|
||||
frame_count = image.n_frames
|
||||
if frame_count <= 1:
|
||||
_LOGGER.warning("Image file %s has no animation frames", path)
|
||||
|
||||
# Encode each frame with its own encoder and concatenate. This keeps every
|
||||
# frame self-contained on disk (e.g. RGB565+alpha emits [RGB plane | alpha plane]
|
||||
# per frame) so animation frame stepping in image.cpp / animation.cpp stays
|
||||
# correct without needing to know the total frame count.
|
||||
byte_order = config.get(CONF_BYTE_ORDER)
|
||||
combined_data: list[int] = []
|
||||
encoder: ImageEncoder | None = None
|
||||
for frame_index in range(frame_count):
|
||||
image.seek(frame_index)
|
||||
encoder = IMAGE_TYPE[type](width, height, transparency, dither, invert_alpha)
|
||||
if byte_order is not None:
|
||||
# Check for valid type has already been done in validate_settings
|
||||
encoder.set_big_endian(byte_order == "BIG_ENDIAN")
|
||||
pixels = encoder.convert(image.resize((width, height)), path).getdata()
|
||||
for row in range(height):
|
||||
for col in range(width):
|
||||
encoder.encode(pixels[row * width + col])
|
||||
encoder.end_row()
|
||||
encoder.end_image()
|
||||
combined_data.extend(encoder.data)
|
||||
|
||||
rhs = [HexInt(x) for x in combined_data]
|
||||
prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs)
|
||||
image_type = get_image_type_enum(type)
|
||||
trans_value = get_transparency_enum(encoder.transparency)
|
||||
|
||||
return prog_arr, width, height, image_type, trans_value, frame_count
|
||||
|
||||
|
||||
def add_metadata(id: str, width: int, height: int, image_type: str, transparency):
|
||||
all_metadata = CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, {})
|
||||
all_metadata[str(id)] = ImageMetaData(
|
||||
@@ -780,17 +388,10 @@ def add_metadata(id: str, width: int, height: int, image_type: str, transparency
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
# Base platform-component codegen: each entry is generated by its platform's
|
||||
# own ``to_code``; here we only need the feature define to be present.
|
||||
cg.add_define("USE_IMAGE")
|
||||
# By now the config will be a simple list.
|
||||
for entry in config:
|
||||
prog_arr, width, height, image_type, trans_value, _ = await write_image(entry)
|
||||
cg.new_Pvariable(
|
||||
entry[CONF_ID], prog_arr, width, height, image_type, trans_value
|
||||
)
|
||||
add_metadata(
|
||||
entry[CONF_ID], width, height, entry[CONF_TYPE], entry[CONF_TRANSPARENCY]
|
||||
)
|
||||
|
||||
|
||||
def get_all_image_metadata() -> dict[str, ImageMetaData]:
|
||||
@@ -801,3 +402,198 @@ def get_all_image_metadata() -> dict[str, ImageMetaData]:
|
||||
def get_image_metadata(image_id: str) -> ImageMetaData | None:
|
||||
"""Get image metadata by ID for use by other components."""
|
||||
return get_all_image_metadata().get(image_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy top-level component -> `image:` platform deprecation helpers
|
||||
# -- REMOVE after 2027.1.0 together with the `animation:`/`online_image:` shims.
|
||||
#
|
||||
# `animation:` and `online_image:` used to be standalone top-level components and
|
||||
# are now platforms of `image:`. Their deprecated top-level shims use this helper
|
||||
# to (1) record each raw entry as it is validated and (2) print a single,
|
||||
# pasteable migrated `image:` block once every entry has been seen. The block is
|
||||
# emitted from FINAL_VALIDATE_SCHEMA, which always runs after every per-entry
|
||||
# CONFIG_SCHEMA step, so all entries are captured before it fires.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def legacy_platform_migration_warning(
|
||||
domain: str, platform: str, removal_version: str
|
||||
) -> tuple[
|
||||
Callable[[ConfigType], ConfigType],
|
||||
Callable[[ConfigType], ConfigType],
|
||||
]:
|
||||
"""Build the per-entry capture and one-shot warning validators for a
|
||||
deprecated top-level component that is now an ``image:`` platform.
|
||||
|
||||
Returns ``(capture, finalize)``:
|
||||
* ``capture`` is a ``CONFIG_SCHEMA`` validator placed *before* the real
|
||||
schema so it sees the raw user entry; it records a copy of each entry.
|
||||
* ``finalize`` is a ``FINAL_VALIDATE_SCHEMA`` validator that warns exactly
|
||||
once with the migrated, pasteable ``image:`` block.
|
||||
"""
|
||||
entries_key = "legacy_entries"
|
||||
shown_key = "legacy_warning_shown"
|
||||
|
||||
def capture(config: ConfigType) -> ConfigType:
|
||||
data = CORE.data.setdefault(domain, {})
|
||||
data.setdefault(entries_key, []).append(dict(config))
|
||||
return config
|
||||
|
||||
def finalize(config: ConfigType) -> ConfigType:
|
||||
data = CORE.data.setdefault(domain, {})
|
||||
if not data.get(shown_key):
|
||||
data[shown_key] = True
|
||||
|
||||
from esphome import yaml_util
|
||||
|
||||
migrated = [
|
||||
{CONF_PLATFORM: platform, **entry}
|
||||
for entry in data.get(entries_key, [])
|
||||
]
|
||||
_LOGGER.warning(
|
||||
"The top-level '%s:' configuration is deprecated and will be "
|
||||
"removed in ESPHome %s. '%s' is now a platform of the 'image' "
|
||||
"component. Replace your '%s:' block with:\n\n%s",
|
||||
domain,
|
||||
removal_version,
|
||||
domain,
|
||||
domain,
|
||||
yaml_util.dump({DOMAIN: migrated}),
|
||||
)
|
||||
return config
|
||||
|
||||
return capture, finalize
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy `image:` config migration -- REMOVE after 2027.1.0
|
||||
#
|
||||
# Before `image` became a platform component, its top-level config was either a
|
||||
# bare list of image dicts, a single image dict, or a dict with `defaults:`,
|
||||
# `images:` and per-type group keys. This block transparently rewrites those
|
||||
# forms into the new ``platform: file`` list and prints the migrated YAML.
|
||||
# It is intentionally self-contained so it can be deleted in one piece together
|
||||
# with the ``LEGACY_CONFIG_MIGRATE`` assignment below.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LEGACY_REMOVAL_VERSION = "2027.1.0"
|
||||
|
||||
|
||||
def _is_new_image_format(config: object) -> bool:
|
||||
"""True when the config is already the new ``platform:``-tagged list."""
|
||||
return isinstance(config, list) and all(
|
||||
isinstance(entry, dict) and CONF_PLATFORM in entry for entry in config
|
||||
)
|
||||
|
||||
|
||||
def _is_legacy_image_format(config: object) -> bool:
|
||||
"""True when ``config`` matches a shape the pre-platform schema accepted.
|
||||
|
||||
Only these shapes are migrated. Anything else -- a list containing a
|
||||
non-dict (or already platform-tagged) entry, or a dict with no recognised
|
||||
image keys -- is left untouched so the platform validation surfaces a
|
||||
proper error instead of the migration silently dropping the input.
|
||||
"""
|
||||
if isinstance(config, list):
|
||||
# A bare list of (not-yet-platform-tagged) image dicts.
|
||||
return bool(config) and all(
|
||||
isinstance(entry, dict) and CONF_PLATFORM not in entry for entry in config
|
||||
)
|
||||
if not isinstance(config, dict):
|
||||
return False
|
||||
# A single image dict, or the grouped `defaults:`/`images:`/type-key form.
|
||||
return (
|
||||
CONF_ID in config
|
||||
or CONF_FILE in config
|
||||
or any(
|
||||
key in (CONF_DEFAULTS, CONF_IMAGES) or key.upper() in IMAGE_TYPE
|
||||
for key in config
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _flatten_legacy_image_config(config: object) -> list[dict]:
|
||||
"""Structurally flatten a legacy ``image:`` config into image dicts.
|
||||
|
||||
No validation or file IO is performed -- the ``file`` platform schema
|
||||
validates the resulting entries. Unrecognised shapes yield no entries so the
|
||||
normal platform validation surfaces the error.
|
||||
"""
|
||||
if isinstance(config, list):
|
||||
return [dict(entry) for entry in config if isinstance(entry, dict)]
|
||||
if not isinstance(config, dict):
|
||||
return []
|
||||
if CONF_ID in config or CONF_FILE in config:
|
||||
return [dict(config)]
|
||||
|
||||
defaults = config.get(CONF_DEFAULTS) or {}
|
||||
result: 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)
|
||||
|
||||
def _add_entries(entries: object, extra: dict) -> None:
|
||||
# `entries` may be a single image dict or a list of them; non-dict
|
||||
# members are silently skipped, mirroring the old `ensure_list` leniency.
|
||||
for entry in [entries] if isinstance(entries, dict) else entries:
|
||||
if isinstance(entry, dict):
|
||||
_add(entry, extra)
|
||||
|
||||
_add_entries(config.get(CONF_IMAGES, []), {})
|
||||
|
||||
for key, value in config.items():
|
||||
if key in (CONF_DEFAULTS, CONF_IMAGES) or key.upper() not in IMAGE_TYPE:
|
||||
continue
|
||||
type_extra = {CONF_TYPE: key}
|
||||
if isinstance(value, dict) and (
|
||||
transparency_keys := [k for k in value if k in TRANSPARENCY_TYPES]
|
||||
):
|
||||
for trans in transparency_keys:
|
||||
_add_entries(value[trans], {**type_extra, CONF_TRANSPARENCY: trans})
|
||||
elif isinstance(value, (list, dict)):
|
||||
_add_entries(value, type_extra)
|
||||
return result
|
||||
|
||||
|
||||
def _migrate_legacy_image_config(config: object) -> list[dict] | None:
|
||||
"""Rewrite a legacy ``image:`` config into the ``platform: file`` list.
|
||||
|
||||
Returns None for the already-migrated platform form and for any shape the
|
||||
pre-platform schema never accepted, so normal platform validation can
|
||||
surface a proper error instead of the migration silently discarding input.
|
||||
"""
|
||||
if _is_new_image_format(config) or not _is_legacy_image_format(config):
|
||||
return None
|
||||
migrated = [
|
||||
{CONF_PLATFORM: PLATFORM_FILE, **entry}
|
||||
for entry in _flatten_legacy_image_config(config)
|
||||
]
|
||||
|
||||
from esphome import yaml_util
|
||||
|
||||
_LOGGER.warning(
|
||||
"The 'image:' configuration format is deprecated and will be removed in "
|
||||
"ESPHome %s. Images are now platforms of the 'image' component. Replace "
|
||||
"your 'image:' block with:\n\n%s",
|
||||
LEGACY_REMOVAL_VERSION,
|
||||
yaml_util.dump({DOMAIN: migrated}),
|
||||
)
|
||||
return migrated
|
||||
|
||||
|
||||
LEGACY_CONFIG_MIGRATE = _migrate_legacy_image_config
|
||||
|
||||
# --------------------------- end legacy migration --------------------------
|
||||
|
||||
@@ -1,150 +1,35 @@
|
||||
import logging
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy top-level `online_image:` deprecation shim -- REMOVE this whole file
|
||||
# after 2027.1.0.
|
||||
#
|
||||
# Online images are now a platform of the `image:` component (`platform:
|
||||
# online_image`); the real schema, actions and codegen live in `image.py`. This
|
||||
# module only keeps the deprecated top-level `online_image:` key working during
|
||||
# the deprecation window: it reuses that schema/codegen and adds a one-shot
|
||||
# deprecation warning (with a pasteable migrated `image:` block) at validation
|
||||
# time. Deleting this file drops the top-level form entirely.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import runtime_image
|
||||
from esphome.components.const import CONF_REQUEST_HEADERS
|
||||
from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestComponent
|
||||
from esphome.components.image import CONF_TRANSPARENCY, add_metadata
|
||||
import esphome.components.image as espImage
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_ON_ERROR, CONF_TYPE, CONF_URL
|
||||
from esphome.core import Lambda
|
||||
|
||||
from .image import ONLINE_IMAGE_CONFIG_SCHEMA, setup_online_image
|
||||
|
||||
AUTO_LOAD = ["image", "runtime_image"]
|
||||
DEPENDENCIES = ["display", "http_request"]
|
||||
CODEOWNERS = ["@guillempages", "@clydebarrow"]
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_ON_DOWNLOAD_FINISHED = "on_download_finished"
|
||||
CONF_UPDATE = "update"
|
||||
DOMAIN = "online_image"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
LEGACY_REMOVAL_VERSION = "2027.1.0"
|
||||
|
||||
online_image_ns = cg.esphome_ns.namespace("online_image")
|
||||
|
||||
OnlineImage = online_image_ns.class_(
|
||||
"OnlineImage", cg.PollingComponent, runtime_image.RuntimeImage
|
||||
_capture_legacy_entry, _warn_legacy_online_image = (
|
||||
espImage.legacy_platform_migration_warning(DOMAIN, DOMAIN, LEGACY_REMOVAL_VERSION)
|
||||
)
|
||||
|
||||
# Actions
|
||||
SetUrlAction = online_image_ns.class_(
|
||||
"OnlineImageSetUrlAction", automation.Action, cg.Parented.template(OnlineImage)
|
||||
)
|
||||
ReleaseImageAction = online_image_ns.class_(
|
||||
"OnlineImageReleaseAction", automation.Action, cg.Parented.template(OnlineImage)
|
||||
)
|
||||
CONFIG_SCHEMA = cv.All(_capture_legacy_entry, ONLINE_IMAGE_CONFIG_SCHEMA)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _warn_legacy_online_image
|
||||
|
||||
ONLINE_IMAGE_SCHEMA = (
|
||||
runtime_image.runtime_image_schema(OnlineImage)
|
||||
.extend(
|
||||
{
|
||||
# Online Image specific options
|
||||
cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent),
|
||||
cv.Required(CONF_URL): cv.url,
|
||||
cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536),
|
||||
cv.Optional(CONF_REQUEST_HEADERS): cv.All(
|
||||
cv.Schema({cv.string: cv.templatable(cv.string)})
|
||||
),
|
||||
cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_ERROR): automation.validate_automation({}),
|
||||
}
|
||||
)
|
||||
.extend(cv.polling_component_schema("never"))
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
cv.All(
|
||||
ONLINE_IMAGE_SCHEMA,
|
||||
cv.require_framework_version(
|
||||
# esp8266 not supported yet; if enabled in the future, minimum version of 2.7.0 is needed
|
||||
# esp8266_arduino=cv.Version(2, 7, 0),
|
||||
esp32_arduino=cv.Version(0, 0, 0),
|
||||
esp_idf=cv.Version(4, 0, 0),
|
||||
rp2_arduino=cv.Version(0, 0, 0),
|
||||
host=cv.Version(0, 0, 0),
|
||||
),
|
||||
runtime_image.validate_runtime_image_settings,
|
||||
)
|
||||
)
|
||||
|
||||
SET_URL_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(OnlineImage),
|
||||
cv.Required(CONF_URL): cv.templatable(cv.url),
|
||||
cv.Optional(CONF_UPDATE, default=True): cv.templatable(bool),
|
||||
}
|
||||
)
|
||||
|
||||
RELEASE_IMAGE_SCHEMA = automation.maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(OnlineImage),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"online_image.set_url", SetUrlAction, SET_URL_SCHEMA, synchronous=True
|
||||
)
|
||||
@automation.register_action(
|
||||
"online_image.release",
|
||||
ReleaseImageAction,
|
||||
RELEASE_IMAGE_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def online_image_action_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
if CONF_URL in config:
|
||||
template_ = await cg.templatable(config[CONF_URL], args, cg.std_string)
|
||||
cg.add(var.set_url(template_))
|
||||
if CONF_UPDATE in config:
|
||||
template_ = await cg.templatable(config[CONF_UPDATE], args, cg.bool_)
|
||||
cg.add(var.set_update(template_))
|
||||
return var
|
||||
|
||||
|
||||
_CALLBACK_AUTOMATIONS = (
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_DOWNLOAD_FINISHED, "add_on_finished_callback", [(bool, "cached")]
|
||||
),
|
||||
automation.CallbackAutomation(CONF_ON_ERROR, "add_on_error_callback"),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
# Use the enhanced helper function to get all runtime image parameters
|
||||
settings = await runtime_image.process_runtime_image_config(config)
|
||||
add_metadata(
|
||||
config[CONF_ID],
|
||||
settings.width,
|
||||
settings.height,
|
||||
config[CONF_TYPE],
|
||||
config[CONF_TRANSPARENCY],
|
||||
)
|
||||
|
||||
url = config[CONF_URL]
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
url,
|
||||
settings.width,
|
||||
settings.height,
|
||||
settings.format_enum,
|
||||
settings.image_type_enum,
|
||||
settings.transparent,
|
||||
settings.placeholder or cg.nullptr,
|
||||
config[CONF_BUFFER_SIZE],
|
||||
settings.byte_order_big_endian,
|
||||
)
|
||||
await cg.register_component(var, config)
|
||||
await cg.register_parented(var, config[CONF_HTTP_REQUEST_ID])
|
||||
|
||||
for key, value in config.get(CONF_REQUEST_HEADERS, {}).items():
|
||||
if isinstance(value, Lambda):
|
||||
template_ = await cg.templatable(value, [], cg.std_string)
|
||||
cg.add(var.add_request_header(key, template_))
|
||||
else:
|
||||
cg.add(var.add_request_header(key, value))
|
||||
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
to_code = setup_online_image
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import runtime_image
|
||||
from esphome.components.const import CONF_REQUEST_HEADERS
|
||||
from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestComponent
|
||||
from esphome.components.image import CONF_TRANSPARENCY, add_metadata
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_ON_ERROR, CONF_TYPE, CONF_URL
|
||||
from esphome.core import Lambda
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["runtime_image"]
|
||||
DEPENDENCIES = ["http_request"]
|
||||
CODEOWNERS = ["@guillempages", "@clydebarrow"]
|
||||
|
||||
CONF_ON_DOWNLOAD_FINISHED = "on_download_finished"
|
||||
CONF_UPDATE = "update"
|
||||
|
||||
online_image_ns = cg.esphome_ns.namespace("online_image")
|
||||
|
||||
OnlineImage = online_image_ns.class_(
|
||||
"OnlineImage", cg.PollingComponent, runtime_image.RuntimeImage
|
||||
)
|
||||
|
||||
# Actions
|
||||
SetUrlAction = online_image_ns.class_(
|
||||
"OnlineImageSetUrlAction", automation.Action, cg.Parented.template(OnlineImage)
|
||||
)
|
||||
ReleaseImageAction = online_image_ns.class_(
|
||||
"OnlineImageReleaseAction", automation.Action, cg.Parented.template(OnlineImage)
|
||||
)
|
||||
|
||||
|
||||
ONLINE_IMAGE_SCHEMA = (
|
||||
runtime_image.runtime_image_schema(OnlineImage)
|
||||
.extend(
|
||||
{
|
||||
# Online Image specific options
|
||||
cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent),
|
||||
cv.Required(CONF_URL): cv.url,
|
||||
cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536),
|
||||
cv.Optional(CONF_REQUEST_HEADERS): cv.All(
|
||||
cv.Schema({cv.string: cv.templatable(cv.string)})
|
||||
),
|
||||
cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_ERROR): automation.validate_automation({}),
|
||||
}
|
||||
)
|
||||
.extend(cv.polling_component_schema("never"))
|
||||
)
|
||||
|
||||
# Shared schema used by both the (deprecated) top-level `online_image:` key and
|
||||
# the `image:` `platform: online_image` entry.
|
||||
ONLINE_IMAGE_CONFIG_SCHEMA = cv.All(
|
||||
ONLINE_IMAGE_SCHEMA,
|
||||
cv.require_framework_version(
|
||||
# esp8266 not supported yet; if enabled in the future, minimum version of 2.7.0 is needed
|
||||
# esp8266_arduino=cv.Version(2, 7, 0),
|
||||
esp32_arduino=cv.Version(0, 0, 0),
|
||||
esp_idf=cv.Version(4, 0, 0),
|
||||
rp2_arduino=cv.Version(0, 0, 0),
|
||||
host=cv.Version(0, 0, 0),
|
||||
),
|
||||
runtime_image.validate_runtime_image_settings,
|
||||
)
|
||||
|
||||
|
||||
SET_URL_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(OnlineImage),
|
||||
cv.Required(CONF_URL): cv.templatable(cv.url),
|
||||
cv.Optional(CONF_UPDATE, default=True): cv.templatable(bool),
|
||||
}
|
||||
)
|
||||
|
||||
RELEASE_IMAGE_SCHEMA = automation.maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(OnlineImage),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"online_image.set_url", SetUrlAction, SET_URL_SCHEMA, synchronous=True
|
||||
)
|
||||
@automation.register_action(
|
||||
"online_image.release",
|
||||
ReleaseImageAction,
|
||||
RELEASE_IMAGE_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def online_image_action_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
if CONF_URL in config:
|
||||
template_ = await cg.templatable(config[CONF_URL], args, cg.std_string)
|
||||
cg.add(var.set_url(template_))
|
||||
if CONF_UPDATE in config:
|
||||
template_ = await cg.templatable(config[CONF_UPDATE], args, cg.bool_)
|
||||
cg.add(var.set_update(template_))
|
||||
return var
|
||||
|
||||
|
||||
_CALLBACK_AUTOMATIONS = (
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_DOWNLOAD_FINISHED, "add_on_finished_callback", [(bool, "cached")]
|
||||
),
|
||||
automation.CallbackAutomation(CONF_ON_ERROR, "add_on_error_callback"),
|
||||
)
|
||||
|
||||
|
||||
async def setup_online_image(config: ConfigType) -> None:
|
||||
# Use the enhanced helper function to get all runtime image parameters
|
||||
settings = await runtime_image.process_runtime_image_config(config)
|
||||
add_metadata(
|
||||
config[CONF_ID],
|
||||
settings.width,
|
||||
settings.height,
|
||||
config[CONF_TYPE],
|
||||
config[CONF_TRANSPARENCY],
|
||||
)
|
||||
|
||||
url = config[CONF_URL]
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
url,
|
||||
settings.width,
|
||||
settings.height,
|
||||
settings.format_enum,
|
||||
settings.image_type_enum,
|
||||
settings.transparent,
|
||||
settings.placeholder or cg.nullptr,
|
||||
config[CONF_BUFFER_SIZE],
|
||||
settings.byte_order_big_endian,
|
||||
)
|
||||
await cg.register_component(var, config)
|
||||
await cg.register_parented(var, config[CONF_HTTP_REQUEST_ID])
|
||||
|
||||
for key, value in config.get(CONF_REQUEST_HEADERS, {}).items():
|
||||
if isinstance(value, Lambda):
|
||||
template_ = await cg.templatable(value, [], cg.std_string)
|
||||
cg.add(var.add_request_header(key, template_))
|
||||
else:
|
||||
cg.add(var.add_request_header(key, value))
|
||||
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = ONLINE_IMAGE_CONFIG_SCHEMA
|
||||
|
||||
to_code = setup_online_image
|
||||
@@ -599,6 +599,18 @@ class LoadValidationStep(ConfigValidationStep):
|
||||
CORE.loaded_integrations.add(self.domain)
|
||||
# For platform components, normalize conf before creating MetadataValidationStep
|
||||
if component.is_platform_component:
|
||||
# Legacy config migration: allow a platform component to rewrite a
|
||||
# pre-platform-format top-level config (e.g. a bare list or legacy
|
||||
# dict form) into the normalized list of `platform:` tagged entries.
|
||||
# Removable deprecation shim hook; no-op for components that do not
|
||||
# define LEGACY_CONFIG_MIGRATE.
|
||||
if (
|
||||
(migrate := component.legacy_config_migrate) is not None
|
||||
and self.conf
|
||||
and not isinstance(self.conf, core.AutoLoad)
|
||||
and (migrated := migrate(self.conf)) is not None
|
||||
):
|
||||
result[self.domain] = self.conf = migrated
|
||||
if not self.conf:
|
||||
result[self.domain] = self.conf = []
|
||||
elif not isinstance(self.conf, list):
|
||||
|
||||
@@ -135,6 +135,19 @@ class ComponentManifest:
|
||||
"""
|
||||
return getattr(self.module, "FINAL_VALIDATE_SCHEMA", None)
|
||||
|
||||
@property
|
||||
def legacy_config_migrate(self) -> Callable[[ConfigType], ConfigType | None] | None:
|
||||
"""Optional `LEGACY_CONFIG_MIGRATE` callable on a platform component module.
|
||||
|
||||
Called once, before platform entries are processed, with the raw top-level
|
||||
config for this domain. It may transform a pre-platform-format config (e.g.
|
||||
a bare list or legacy dict form) into the normalized list of `platform:`
|
||||
tagged entries and return it. Returning ``None`` means "already in the new
|
||||
format, leave untouched". This is an intentionally removable deprecation
|
||||
shim hook.
|
||||
"""
|
||||
return getattr(self.module, "LEGACY_CONFIG_MIGRATE", None)
|
||||
|
||||
@property
|
||||
def resources(self) -> list[FileResource]:
|
||||
"""Return a list of all file resources defined in the package of this component.
|
||||
|
||||
@@ -390,16 +390,6 @@ def fix_mapping():
|
||||
output["mapping"][S_SCHEMAS][S_CONFIG_SCHEMA] = config
|
||||
|
||||
|
||||
def fix_image():
|
||||
if "image" not in output:
|
||||
return
|
||||
from esphome.components.image import IMAGE_SCHEMA
|
||||
|
||||
config = convert_config(IMAGE_SCHEMA, "image/CONFIG_SCHEMA")
|
||||
config["is_list"] = True
|
||||
output["image"][S_SCHEMAS][S_CONFIG_SCHEMA] = config
|
||||
|
||||
|
||||
def fix_menu():
|
||||
if "display_menu_base" not in output:
|
||||
return
|
||||
@@ -763,7 +753,6 @@ def build_schema():
|
||||
fix_font()
|
||||
fix_globals()
|
||||
fix_mapping()
|
||||
fix_image()
|
||||
add_logger_tags()
|
||||
shrink()
|
||||
fix_menu()
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
display:
|
||||
- platform: sdl
|
||||
id: file_display
|
||||
auto_clear_enabled: false
|
||||
dimensions:
|
||||
width: 480
|
||||
height: 480
|
||||
|
||||
<<: !include common.yaml
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user