From bfe4616f20555f2a0a60302b6061f1f15fb0b989 Mon Sep 17 00:00:00 2001 From: Honey Date: Tue, 21 Jul 2026 22:56:32 +0200 Subject: [PATCH] Add Picture screen: upload a file or point at a URL New screen alongside clock/weather/navidrome. Upload goes through the web UI (validated with PIL before accepting, stored under web/static/uploads/, gitignored), or point it at a URL re-fetched every 30 minutes. Cover/contain fit modes (new fit_cover/fit_contain helpers in render_utils.py, generalized from the square-only cover-art cropping already used by the Navidrome screen), plus the same contrast-boost/grayscale options that screen has. --- .gitignore | 1 + README.md | 7 ++- screens/__init__.py | 2 + screens/picture.py | 88 +++++++++++++++++++++++++++++ screens/render_utils.py | 25 +++++++- web/app.py | 55 ++++++++++++++++++ web/templates/base.html | 1 + web/templates/settings_picture.html | 57 +++++++++++++++++++ 8 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 screens/picture.py create mode 100644 web/templates/settings_picture.html diff --git a/.gitignore b/.gitignore index ac0fe11..c19fff0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ venv/ .venv/ mock_output/ web/static/*.png +web/static/uploads/ config.json config.json.tmp diff --git a/README.md b/README.md index 7ecc7b2..9995b75 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # Paperwall A standalone dashboard for one Waveshare `epd4in0e` (4", 6-color) e-ink panel: -Navidrome now-playing, clock, and weather, switchable/controllable from a -local web UI. Started as an InkyPi replacement, but doesn't depend on InkyPi -at all -- own venv, own vendored copy of the Waveshare driver files. +Navidrome now-playing, clock, weather, and pictures (upload or URL), +switchable/controllable from a local web UI. Started as an InkyPi +replacement, but doesn't depend on InkyPi at all -- own venv, own vendored +copy of the Waveshare driver files. Confirmed by reading the vendor driver directly: this panel has no partial-refresh mode at all -- every update is a full flash. So the design diff --git a/screens/__init__.py b/screens/__init__.py index 9f44b1d..248045c 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -1,10 +1,12 @@ from screens.clock import ClockScreen from screens.navidrome import NavidromeScreen +from screens.picture import PictureScreen from screens.weather import WeatherScreen SCREEN_CLASSES = { ClockScreen.id: ClockScreen, WeatherScreen.id: WeatherScreen, + PictureScreen.id: PictureScreen, NavidromeScreen.id: NavidromeScreen, } diff --git a/screens/picture.py b/screens/picture.py new file mode 100644 index 0000000..153ee4a --- /dev/null +++ b/screens/picture.py @@ -0,0 +1,88 @@ +"""Shows a picture -- either uploaded through the web UI or fetched from a +URL on a timer. Same contrast-boost/grayscale treatment as the Navidrome +cover art, since the same e-ink legibility concerns apply. +""" + +import logging +from io import BytesIO +from pathlib import Path + +import requests +from PIL import Image, ImageDraw, ImageEnhance + +from screens.base import Screen +from screens.render_utils import fit_contain, fit_cover, load_font, text_width + +logger = logging.getLogger(__name__) + +HTTP_TIMEOUT = 15 +UPLOAD_DIR = Path(__file__).resolve().parent.parent / "web" / "static" / "uploads" + + +class PictureScreen(Screen): + id = "picture" + display_name = "Picture" + poll_interval = 1800 # 30 minutes -- only matters for url mode + + def default_settings(self) -> dict: + return { + "mode": "url", # "url" or "upload" + "image_url": "", + "uploaded_filename": "", + "fit": "cover", # "cover" or "contain" + "boost_contrast": True, + "grayscale": False, + } + + def check(self, settings: dict): + mode = settings.get("mode", "url") + if mode == "upload": + filename = settings.get("uploaded_filename") + if not filename: + return None + path = UPLOAD_DIR / filename + if not path.exists(): + logger.warning("Uploaded picture %s is missing", path) + return None + try: + return Image.open(path).convert("RGB") + except OSError as exc: + logger.warning("Could not open uploaded picture %s: %s", path, exc) + return None + + url = (settings.get("image_url") or "").strip() + if not url: + return None + try: + response = requests.get(url, timeout=HTTP_TIMEOUT) + response.raise_for_status() + return Image.open(BytesIO(response.content)).convert("RGB") + except (requests.exceptions.RequestException, OSError) as exc: + logger.warning("Could not load picture from %s: %s", url, exc) + return None + + def render(self, state, settings: dict, resolution: tuple[int, int]) -> Image.Image: + width, height = resolution + + if state is None: + image = Image.new("RGB", (width, height), "white") + draw = ImageDraw.Draw(image) + font = load_font(size=int(min(width, height) * 0.06)) + msg = "Kein Bild konfiguriert" + mw = text_width(draw, msg, font) + draw.text(((width - mw) / 2, height / 2), msg, font=font, fill=(140, 140, 140)) + return image + + if settings.get("fit", "cover") == "contain": + image = fit_contain(state, width, height) + else: + image = fit_cover(state, width, height) + + if settings.get("boost_contrast", True): + image = ImageEnhance.Contrast(image).enhance(1.35) + image = ImageEnhance.Brightness(image).enhance(1.05) + + if settings.get("grayscale", False): + image = image.convert("L").convert("RGB") + + return image diff --git a/screens/render_utils.py b/screens/render_utils.py index 04cbb65..fd6ee0d 100644 --- a/screens/render_utils.py +++ b/screens/render_utils.py @@ -8,7 +8,7 @@ text and full-color cover art without worrying about the palette themselves. from pathlib import Path -from PIL import ImageFont +from PIL import Image, ImageFont FONTS_DIR = Path(__file__).resolve().parent.parent / "fonts" @@ -77,3 +77,26 @@ def wrap_to_lines(draw, text: str, font: ImageFont.FreeTypeFont, max_width: int, lines.append(current) return lines[:max_lines] + + +def fit_cover(image: Image.Image, width: int, height: int) -> Image.Image: + """Resize+crop to fill exactly width x height, mirroring CSS's + object-fit: cover -- no letterboxing, but edges may be cropped off.""" + src_w, src_h = image.size + scale = max(width / src_w, height / src_h) + scaled = image.resize((round(src_w * scale), round(src_h * scale)), Image.LANCZOS) + left = (scaled.width - width) // 2 + top = (scaled.height - height) // 2 + return scaled.crop((left, top, left + width, top + height)) + + +def fit_contain(image: Image.Image, width: int, height: int, background=(255, 255, 255)) -> Image.Image: + """Resize to fit entirely within width x height, centered on a solid + background -- mirrors CSS's object-fit: contain, full image always + visible, letterboxed instead of cropped.""" + src_w, src_h = image.size + scale = min(width / src_w, height / src_h) + scaled = image.resize((round(src_w * scale), round(src_h * scale)), Image.LANCZOS) + canvas = Image.new("RGB", (width, height), background) + canvas.paste(scaled, ((width - scaled.width) // 2, (height - scaled.height) // 2)) + return canvas diff --git a/web/app.py b/web/app.py index 2945c9b..e781432 100644 --- a/web/app.py +++ b/web/app.py @@ -2,10 +2,17 @@ import requests from flask import Flask, redirect, render_template, request, url_for +from PIL import Image +from werkzeug.utils import secure_filename + +from screens.picture import UPLOAD_DIR + +ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "bmp", "webp"} def create_app(config, screens: dict, scheduler) -> Flask: app = Flask(__name__) + app.config["MAX_CONTENT_LENGTH"] = 20 * 1024 * 1024 # 20 MB, generous for a photo @app.route("/") def index(): @@ -78,6 +85,54 @@ def create_app(config, screens: dict, scheduler) -> Flask: return render_template("settings_weather.html", settings=config.get_screen_settings("weather"), error=error) + @app.route("/settings/picture", methods=["GET", "POST"]) + def settings_picture(): + error = None + if request.method == "POST": + url = request.form.get("image_url", "").strip() + common_updates = { + "fit": request.form.get("fit", "cover"), + "boost_contrast": "boost_contrast" in request.form, + "grayscale": "grayscale" in request.form, + } + uploaded = request.files.get("image") + + if uploaded and uploaded.filename: + filename = secure_filename(uploaded.filename) + ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + if ext not in ALLOWED_IMAGE_EXTENSIONS: + error = "Unsupported file type -- use PNG, JPG, GIF, BMP, or WebP." + else: + UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + # Only one picture is ever active -- clear any previous + # upload first so a different extension doesn't leave an + # orphaned file behind. + for old in UPLOAD_DIR.glob("current.*"): + old.unlink() + dest = UPLOAD_DIR / f"current.{ext}" + uploaded.save(dest) + try: + Image.open(dest).verify() + except Exception: + dest.unlink(missing_ok=True) + error = "That file doesn't look like a valid image." + else: + config.update_screen_settings("picture", { + "mode": "upload", + "uploaded_filename": dest.name, + **common_updates, + }) + return redirect(url_for("settings_picture", saved=1)) + elif url: + config.update_screen_settings("picture", {"mode": "url", "image_url": url, **common_updates}) + return redirect(url_for("settings_picture", saved=1)) + else: + # Neither a new upload nor a URL change -- just update fit/boost/grayscale. + config.update_screen_settings("picture", common_updates) + return redirect(url_for("settings_picture", saved=1)) + + return render_template("settings_picture.html", settings=config.get_screen_settings("picture"), error=error) + @app.route("/settings/rotation", methods=["GET", "POST"]) def settings_rotation(): if request.method == "POST": diff --git a/web/templates/base.html b/web/templates/base.html index d35bd83..3c6a33c 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -198,6 +198,7 @@ Status Navidrome Weather + Picture Rotation {% if request.args.get('saved') %} diff --git a/web/templates/settings_picture.html b/web/templates/settings_picture.html new file mode 100644 index 0000000..ee1ec7a --- /dev/null +++ b/web/templates/settings_picture.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block content %} +

Picture

+ +{% if error %} +
{{ error }}
+{% endif %} + +{% if settings.mode == "upload" and settings.uploaded_filename %} +
+ +
+ current picture +
+
+{% elif settings.mode == "url" and settings.image_url %} +
+ +

Fetched from a URL: {{ settings.image_url }}

+
+{% endif %} + +
+
+ +
+ + +

PNG, JPG, GIF, BMP, or WebP. Replaces whatever's currently set.

+
+
+ +
+ +
+ + +

Re-fetched every 30 minutes. Leave both this and the upload above alone to just change the options below.

+
+
+ +
+ +
+ + +
+ + +
+ + +
+{% endblock %}