From 61348be674d11c4a13a794496831246d73f50988 Mon Sep 17 00:00:00 2001 From: Honey Date: Tue, 21 Jul 2026 22:51:16 +0200 Subject: [PATCH] Landscape orientation, redesigned web UI, rotation on/off + minutes, weather by city name - Panel is mounted sideways: render landscape (600x400), rotate to the panel's native portrait orientation right before writing to hardware. - Web UI: card-based layout, dark mode, active nav highlighting, save confirmation toasts, error banners. - Rotation: separate enabled/disabled toggle (off pins to the first screen in the list) and the interval is now in minutes instead of raw seconds. Migrates existing config.json from the old interval_seconds field. - Weather: type a city name instead of hunting down coordinates, resolved via Open-Meteo's free geocoding API. Manual lat/long still available as a fallback. --- README.md | 4 +- config.py | 12 +- display/epd4in0e_driver.py | 15 +- scheduler.py | 11 +- screens/weather.py | 25 ++- web/app.py | 46 ++++-- web/templates/base.html | 221 +++++++++++++++++++++++--- web/templates/index.html | 50 ++++-- web/templates/settings_navidrome.html | 42 ++--- web/templates/settings_rotation.html | 36 +++-- web/templates/settings_weather.html | 35 +++- 11 files changed, 393 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 11117b0..7ecc7b2 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ can't drive the same panel at once -- but nothing is copied from or reused out of it. Safe to re-run. Then open `http://:8080`, fill in Navidrome credentials under -**Navidrome** and coordinates under **Weather**. Everything saves -immediately and takes effect within a few seconds -- no restart needed. +**Navidrome** and a city under **Weather**. Everything saves immediately and +takes effect within a few seconds -- no restart needed. ## Updating diff --git a/config.py b/config.py index 85a63c7..4e24c45 100644 --- a/config.py +++ b/config.py @@ -17,7 +17,8 @@ DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent / "config.json" DEFAULT_ROTATION = { "screens": ["clock", "weather"], - "interval_seconds": 300, + "interval_minutes": 5, + "enabled": True, } @@ -35,6 +36,11 @@ class Config: data = {} data.setdefault("rotation", dict(DEFAULT_ROTATION)) + # Migrate configs written before rotation had minutes/enabled. + if "interval_seconds" in data["rotation"] and "interval_minutes" not in data["rotation"]: + data["rotation"]["interval_minutes"] = max(1, round(data["rotation"].pop("interval_seconds") / 60)) + data["rotation"].setdefault("interval_minutes", DEFAULT_ROTATION["interval_minutes"]) + data["rotation"].setdefault("enabled", DEFAULT_ROTATION["enabled"]) data.setdefault("navidrome_priority", True) data.setdefault("settings", {}) for screen_id, screen in screens.items(): @@ -72,9 +78,9 @@ class Config: with self._lock: return dict(self._data["rotation"]) - def set_rotation(self, screens: list[str], interval_seconds: int) -> None: + def set_rotation(self, screens: list[str], interval_minutes: int, enabled: bool) -> None: with self._lock: - self._data["rotation"] = {"screens": screens, "interval_seconds": interval_seconds} + self._data["rotation"] = {"screens": screens, "interval_minutes": interval_minutes, "enabled": enabled} self.save() def get_navidrome_priority(self) -> bool: diff --git a/display/epd4in0e_driver.py b/display/epd4in0e_driver.py index 652f1cc..461704d 100644 --- a/display/epd4in0e_driver.py +++ b/display/epd4in0e_driver.py @@ -13,7 +13,11 @@ from display import Display logger = logging.getLogger(__name__) -RESOLUTION = (400, 600) +# The panel is physically native portrait (400x600), but mounted sideways -- +# screens render in this landscape resolution, and show() rotates the frame +# to match the panel's native orientation right before sending it over. +RESOLUTION = (600, 400) +PANEL_NATIVE_RESOLUTION = (400, 600) class EPD4in0eDisplay(Display): @@ -46,7 +50,14 @@ class EPD4in0eDisplay(Display): # full flash, there's no way around it on this panel. self._epd.Clear() - self._epd.display(self._epd.getbuffer(image.convert("RGB"))) + # Rotate landscape -> the panel's native portrait orientation. If this + # comes out upside-down for how it's physically mounted, flip the + # sign here (rotate(-90) instead of rotate(90)). + rotated = image.convert("RGB").rotate(90, expand=True) + if rotated.size != PANEL_NATIVE_RESOLUTION: + raise ValueError(f"Rotated image {rotated.size} doesn't match panel {PANEL_NATIVE_RESOLUTION}") + + self._epd.display(self._epd.getbuffer(rotated)) def sleep(self) -> None: if self._epd is not None: diff --git a/scheduler.py b/scheduler.py index f5fad34..4d8ce7a 100644 --- a/scheduler.py +++ b/scheduler.py @@ -161,9 +161,14 @@ class Scheduler: screen_ids = list(self.screens.keys()) now = time.time() - if now - self._rotation_switched_at >= rotation["interval_seconds"]: - self._rotation_index = (self._rotation_index + 1) % len(screen_ids) - self._rotation_switched_at = now + # With rotation disabled, just pin to whatever's currently selected -- + # never auto-advance. (Manual "show this screen now" still works via + # request_switch(); it just won't stick past the next tick.) + if rotation.get("enabled", True): + interval_seconds = rotation["interval_minutes"] * 60 + if now - self._rotation_switched_at >= interval_seconds: + self._rotation_index = (self._rotation_index + 1) % len(screen_ids) + self._rotation_switched_at = now screen_id = screen_ids[self._rotation_index % len(screen_ids)] cache = self._caches[screen_id] diff --git a/screens/weather.py b/screens/weather.py index f1d7fbf..114e362 100644 --- a/screens/weather.py +++ b/screens/weather.py @@ -11,6 +11,7 @@ from screens.render_utils import load_font, text_width logger = logging.getLogger(__name__) HTTP_TIMEOUT = 10 +GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search" # WMO weather codes -> (German description, symbol). Not exhaustive, covers # the common cases; unmapped codes fall back to a generic description. @@ -47,7 +48,29 @@ class WeatherScreen(Screen): poll_interval = 1800 # 30 minutes -- weather doesn't need to be fresher def default_settings(self) -> dict: - return {"latitude": None, "longitude": None} + return {"city": "", "latitude": None, "longitude": None} + + def geocode(self, city: str): + """Resolve a city name to coordinates via Open-Meteo's free geocoding + API (no key needed). Returns {"latitude", "longitude", "label"} or + None if nothing matched.""" + response = requests.get( + GEOCODE_URL, + params={"name": city, "count": 1, "language": "de"}, + timeout=HTTP_TIMEOUT, + ) + response.raise_for_status() + results = response.json().get("results") or [] + if not results: + return None + + top = results[0] + label_parts = [top.get("name"), top.get("admin1"), top.get("country")] + return { + "latitude": top["latitude"], + "longitude": top["longitude"], + "label": ", ".join(p for p in label_parts if p), + } def check(self, settings: dict): lat = settings.get("latitude") diff --git a/web/app.py b/web/app.py index 8b5a2dd..2945c9b 100644 --- a/web/app.py +++ b/web/app.py @@ -1,5 +1,6 @@ """Flask app: status/preview page + one settings form per screen + rotation settings.""" +import requests from flask import Flask, redirect, render_template, request, url_for @@ -42,30 +43,51 @@ def create_app(config, screens: dict, scheduler) -> Flask: "boost_cover": "boost_cover" in request.form, "grayscale_cover": "grayscale_cover" in request.form, }) - return redirect(url_for("settings_navidrome")) + return redirect(url_for("settings_navidrome", saved=1)) return render_template("settings_navidrome.html", settings=config.get_screen_settings("navidrome")) @app.route("/settings/weather", methods=["GET", "POST"]) def settings_weather(): + error = None if request.method == "POST": - lat = request.form.get("latitude", "").strip() - lon = request.form.get("longitude", "").strip() - config.update_screen_settings("weather", { - "latitude": float(lat) if lat else None, - "longitude": float(lon) if lon else None, - }) - return redirect(url_for("settings_weather")) - return render_template("settings_weather.html", settings=config.get_screen_settings("weather")) + city = request.form.get("city", "").strip() + lat_raw = request.form.get("latitude", "").strip() + lon_raw = request.form.get("longitude", "").strip() + + if city: + try: + result = screens["weather"].geocode(city) + except requests.exceptions.RequestException: + result = None + if result is None: + error = f'Could not find "{city}" -- try a different spelling, or fill in coordinates manually below instead.' + else: + config.update_screen_settings("weather", { + "city": result["label"], + "latitude": result["latitude"], + "longitude": result["longitude"], + }) + return redirect(url_for("settings_weather", saved=1)) + else: + config.update_screen_settings("weather", { + "city": "", + "latitude": float(lat_raw) if lat_raw else None, + "longitude": float(lon_raw) if lon_raw else None, + }) + return redirect(url_for("settings_weather", saved=1)) + + return render_template("settings_weather.html", settings=config.get_screen_settings("weather"), error=error) @app.route("/settings/rotation", methods=["GET", "POST"]) def settings_rotation(): if request.method == "POST": raw = request.form.get("screens", "") selected = [s.strip() for s in raw.split(",") if s.strip() in screens] - interval = int(request.form.get("interval_seconds", 300) or 300) - config.set_rotation(selected, interval) + interval_minutes = int(request.form.get("interval_minutes", 5) or 5) + enabled = "rotation_enabled" in request.form + config.set_rotation(selected, interval_minutes, enabled) config.set_navidrome_priority("navidrome_priority" in request.form) - return redirect(url_for("settings_rotation")) + return redirect(url_for("settings_rotation", saved=1)) return render_template( "settings_rotation.html", screens=screens, diff --git a/web/templates/base.html b/web/templates/base.html index 77906cf..d35bd83 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -2,37 +2,208 @@ + Paperwall -
Paperwall
- - {% block content %}{% endblock %} +
+
Paperwall
+ + {% if request.args.get('saved') %} +
Saved.
+ {% endif %} + {% block content %}{% endblock %} +
diff --git a/web/templates/index.html b/web/templates/index.html index a16682e..a6f567d 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -2,24 +2,40 @@ {% block content %}

