from __future__ import annotations import contextlib 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.final_validate import full_config _LOGGER = logging.getLogger(__name__) DOMAIN = "image" DEPENDENCIES = ["display"] image_ns = cg.esphome_ns.namespace("image") ImageType = image_ns.enum("ImageType") @dataclass(frozen=True) class ImageMetaData: width: int height: int image_type: str transparency: str CONF_OPAQUE = "opaque" CONF_CHROMA_KEY = "chroma_key" CONF_ALPHA_CHANNEL = "alpha_channel" CONF_INVERT_ALPHA = "invert_alpha" CONF_IMAGES = "images" TRANSPARENCY_TYPES = ( CONF_OPAQUE, CONF_CHROMA_KEY, CONF_ALPHA_CHANNEL, ) def get_image_type_enum(type): return getattr(ImageType, f"IMAGE_TYPE_{type.upper()}") def get_transparency_enum(transparency): return getattr(TransparencyType, f"TRANSPARENCY_{transparency.upper()}") class ImageEncoder: """ Superclass of image type encoders """ # Control which transparency options are available for a given type allow_config = {CONF_ALPHA_CHANNEL, CONF_CHROMA_KEY, CONF_OPAQUE} # All imageencoder types are valid @staticmethod def validate(value): return value def __init__(self, width, height, transparency, dither, invert_alpha): """ :param width: The image width in pixels (or bytes) :param height: The image height in pixels :param transparency: Transparency type :param dither: Dither method :param invert_alpha: True if the alpha channel should be inverted; for monochrome formats inverts the colours. """ self.transparency = transparency self.width = width self.height = height self.data = [0] * width * height self.dither = dither self.index = 0 self.invert_alpha = invert_alpha self.path = "" self.big_endian = False def convert(self, image, path): """ Convert the image format :param image: Input image :param path: Path to the image file :return: converted image """ return image def encode(self, pixel): """ Encode a single pixel """ def end_row(self): """ Marks the end of a pixel row :return: """ def end_image(self): """ Called at the end of the image. :return: """ def set_big_endian(self, big_endian: bool) -> None: self.big_endian = big_endian @classmethod def is_endian(cls) -> bool: """ Check if the image encoder supports endianness configuration """ 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): """ Check if an image (assumed to be RGBA) is only alpha """ # Any alpha data? if image.split()[-1].getextrema()[0] == 0xFF: return False return all(b.getextrema()[1] == 0 for b in image.split()[:-1]) class ImageBinary(ImageEncoder): allow_config = {CONF_OPAQUE, CONF_INVERT_ALPHA, CONF_CHROMA_KEY} def __init__(self, width, height, transparency, dither, invert_alpha): self.width8 = (width + 7) // 8 super().__init__(self.width8, height, transparency, dither, invert_alpha) self.bitno = 0 def convert(self, image, path): if is_alpha_only(image): image = image.split()[-1] return image.convert("1", dither=self.dither) def encode(self, pixel): if self.invert_alpha: pixel = not pixel if pixel: self.data[self.index] |= 0x80 >> (self.bitno % 8) self.bitno += 1 if self.bitno == 8: self.bitno = 0 self.index += 1 def end_row(self): """ Pad rows to a byte boundary """ if self.bitno != 0: self.bitno = 0 self.index += 1 class ImageGrayscale(ImageEncoder): allow_config = {CONF_ALPHA_CHANNEL, CONF_CHROMA_KEY, CONF_INVERT_ALPHA, CONF_OPAQUE} def convert(self, image, path): if is_alpha_only(image): if self.transparency != CONF_ALPHA_CHANNEL: _LOGGER.warning( "Grayscale image %s is alpha only, but transparency is set to %s", path, self.transparency, ) self.transparency = CONF_ALPHA_CHANNEL image = image.split()[-1] return image.convert("LA") def encode(self, pixel): b, a = pixel if self.transparency == CONF_CHROMA_KEY: if b == 1: b = 0 if a != 0xFF: b = 1 if self.invert_alpha: b ^= 0xFF if self.transparency == CONF_ALPHA_CHANNEL and a != 0xFF: b = a self.data[self.index] = b self.index += 1 class ImageRGB565(ImageEncoder): def __init__(self, width, height, transparency, dither, invert_alpha): super().__init__( width * 2, height, transparency, dither, invert_alpha, ) self.alpha = [0] * width * height @classmethod def is_endian(cls) -> bool: """ Check if the image encoder supports endianness configuration """ return True def convert(self, image, path): return image.convert("RGBA") def encode(self, pixel): r, g, b, a = pixel r = r >> 3 g = g >> 2 b = b >> 3 if self.invert_alpha: a ^= 0xFF self.alpha[self.index // 2] = a if self.transparency == CONF_CHROMA_KEY: if r == 0 and g == 1 and b == 0: g = 0 elif a < 128: r = 0 g = 1 b = 0 rgb = (r << 11) | (g << 5) | b if self.big_endian: self.data[self.index] = rgb >> 8 self.index += 1 self.data[self.index] = rgb & 0xFF self.index += 1 else: self.data[self.index] = rgb & 0xFF self.index += 1 self.data[self.index] = rgb >> 8 self.index += 1 def end_image(self): if self.transparency == CONF_ALPHA_CHANNEL: self.data.extend(self.alpha) class ImageRGB(ImageEncoder): def __init__(self, width, height, transparency, dither, invert_alpha): stride = 4 if transparency == CONF_ALPHA_CHANNEL else 3 super().__init__( width * stride, height, transparency, dither, invert_alpha, ) def convert(self, image, path): return image.convert("RGBA") def encode(self, pixel): r, g, b, a = pixel if self.transparency == CONF_CHROMA_KEY: if r == 0 and g == 1 and b == 0: g = 0 elif a < 128: r = 0 g = 1 b = 0 self.data[self.index] = b self.index += 1 self.data[self.index] = g self.index += 1 self.data[self.index] = r self.index += 1 if self.transparency == CONF_ALPHA_CHANNEL: if self.invert_alpha: a ^= 0xFF self.data[self.index] = a self.index += 1 class ReplaceWith: """ Placeholder class to provide feedback on deprecated features """ allow_config = {CONF_ALPHA_CHANNEL, CONF_CHROMA_KEY, CONF_OPAQUE} def __init__(self, replace_with): self.replace_with = replace_with def validate(self, value): raise cv.Invalid( f"Image type {value} is removed; replace with {self.replace_with}" ) IMAGE_TYPE = { "BINARY": ImageBinary, "GRAYSCALE": ImageGrayscale, "RGB565": ImageRGB565, "RGB": ImageRGB, "TRANSPARENT_BINARY": ReplaceWith("'type: BINARY' and 'transparency: chroma_key'"), "RGB24": ReplaceWith("'type: RGB'"), "RGBA": ReplaceWith("'type: RGB' and 'transparency: alpha_channel'"), } 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 with open(file, "rb") as f: return " 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) total_rows = height * frame_count encoder = IMAGE_TYPE[type](width, total_rows, transparency, dither, invert_alpha) if byte_order := config.get(CONF_BYTE_ORDER): # Check for valid type has already been done in validate_settings encoder.set_big_endian(byte_order == "BIG_ENDIAN") for frame_index in range(frame_count): image.seek(frame_index) 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() rhs = [HexInt(x) for x in encoder.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( width=width, height=height, image_type=image_type, transparency=transparency ) async def to_code(config): 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]: """Get all image metadata.""" return CORE.data.get(DOMAIN, {}).get(KEY_METADATA, {}) 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)