diff --git a/scheduler.py b/scheduler.py index 4d8ce7a..6f58748 100644 --- a/scheduler.py +++ b/scheduler.py @@ -22,6 +22,7 @@ from PIL import Image logger = logging.getLogger(__name__) TICK_SECONDS = 5 +MANUAL_OVERRIDE_SECONDS = 300 # how long "show this now" sticks before falling back to automatic PREVIEW_PATH = Path(__file__).resolve().parent / "web" / "static" / "preview.png" @@ -44,12 +45,12 @@ class Scheduler: self.display = display self._caches = {screen_id: ScreenCache() for screen_id in screens} - self._current_screen_id: str | None = None self._rotation_index = 0 self._rotation_switched_at = time.time() self._last_image_hash: str | None = None self._manual_screen: str | None = None + self._manual_until: float = 0.0 self._force_once = False self._wake = threading.Event() @@ -79,12 +80,24 @@ class Scheduler: self._wake.set() def request_switch(self, screen_id: str): + """Pin a screen for MANUAL_OVERRIDE_SECONDS. Navidrome priority can + still preempt it if something starts playing -- that's the one thing + this app promises to always show reliably -- but rotation won't.""" self._manual_screen = screen_id + self._manual_until = time.time() + MANUAL_OVERRIDE_SECONDS + self._wake.set() + + def request_resume_rotation(self): + self._manual_screen = None self._wake.set() def get_status(self) -> dict: with self._status_lock: - return dict(self._status) + status = dict(self._status) + manual_active = bool(self._manual_screen) and time.time() < self._manual_until + status["manual_screen"] = self._manual_screen if manual_active else None + status["manual_until"] = time.strftime("%H:%M:%S", time.localtime(self._manual_until)) if manual_active else None + return status # ------------------------------------------------------------- internal @@ -98,18 +111,7 @@ class Scheduler: self._wake.clear() def _tick(self): - did_check = False - - if self._manual_screen: - screen_id = self._manual_screen - self._manual_screen = None - state = self._check(screen_id) - did_check = True - else: - screen_id, state, checked = self._select_screen() - did_check = checked - - self._current_screen_id = screen_id + screen_id, state, did_check = self._select_screen() screen = self.screens[screen_id] settings = self.config.get_screen_settings(screen_id) resolution = self.display.resolution @@ -138,17 +140,22 @@ class Scheduler: self.config.save() def _select_screen(self) -> tuple[str, object, bool]: - """Returns (screen_id, state, did_check_happen).""" + """Returns (screen_id, state, did_check_happen). + + Priority order: Navidrome-when-playing (if enabled) always wins -- + that's the one thing this app promises to show reliably. Then a + manual "show this now" pin, while it's still within its window. Then + normal rotation. + """ if self.config.get_navidrome_priority() and "navidrome" in self.screens: - cache = self._caches["navidrome"] - navidrome = self.screens["navidrome"] - checked = False - if cache.is_stale(navidrome.poll_interval): - cache.state = self._check("navidrome") - cache.checked_at = time.time() - checked = True - if cache.state is not None: - return "navidrome", cache.state, checked + screen_id, state, checked = self._poll_screen("navidrome") + if state is not None: + return screen_id, state, checked + + if self._manual_screen: + if time.time() < self._manual_until: + return self._poll_screen(self._manual_screen) + self._manual_screen = None # window expired return self._rotation_screen() @@ -162,8 +169,7 @@ class Scheduler: now = time.time() # 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.) + # never auto-advance. if rotation.get("enabled", True): interval_seconds = rotation["interval_minutes"] * 60 if now - self._rotation_switched_at >= interval_seconds: @@ -171,6 +177,9 @@ class Scheduler: self._rotation_switched_at = now screen_id = screen_ids[self._rotation_index % len(screen_ids)] + return self._poll_screen(screen_id) + + def _poll_screen(self, screen_id: str) -> tuple[str, object, bool]: cache = self._caches[screen_id] screen = self.screens[screen_id] checked = False diff --git a/screens/weather.py b/screens/weather.py index 114e362..037d7d4 100644 --- a/screens/weather.py +++ b/screens/weather.py @@ -1,6 +1,7 @@ -"""Current weather via Open-Meteo (free, no API key -- just a lat/long).""" +"""Current weather + daily forecast via Open-Meteo (free, no API key).""" import logging +from datetime import date import requests from PIL import Image, ImageDraw @@ -12,13 +13,16 @@ logger = logging.getLogger(__name__) HTTP_TIMEOUT = 10 GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search" +FORECAST_DAY_CHOICES = (3, 5, 7) + +WEEKDAY_ABBR_DE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] # WMO weather codes -> (German description, symbol). Not exhaustive, covers # the common cases; unmapped codes fall back to a generic description. WMO_CODES = { 0: ("Klarer Himmel", "☀"), 1: ("Überwiegend klar", "☀"), - 2: ("Teilweise bewölkt", "⛅"), + 2: ("Teilweise bewölkt", "☁"), # DejaVu Sans has no ⛅ glyph, falls back to a tofu box 3: ("Bedeckt", "☁"), 45: ("Nebel", "☁"), 48: ("Nebel", "☁"), @@ -48,7 +52,7 @@ class WeatherScreen(Screen): poll_interval = 1800 # 30 minutes -- weather doesn't need to be fresher def default_settings(self) -> dict: - return {"city": "", "latitude": None, "longitude": None} + return {"city": "", "latitude": None, "longitude": None, "forecast_days": 3} def geocode(self, city: str): """Resolve a city name to coordinates via Open-Meteo's free geocoding @@ -79,32 +83,64 @@ class WeatherScreen(Screen): logger.warning("Weather screen has no latitude/longitude configured") return None + forecast_days = settings.get("forecast_days", 3) + if forecast_days not in FORECAST_DAY_CHOICES: + forecast_days = 3 + response = requests.get( "https://api.open-meteo.com/v1/forecast", - params={"latitude": lat, "longitude": lon, "current_weather": "true"}, + params={ + "latitude": lat, + "longitude": lon, + "current_weather": "true", + "daily": "weather_code,temperature_2m_max,temperature_2m_min", + "forecast_days": forecast_days, + "timezone": "auto", + }, timeout=HTTP_TIMEOUT, ) response.raise_for_status() - current = response.json().get("current_weather") or {} + payload = response.json() + + current = payload.get("current_weather") or {} if "temperature" not in current: return None code = int(current.get("weathercode", -1)) description, symbol = WMO_CODES.get(code, ("Unbekannt", "?")) + + days = [] + daily = payload.get("daily") or {} + for i, date_str in enumerate(daily.get("time", [])): + try: + weekday = date.fromisoformat(date_str).weekday() + except ValueError: + continue + day_code = int(daily["weather_code"][i]) + _, day_symbol = WMO_CODES.get(day_code, ("Unbekannt", "?")) + days.append({ + "label": WEEKDAY_ABBR_DE[weekday], + "symbol": day_symbol, + "high": daily["temperature_2m_max"][i], + "low": daily["temperature_2m_min"][i], + }) + return { "temperature": current["temperature"], "windspeed": current.get("windspeed"), "description": description, "symbol": symbol, + "days": days, } def render(self, state, settings: dict, resolution: tuple[int, int]) -> Image.Image: width, height = resolution image = Image.new("RGB", (width, height), "white") draw = ImageDraw.Draw(image) + scale = min(width, height) if not state: - font = load_font(size=int(min(width, height) * 0.06)) + font = load_font(size=int(scale * 0.06)) msg = "Kein Standort konfiguriert" if not ( settings.get("latitude") and settings.get("longitude") ) else "Wetterdaten nicht verfügbar" @@ -112,20 +148,51 @@ class WeatherScreen(Screen): draw.text(((width - mw) / 2, height / 2), msg, font=font, fill=(120, 120, 120)) return image - symbol_font = load_font(size=int(min(width, height) * 0.28)) - temp_font = load_font(size=int(min(width, height) * 0.22), bold=True) - desc_font = load_font(size=int(min(width, height) * 0.055)) + days = state.get("days") or [] + has_forecast = bool(days) + current_h = height * (0.56 if has_forecast else 1.0) + + symbol_font = load_font(size=int(scale * (0.20 if has_forecast else 0.28))) + temp_font = load_font(size=int(scale * (0.15 if has_forecast else 0.22)), bold=True) + desc_font = load_font(size=int(scale * 0.05)) symbol = state["symbol"] sw = text_width(draw, symbol, symbol_font) - draw.text(((width - sw) / 2, height * 0.14), symbol, font=symbol_font, fill="black") + draw.text(((width - sw) / 2, current_h * 0.08), symbol, font=symbol_font, fill="black") temp_str = f"{round(state['temperature'])}°" tw = text_width(draw, temp_str, temp_font) - draw.text(((width - tw) / 2, height * 0.46), temp_str, font=temp_font, fill="black") + draw.text(((width - tw) / 2, current_h * 0.42), temp_str, font=temp_font, fill="black") desc = state["description"] dw = text_width(draw, desc, desc_font) - draw.text(((width - dw) / 2, height * 0.68), desc, font=desc_font, fill=(90, 90, 90)) + draw.text(((width - dw) / 2, current_h * 0.78), desc, font=desc_font, fill=(90, 90, 90)) + + if has_forecast: + forecast_y = current_h + forecast_h = height - forecast_y + draw.line((width * 0.06, forecast_y, width * 0.94, forecast_y), fill=(224, 224, 224), width=2) + + col_w = width / len(days) + label_font = load_font(size=int(scale * 0.042), bold=True) + day_symbol_font = load_font(size=int(scale * 0.085)) + day_temp_font = load_font(size=int(scale * 0.038)) + + for i, day in enumerate(days): + cx = col_w * i + col_w / 2 + y = forecast_y + forecast_h * 0.12 + + label = day["label"] + lw = text_width(draw, label, label_font) + draw.text((cx - lw / 2, y), label, font=label_font, fill=(90, 90, 90)) + y += label_font.size * 1.5 + + dsw = text_width(draw, day["symbol"], day_symbol_font) + draw.text((cx - dsw / 2, y), day["symbol"], font=day_symbol_font, fill="black") + y += day_symbol_font.size * 1.2 + + temps = f"{round(day['high'])}°/{round(day['low'])}°" + dtw = text_width(draw, temps, day_temp_font) + draw.text((cx - dtw / 2, y), temps, font=day_temp_font, fill=(110, 110, 110)) return image diff --git a/web/app.py b/web/app.py index e781432..106af70 100644 --- a/web/app.py +++ b/web/app.py @@ -36,6 +36,11 @@ def create_app(config, screens: dict, scheduler) -> Flask: scheduler.request_switch(screen_id) return redirect(url_for("index")) + @app.route("/actions/resume", methods=["POST"]) + def resume_rotation(): + scheduler.request_resume_rotation() + return redirect(url_for("index")) + @app.route("/settings/navidrome", methods=["GET", "POST"]) def settings_navidrome(): if request.method == "POST": @@ -60,6 +65,9 @@ def create_app(config, screens: dict, scheduler) -> Flask: city = request.form.get("city", "").strip() lat_raw = request.form.get("latitude", "").strip() lon_raw = request.form.get("longitude", "").strip() + forecast_days = int(request.form.get("forecast_days", 3) or 3) + if forecast_days not in (3, 5, 7): + forecast_days = 3 if city: try: @@ -73,6 +81,7 @@ def create_app(config, screens: dict, scheduler) -> Flask: "city": result["label"], "latitude": result["latitude"], "longitude": result["longitude"], + "forecast_days": forecast_days, }) return redirect(url_for("settings_weather", saved=1)) else: @@ -80,6 +89,7 @@ def create_app(config, screens: dict, scheduler) -> Flask: "city": "", "latitude": float(lat_raw) if lat_raw else None, "longitude": float(lon_raw) if lon_raw else None, + "forecast_days": forecast_days, }) return redirect(url_for("settings_weather", saved=1)) diff --git a/web/templates/base.html b/web/templates/base.html index 3c6a33c..d43baac 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -108,7 +108,7 @@ label { display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; } .hint { font-size: 0.78rem; color: var(--text-muted); margin: 0.35rem 0 0; } - input[type=text], input[type=password], input[type=number] { + input[type=text], input[type=password], input[type=number], select, input[type=file] { width: 100%; padding: 0.6rem 0.7rem; font-size: 0.95rem; diff --git a/web/templates/index.html b/web/templates/index.html index a6f567d..b3b30bb 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -22,6 +22,10 @@ {{ "on" if rotation.enabled else "off" }} {{ rotation.screens | join(" → ") }} (every {{ rotation.interval_minutes }} min) + {% if status.manual_screen %} +
Leave the city field above empty to use these instead.