diff --git a/docker/Dockerfile b/docker/Dockerfile index 95b6677815..2d9a8a9ae4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -29,6 +29,8 @@ RUN \ git=1:2.30.2-1+deb11u2 \ curl=7.74.0-1.3+deb11u7 \ openssh-client=1:8.4p1-5+deb11u1 \ + libcairo2=1.16.0-5 \ + python3-cffi=1.14.5-1 \ && rm -rf \ /tmp/* \ /var/{cache,log}/* \ diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 4260636fa9..113c5a2df1 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -1,5 +1,10 @@ import logging +import io +from pathlib import Path +import re +import requests + from esphome import core from esphome.components import display, font import esphome.config_validation as cv @@ -7,15 +12,19 @@ import esphome.codegen as cg from esphome.const import ( CONF_DITHER, CONF_FILE, + CONF_ICON, CONF_ID, + CONF_PATH, CONF_RAW_DATA_ID, CONF_RESIZE, + CONF_SOURCE, CONF_TYPE, ) from esphome.core import CORE, HexInt _LOGGER = logging.getLogger(__name__) +DOMAIN = "image" DEPENDENCIES = ["display"] MULTI_CONF = True @@ -31,9 +40,58 @@ IMAGE_TYPE = { CONF_USE_TRANSPARENCY = "use_transparency" +# If the MDI file cannot be downloaded within this time, abort. +MDI_DOWNLOAD_TIMEOUT = 30 # seconds + +SOURCE_LOCAL = "local" +SOURCE_MDI = "mdi" + Image_ = display.display_ns.class_("Image") +def _compute_local_icon_path(value) -> Path: + base_dir = Path(CORE.config_dir) / ".esphome" / DOMAIN / "mdi" + return base_dir / f"{value[CONF_ICON]}.svg" + + +def download_mdi(value): + mdi_id = value[CONF_ICON] + path = _compute_local_icon_path(value) + if path.is_file(): + return value + url = f"https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/{mdi_id}.svg" + _LOGGER.debug("Downloading %s MDI image from %s", mdi_id, url) + try: + req = requests.get(url, timeout=MDI_DOWNLOAD_TIMEOUT) + req.raise_for_status() + except requests.exceptions.RequestException as e: + raise cv.Invalid(f"Could not download MDI image {mdi_id} from {url}: {e}") + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(req.content) + return value + + +def validate_cairosvg_installed(value): + """Validate that cairosvg is installed""" + try: + import cairosvg + except ImportError as err: + raise cv.Invalid( + "Please install the cairosvg python package to use this feature. " + "(pip install cairosvg)" + ) from err + + major, minor, _ = cairosvg.__version__.split(".") + if major < "2" or major == "2" and minor < "2": + raise cv.Invalid( + "Please update your cairosvg installation to at least 2.2.0. " + "(pip install -U cairosvg)" + ) + + return value + + def validate_cross_dependencies(config): """ Validate fields whose possible values depend on other fields. @@ -41,6 +99,13 @@ def validate_cross_dependencies(config): have "use_transparency" set to True. Also set the default value for those kind of dependent fields. """ + is_mdi = CONF_FILE in config and config[CONF_FILE][CONF_SOURCE] == SOURCE_MDI + if CONF_TYPE not in config: + if is_mdi: + config[CONF_TYPE] = "TRANSPARENT_BINARY" + else: + config[CONF_TYPE] = "BINARY" + image_type = config[CONF_TYPE] is_transparent_type = image_type in ["TRANSPARENT_BINARY", "RGBA"] @@ -51,16 +116,74 @@ def validate_cross_dependencies(config): if is_transparent_type and not config[CONF_USE_TRANSPARENCY]: raise cv.Invalid(f"Image type {image_type} must always be transparent.") + if is_mdi and config[CONF_TYPE] not in ["BINARY", "TRANSPARENT_BINARY"]: + raise cv.Invalid("MDI images must be binary images.") + return config +def validate_file_shorthand(value): + value = cv.string_strict(value) + if value.startswith("mdi:"): + validate_cairosvg_installed(value) + + match = re.search(r"mdi:([a-zA-Z0-9\-]+)", value) + if match is None: + raise cv.Invalid("Could not parse mdi icon name.") + icon = match.group(1) + return FILE_SCHEMA( + { + CONF_SOURCE: SOURCE_MDI, + CONF_ICON: icon, + } + ) + return FILE_SCHEMA( + { + CONF_SOURCE: SOURCE_LOCAL, + CONF_PATH: value, + } + ) + + +LOCAL_SCHEMA = cv.Schema( + { + cv.Required(CONF_PATH): cv.file_, + } +) + +MDI_SCHEMA = cv.All( + { + cv.Required(CONF_ICON): cv.string, + }, + download_mdi, +) + +TYPED_FILE_SCHEMA = cv.typed_schema( + { + SOURCE_LOCAL: LOCAL_SCHEMA, + SOURCE_MDI: MDI_SCHEMA, + }, + key=CONF_SOURCE, +) + + +def _file_schema(value): + if isinstance(value, str): + return validate_file_shorthand(value) + return TYPED_FILE_SCHEMA(value) + + +FILE_SCHEMA = cv.Schema(_file_schema) + IMAGE_SCHEMA = cv.Schema( cv.All( { cv.Required(CONF_ID): cv.declare_id(Image_), - cv.Required(CONF_FILE): cv.file_, + cv.Required(CONF_FILE): FILE_SCHEMA, cv.Optional(CONF_RESIZE): cv.dimensions, - cv.Optional(CONF_TYPE, default="BINARY"): cv.enum(IMAGE_TYPE, upper=True), + # Not setting default here on purpose; the default depends on the source type + # (file or mdi), and will be set in the "validate_cross_dependencies" validator. + cv.Optional(CONF_TYPE): cv.enum(IMAGE_TYPE, upper=True), # Not setting default here on purpose; the default depends on the image type, # and thus will be set in the "validate_cross_dependencies" validator. cv.Optional(CONF_USE_TRANSPARENCY): cv.boolean, @@ -79,24 +202,43 @@ CONFIG_SCHEMA = cv.All(font.validate_pillow_installed, IMAGE_SCHEMA) async def to_code(config): from PIL import Image - path = CORE.relative_config_path(config[CONF_FILE]) - try: - image = Image.open(path) - except Exception as e: - raise core.EsphomeError(f"Could not load image file {path}: {e}") + conf_file = config[CONF_FILE] + + if conf_file[CONF_SOURCE] == SOURCE_LOCAL: + path = CORE.relative_config_path(conf_file[CONF_PATH]) + try: + image = Image.open(path) + except Exception as e: + raise core.EsphomeError(f"Could not load image file {path}: {e}") + if CONF_RESIZE in config: + image.thumbnail(config[CONF_RESIZE]) + elif conf_file[CONF_SOURCE] == SOURCE_MDI: + # Those imports are only needed in case of MDI images; adding them + # to the top would force configurations not using MDI to also have them + # installed for no reason. + from cairosvg import svg2png + + svg_file = _compute_local_icon_path(conf_file) + if CONF_RESIZE in config: + req_width, req_height = config[CONF_RESIZE] + svg_image = svg2png( + url=svg_file.as_posix(), + output_width=req_width, + output_height=req_height, + ) + else: + svg_image = svg2png(url=svg_file.as_posix()) + + image = Image.open(io.BytesIO(svg_image)) width, height = image.size - if CONF_RESIZE in config: - image.thumbnail(config[CONF_RESIZE]) - width, height = image.size - else: - if width > 500 or height > 500: - _LOGGER.warning( - 'The image "%s" you requested is very big. Please consider' - " using the resize parameter.", - path, - ) + if CONF_RESIZE not in config and (width > 500 or height > 500): + _LOGGER.warning( + 'The image "%s" you requested is very big. Please consider' + " using the resize parameter.", + path, + ) transparent = config[CONF_USE_TRANSPARENCY] diff --git a/requirements_optional.txt b/requirements_optional.txt index 2c73430109..df6b3b387e 100644 --- a/requirements_optional.txt +++ b/requirements_optional.txt @@ -1,2 +1,3 @@ pillow>4.0.0 +cairosvg>=2.2.0 cryptography>=2.0.0,<4 diff --git a/tests/test2.yaml b/tests/test2.yaml index 8f56336848..0dae0b2570 100644 --- a/tests/test2.yaml +++ b/tests/test2.yaml @@ -680,6 +680,13 @@ image: type: RGB565 use_transparency: no + - id: mdi_alert + file: mdi:alert-circle-outline + resize: 50x50 + - id: another_alert_icon + file: mdi:alert-outline + type: BINARY + cap1188: id: cap1188_component address: 0x29