Status

-preview - -
-
Current screen
{{ status.current_screen or "-" }}
-
Last render
{{ status.last_render_at or "-" }}
-
Last panel write
{{ status.last_display_write_at or "-" }}
-
Navidrome priority
{{ "on" if navidrome_priority else "off" }}
-
Rotation
{{ rotation.screens | join(" → ") }} (every {{ rotation.interval_seconds }}s)
+
+ +
+ preview +
-
-
- -
- {% for screen_id, screen in screens.items() %} -
- -
- {% endfor %} +
+ +
+
Current screen
{{ status.current_screen or "-" }}
+
Last render
{{ status.last_render_at or "-" }}
+
Last panel write
{{ status.last_display_write_at or "-" }}
+
Navidrome priority
+
{{ "on" if navidrome_priority else "off" }}
+
Rotation
+
+ {{ "on" if rotation.enabled else "off" }} + {{ rotation.screens | join(" → ") }} (every {{ rotation.interval_minutes }} min) +
+
+
+ +
+ +
+
+ +
+ {% for screen_id, screen in screens.items() %} +
+ +
+ {% endfor %} +
{% endblock %} diff --git a/web/templates/settings_navidrome.html b/web/templates/settings_navidrome.html index c676400..4eea6d1 100644 --- a/web/templates/settings_navidrome.html +++ b/web/templates/settings_navidrome.html @@ -2,32 +2,36 @@ {% block content %}

