Allow images to be downloaded from URLs (#5214)

Co-authored-by: guillempages <guillempages@users.noreply.github.com>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
This commit is contained in:
Landon Rohatensky 2023-11-23 11:10:33 -08:00 committed by Jesse Hills
parent cadbf7463e
commit 1204b4f1bd
No known key found for this signature in database
GPG key ID: BEAAE804EFD8E83A
5 changed files with 184 additions and 26 deletions

View file

@ -38,6 +38,7 @@ RUN \
openssh-client=1:9.2p1-2+deb12u1 \ openssh-client=1:9.2p1-2+deb12u1 \
python3-cffi=1.15.1-5 \ python3-cffi=1.15.1-5 \
libcairo2=1.16.0-7 \ libcairo2=1.16.0-7 \
libmagic1=1:5.44-3 \
patch=2.7.6-7; \ patch=2.7.6-7; \
if [ "$TARGETARCH$TARGETVARIANT" = "armv7" ]; then \ if [ "$TARGETARCH$TARGETVARIANT" = "armv7" ]; then \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \

View file

@ -1,15 +1,23 @@
from __future__ import annotations
import logging import logging
import hashlib
import io import io
from pathlib import Path from pathlib import Path
import re import re
import requests import requests
from magic import Magic
from PIL import Image
from esphome import core from esphome import core
from esphome.components import font from esphome.components import font
from esphome import external_files
import esphome.config_validation as cv import esphome.config_validation as cv
import esphome.codegen as cg import esphome.codegen as cg
from esphome.const import ( from esphome.const import (
__version__,
CONF_DITHER, CONF_DITHER,
CONF_FILE, CONF_FILE,
CONF_ICON, CONF_ICON,
@ -19,6 +27,7 @@ from esphome.const import (
CONF_RESIZE, CONF_RESIZE,
CONF_SOURCE, CONF_SOURCE,
CONF_TYPE, CONF_TYPE,
CONF_URL,
) )
from esphome.core import CORE, HexInt from esphome.core import CORE, HexInt
@ -43,34 +52,74 @@ IMAGE_TYPE = {
CONF_USE_TRANSPARENCY = "use_transparency" CONF_USE_TRANSPARENCY = "use_transparency"
# If the MDI file cannot be downloaded within this time, abort. # If the MDI file cannot be downloaded within this time, abort.
MDI_DOWNLOAD_TIMEOUT = 30 # seconds IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds
SOURCE_LOCAL = "local" SOURCE_LOCAL = "local"
SOURCE_MDI = "mdi" SOURCE_MDI = "mdi"
SOURCE_WEB = "web"
Image_ = image_ns.class_("Image") Image_ = image_ns.class_("Image")
def _compute_local_icon_path(value) -> Path: def _compute_local_icon_path(value: dict) -> Path:
base_dir = Path(CORE.data_dir) / DOMAIN / "mdi" base_dir = external_files.compute_local_file_dir(DOMAIN) / "mdi"
return base_dir / f"{value[CONF_ICON]}.svg" return base_dir / f"{value[CONF_ICON]}.svg"
def download_mdi(value): def _compute_local_image_path(value: dict) -> Path:
mdi_id = value[CONF_ICON] url = value[CONF_URL]
path = _compute_local_icon_path(value) h = hashlib.new("sha256")
if path.is_file(): h.update(url.encode())
return value key = h.hexdigest()[:8]
url = f"https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/{mdi_id}.svg" base_dir = external_files.compute_local_file_dir(DOMAIN)
_LOGGER.debug("Downloading %s MDI image from %s", mdi_id, url) return base_dir / key
def download_content(url: str, path: Path) -> None:
if not external_files.has_remote_file_changed(url, path):
_LOGGER.debug("Remote file has not changed %s", url)
return
_LOGGER.debug(
"Remote file has changed, downloading from %s to %s",
url,
path,
)
try: try:
req = requests.get(url, timeout=MDI_DOWNLOAD_TIMEOUT) req = requests.get(
url,
timeout=IMAGE_DOWNLOAD_TIMEOUT,
headers={"User-agent": f"ESPHome/{__version__} (https://esphome.io)"},
)
req.raise_for_status() req.raise_for_status()
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
raise cv.Invalid(f"Could not download MDI image {mdi_id} from {url}: {e}") raise cv.Invalid(f"Could not download from {url}: {e}")
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(req.content) path.write_bytes(req.content)
def download_mdi(value):
validate_cairosvg_installed(value)
mdi_id = value[CONF_ICON]
path = _compute_local_icon_path(value)
url = f"https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/{mdi_id}.svg"
download_content(url, path)
return value
def download_image(value):
url = value[CONF_URL]
path = _compute_local_image_path(value)
download_content(url, path)
return value return value
@ -139,6 +188,13 @@ def validate_file_shorthand(value):
CONF_ICON: icon, CONF_ICON: icon,
} }
) )
if value.startswith("http://") or value.startswith("https://"):
return FILE_SCHEMA(
{
CONF_SOURCE: SOURCE_WEB,
CONF_URL: value,
}
)
return FILE_SCHEMA( return FILE_SCHEMA(
{ {
CONF_SOURCE: SOURCE_LOCAL, CONF_SOURCE: SOURCE_LOCAL,
@ -160,10 +216,18 @@ MDI_SCHEMA = cv.All(
download_mdi, download_mdi,
) )
WEB_SCHEMA = cv.All(
{
cv.Required(CONF_URL): cv.string,
},
download_image,
)
TYPED_FILE_SCHEMA = cv.typed_schema( TYPED_FILE_SCHEMA = cv.typed_schema(
{ {
SOURCE_LOCAL: LOCAL_SCHEMA, SOURCE_LOCAL: LOCAL_SCHEMA,
SOURCE_MDI: MDI_SCHEMA, SOURCE_MDI: MDI_SCHEMA,
SOURCE_WEB: WEB_SCHEMA,
}, },
key=CONF_SOURCE, key=CONF_SOURCE,
) )
@ -201,9 +265,7 @@ IMAGE_SCHEMA = cv.Schema(
CONFIG_SCHEMA = cv.All(font.validate_pillow_installed, IMAGE_SCHEMA) CONFIG_SCHEMA = cv.All(font.validate_pillow_installed, IMAGE_SCHEMA)
def load_svg_image(file: str, resize: tuple[int, int]): def load_svg_image(file: bytes, resize: tuple[int, int]):
from PIL import Image
# This import is only needed in case of SVG images; adding it # This import is only needed in case of SVG images; adding it
# to the top would force configurations not using SVG to also have it # to the top would force configurations not using SVG to also have it
# installed for no reason. # installed for no reason.
@ -212,19 +274,17 @@ def load_svg_image(file: str, resize: tuple[int, int]):
if resize: if resize:
req_width, req_height = resize req_width, req_height = resize
svg_image = svg2png( svg_image = svg2png(
url=file, file,
output_width=req_width, output_width=req_width,
output_height=req_height, output_height=req_height,
) )
else: else:
svg_image = svg2png(url=file) svg_image = svg2png(file)
return Image.open(io.BytesIO(svg_image)) return Image.open(io.BytesIO(svg_image))
async def to_code(config): async def to_code(config):
from PIL import Image
conf_file = config[CONF_FILE] conf_file = config[CONF_FILE]
if conf_file[CONF_SOURCE] == SOURCE_LOCAL: if conf_file[CONF_SOURCE] == SOURCE_LOCAL:
@ -233,17 +293,26 @@ async def to_code(config):
elif conf_file[CONF_SOURCE] == SOURCE_MDI: elif conf_file[CONF_SOURCE] == SOURCE_MDI:
path = _compute_local_icon_path(conf_file).as_posix() path = _compute_local_icon_path(conf_file).as_posix()
elif conf_file[CONF_SOURCE] == SOURCE_WEB:
path = _compute_local_image_path(conf_file).as_posix()
try: try:
resize = config.get(CONF_RESIZE) with open(path, "rb") as f:
if path.lower().endswith(".svg"): file_contents = f.read()
image = load_svg_image(path, resize)
else:
image = Image.open(path)
if resize:
image.thumbnail(resize)
except Exception as e: except Exception as e:
raise core.EsphomeError(f"Could not load image file {path}: {e}") raise core.EsphomeError(f"Could not load image file {path}: {e}")
mime = Magic(mime=True)
file_type = mime.from_buffer(file_contents)
resize = config.get(CONF_RESIZE)
if "svg" in file_type:
image = load_svg_image(file_contents, resize)
else:
image = Image.open(io.BytesIO(file_contents))
if resize:
image.thumbnail(resize)
width, height = image.size width, height = image.size
if CONF_RESIZE not in config and (width > 500 or height > 500): if CONF_RESIZE not in config and (width > 500 or height > 500):

75
esphome/external_files.py Normal file
View file

@ -0,0 +1,75 @@
from __future__ import annotations
import logging
from pathlib import Path
import os
from datetime import datetime
import requests
import esphome.config_validation as cv
from esphome.core import CORE, TimePeriodSeconds
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@landonr"]
NETWORK_TIMEOUT = 30
IF_MODIFIED_SINCE = "If-Modified-Since"
CACHE_CONTROL = "Cache-Control"
CACHE_CONTROL_MAX_AGE = "max-age="
CONTENT_DISPOSITION = "content-disposition"
TEMP_DIR = "temp"
def has_remote_file_changed(url, local_file_path):
if os.path.exists(local_file_path):
_LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path)
try:
local_modification_time = os.path.getmtime(local_file_path)
local_modification_time_str = datetime.utcfromtimestamp(
local_modification_time
).strftime("%a, %d %b %Y %H:%M:%S GMT")
headers = {
IF_MODIFIED_SINCE: local_modification_time_str,
CACHE_CONTROL: CACHE_CONTROL_MAX_AGE + "3600",
}
response = requests.head(url, headers=headers, timeout=NETWORK_TIMEOUT)
_LOGGER.debug(
"has_remote_file_changed: File %s, Local modified %s, response code %d",
local_file_path,
local_modification_time_str,
response.status_code,
)
if response.status_code == 304:
_LOGGER.debug(
"has_remote_file_changed: File not modified since %s",
local_modification_time_str,
)
return False
_LOGGER.debug("has_remote_file_changed: File modified")
return True
except requests.exceptions.RequestException as e:
raise cv.Invalid(
f"Could not check if {url} has changed, please check if file exists "
f"({e})"
)
_LOGGER.debug("has_remote_file_changed: File doesn't exists at %s", local_file_path)
return True
def is_file_recent(file_path: str, refresh: TimePeriodSeconds) -> bool:
if os.path.exists(file_path):
creation_time = os.path.getctime(file_path)
current_time = datetime.now().timestamp()
return current_time - creation_time <= refresh.total_seconds
return False
def compute_local_file_dir(domain: str) -> Path:
base_directory = Path(CORE.data_dir) / domain
base_directory.mkdir(parents=True, exist_ok=True)
return base_directory

View file

@ -12,6 +12,7 @@ click==8.1.7
esphome-dashboard==20231107.0 esphome-dashboard==20231107.0
aioesphomeapi==18.5.2 aioesphomeapi==18.5.2
zeroconf==0.123.0 zeroconf==0.123.0
python-magic==0.4.27
# esp-idf requires this, but doesn't bundle it by default # esp-idf requires this, but doesn't bundle it by default
# https://github.com/espressif/esp-idf/blob/220590d599e134d7a5e7f1e683cc4550349ffbf8/requirements.txt#L24 # https://github.com/espressif/esp-idf/blob/220590d599e134d7a5e7f1e683cc4550349ffbf8/requirements.txt#L24

View file

@ -752,6 +752,18 @@ image:
file: pnglogo.png file: pnglogo.png
type: RGB565 type: RGB565
use_transparency: no use_transparency: no
- id: web_svg_image
file: https://raw.githubusercontent.com/esphome/esphome-docs/a62d7ab193c1a464ed791670170c7d518189109b/images/logo.svg
resize: 256x48
type: TRANSPARENT_BINARY
- id: web_tiff_image
file: https://upload.wikimedia.org/wikipedia/commons/b/b6/SIPI_Jelly_Beans_4.1.07.tiff
type: RGB24
resize: 48x48
- id: web_redirect_image
file: https://avatars.githubusercontent.com/u/3060199?s=48&v=4
type: RGB24
resize: 48x48
- id: mdi_alert - id: mdi_alert
file: mdi:alert-circle-outline file: mdi:alert-circle-outline