Navidrome

-
- - -
-
- - -
-
- - -
-
- - +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +

Leave empty to show whoever is playing.

+
-
- +
+
-
- +
+
diff --git a/web/templates/settings_rotation.html b/web/templates/settings_rotation.html index 301c7f8..da7a162 100644 --- a/web/templates/settings_rotation.html +++ b/web/templates/settings_rotation.html @@ -3,25 +3,35 @@

Rotation

-
- +
+
-
- - -

- Comma-separated, in order. Available: {{ screens.keys() | join(', ') }}. +

+ + +

+ Off just pins to the first screen in the order below (still shown when + Navidrome priority is on but nothing's playing). "Show X now" buttons on + the Status page still work either way, they just don't stick past the + next check.

-
- -
- - +
+ + +

Comma-separated, in order. Available: {{ screens.keys() | join(', ') }}.

+
+
+ + +
diff --git a/web/templates/settings_weather.html b/web/templates/settings_weather.html index 7bda66b..a00663e 100644 --- a/web/templates/settings_weather.html +++ b/web/templates/settings_weather.html @@ -1,16 +1,37 @@ {% extends "base.html" %} {% block content %}

Weather

-

Uses Open-Meteo (no API key needed) — just the coordinates for your location.

+ +{% if error %} +
{{ error }}
+{% endif %} + -
- - +
+ +

Uses Open-Meteo — no API key needed.

+
+ + + {% if settings.city %} +

Currently: {{ settings.city }} ({{ settings.latitude }}, {{ settings.longitude }})

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

Leave the city field above empty to use these instead.

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