Initial commit: Paperwall, a standalone e-ink dashboard
Navidrome now-playing, clock, and weather for a Waveshare epd4in0e panel, with a local web UI and install/uninstall scripts for the Pi.
This commit is contained in:
commit
66464291e1
33 files changed
+2215
No files matched your search
@@ -0,0 +1,13 @@
|
||||
from screens.clock import ClockScreen
|
||||
from screens.navidrome import NavidromeScreen
|
||||
from screens.weather import WeatherScreen
|
||||
|
||||
SCREEN_CLASSES = {
|
||||
ClockScreen.id: ClockScreen,
|
||||
WeatherScreen.id: WeatherScreen,
|
||||
NavidromeScreen.id: NavidromeScreen,
|
||||
}
|
||||
|
||||
|
||||
def create_screens() -> dict:
|
||||
return {screen_id: cls() for screen_id, cls in SCREEN_CLASSES.items()}
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Base interface every screen implements."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class Screen(ABC):
|
||||
id: str
|
||||
display_name: str
|
||||
poll_interval: int = 60 # seconds between check() calls
|
||||
|
||||
@abstractmethod
|
||||
def check(self, settings: dict) -> Any:
|
||||
"""Cheap call (ideally a single small request) that returns whatever
|
||||
state this screen needs to render. Returning None means "nothing to
|
||||
show" (only meaningful for preemptive screens like Navidrome)."""
|
||||
|
||||
@abstractmethod
|
||||
def render(self, state: Any, settings: dict, resolution: tuple[int, int]) -> Image.Image:
|
||||
"""Full draw. Only called when this screen is actually about to be
|
||||
shown, so it's fine for this to be more expensive than check()."""
|
||||
|
||||
def default_settings(self) -> dict:
|
||||
"""Settings this screen needs, with defaults, merged under config.json's
|
||||
settings[self.id] on first run."""
|
||||
return {}
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Simple full-screen clock."""
|
||||
|
||||
import time
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from screens.base import Screen
|
||||
from screens.render_utils import load_font, text_width
|
||||
|
||||
LINE_GAP = 0.04 # fraction of min(width, height) between the time and date lines
|
||||
|
||||
WEEKDAYS_DE = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
||||
MONTHS_DE = [
|
||||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||
]
|
||||
|
||||
|
||||
class ClockScreen(Screen):
|
||||
id = "clock"
|
||||
display_name = "Clock"
|
||||
poll_interval = 30
|
||||
|
||||
def check(self, settings: dict):
|
||||
return time.strftime("%H:%M")
|
||||
|
||||
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)
|
||||
|
||||
now = time.localtime()
|
||||
time_str = state or time.strftime("%H:%M", now)
|
||||
date_str = f"{WEEKDAYS_DE[now.tm_wday]}, {now.tm_mday}. {MONTHS_DE[now.tm_mon - 1]}"
|
||||
|
||||
scale = min(width, height)
|
||||
time_font = load_font(size=int(scale * 0.32), bold=True)
|
||||
date_font = load_font(size=int(scale * 0.06))
|
||||
gap = int(scale * LINE_GAP)
|
||||
|
||||
time_left, time_top, time_right, time_bottom = draw.textbbox((0, 0), time_str, font=time_font)
|
||||
time_h = time_bottom - time_top
|
||||
date_left, date_top, date_right, date_bottom = draw.textbbox((0, 0), date_str, font=date_font)
|
||||
date_h = date_bottom - date_top
|
||||
|
||||
block_h = time_h + gap + date_h
|
||||
block_top = (height - block_h) / 2
|
||||
|
||||
tw = time_right - time_left
|
||||
draw.text(((width - tw) / 2, block_top - time_top), time_str, font=time_font, fill="black")
|
||||
|
||||
dw = date_right - date_left
|
||||
draw.text(((width - dw) / 2, block_top + time_h + gap - date_top), date_str, font=date_font, fill=(90, 90, 90))
|
||||
|
||||
return image
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Navidrome "now playing" screen -- ported from the InkyPi plugin.
|
||||
|
||||
Same Subsonic API logic as before (salted-MD5 auth, filter on state=="playing"
|
||||
rather than the unreliable minutesAgo, live positionMs for elapsed time), now
|
||||
rendering directly with Pillow instead of HTML/CSS + headless Chromium.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
from PIL import Image, ImageDraw, ImageEnhance
|
||||
|
||||
from screens.base import Screen
|
||||
from screens.render_utils import load_font, text_width, truncate_to_width, wrap_to_lines
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUBSONIC_API_VERSION = "1.16.1"
|
||||
CLIENT_NAME = "Paperwall"
|
||||
HTTP_TIMEOUT = 10
|
||||
|
||||
REMEMBERED_FIELDS = (
|
||||
"id", "title", "artist", "album", "year", "genre",
|
||||
"coverArt", "duration", "starred", "track", "username", "playerName",
|
||||
)
|
||||
|
||||
|
||||
class NavidromeScreen(Screen):
|
||||
id = "navidrome"
|
||||
display_name = "Navidrome Now Playing"
|
||||
poll_interval = 20
|
||||
|
||||
def default_settings(self) -> dict:
|
||||
return {
|
||||
"url": "",
|
||||
"username": "",
|
||||
"password": "",
|
||||
"filter_user": "",
|
||||
"show_progress": False,
|
||||
"show_genre": True,
|
||||
"show_source": True,
|
||||
"boost_cover": True,
|
||||
"grayscale_cover": False,
|
||||
"_last_track": None,
|
||||
"_last_track_at": None,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- check
|
||||
|
||||
def check(self, settings: dict):
|
||||
url = (settings.get("url") or "").strip().rstrip("/")
|
||||
username = (settings.get("username") or "").strip()
|
||||
password = settings.get("password") or ""
|
||||
if not url or not username or not password:
|
||||
return None
|
||||
|
||||
only_user = (settings.get("filter_user") or "").strip()
|
||||
try:
|
||||
entry = self._fetch_now_playing(url, username, password, only_user)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
logger.error("Navidrome request failed: %s", exc)
|
||||
return None
|
||||
|
||||
if entry is not None:
|
||||
settings["_last_track"] = {k: entry.get(k) for k in REMEMBERED_FIELDS}
|
||||
settings["_last_track_at"] = time.time()
|
||||
|
||||
return entry
|
||||
|
||||
def _auth_params(self, username: str, password: str) -> dict:
|
||||
salt = "%08x" % random.getrandbits(32)
|
||||
token = hashlib.md5((password + salt).encode("utf-8")).hexdigest()
|
||||
return {"u": username, "t": token, "s": salt, "v": SUBSONIC_API_VERSION, "c": CLIENT_NAME, "f": "json"}
|
||||
|
||||
def _call(self, url: str, username: str, password: str, method: str, params: dict | None = None) -> dict:
|
||||
query = self._auth_params(username, password)
|
||||
query.update(params or {})
|
||||
response = requests.get(f"{url}/rest/{method}", params=query, timeout=HTTP_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
payload = response.json().get("subsonic-response", {})
|
||||
if payload.get("status") != "ok":
|
||||
message = payload.get("error", {}).get("message", "unknown error")
|
||||
raise RuntimeError(f"Navidrome API error on {method}: {message}")
|
||||
return payload
|
||||
|
||||
def _fetch_now_playing(self, url: str, username: str, password: str, only_user: str):
|
||||
"""Return the currently-playing entry, or None.
|
||||
|
||||
Navidrome's `minutesAgo` counts from when the session *started*, not
|
||||
since the last heartbeat, so a track over 5 minutes long would look
|
||||
"stale" under a naive staleness filter while still genuinely playing.
|
||||
Filtering on `state == "playing"` instead is correct -- Navidrome
|
||||
expires stopped sessions server-side already.
|
||||
"""
|
||||
payload = self._call(url, username, password, "getNowPlaying")
|
||||
entries = payload.get("nowPlaying", {}).get("entry", []) or []
|
||||
|
||||
if only_user:
|
||||
entries = [e for e in entries if e.get("username") == only_user]
|
||||
|
||||
entries = [e for e in entries if e.get("state") == "playing"]
|
||||
if not entries:
|
||||
return None
|
||||
|
||||
return min(entries, key=lambda e: e.get("minutesAgo") or 0)
|
||||
|
||||
# --------------------------------------------------------------- render
|
||||
|
||||
def render(self, state, settings: dict, resolution: tuple[int, int]) -> Image.Image:
|
||||
idle = state is None
|
||||
entry = state if not idle else settings.get("_last_track")
|
||||
|
||||
width, height = resolution
|
||||
image = Image.new("RGB", (width, height), "white")
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
if entry is None:
|
||||
self._render_empty(draw, width, height)
|
||||
return image
|
||||
|
||||
track = self._build_track(entry, settings, idle)
|
||||
cover = None
|
||||
if entry.get("coverArt"):
|
||||
cover = self._fetch_cover(settings, entry["coverArt"])
|
||||
|
||||
landscape = width > height
|
||||
if landscape:
|
||||
self._render_landscape(image, draw, track, cover, settings, idle, width, height)
|
||||
else:
|
||||
self._render_portrait(image, draw, track, cover, settings, idle, width, height)
|
||||
|
||||
return image
|
||||
|
||||
def _build_track(self, entry: dict, settings: dict, idle: bool) -> dict:
|
||||
duration = int(entry.get("duration") or 0)
|
||||
if idle:
|
||||
elapsed = 0
|
||||
since = time.time() - float(settings.get("_last_track_at") or time.time())
|
||||
minutes_ago = (int(since // 60) // 5) * 5
|
||||
else:
|
||||
elapsed = int((entry.get("positionMs") or 0) / 1000)
|
||||
elapsed = min(elapsed, duration) if duration else elapsed
|
||||
minutes_ago = entry.get("minutesAgo") or 0
|
||||
|
||||
return {
|
||||
"title": entry.get("title") or "Unknown title",
|
||||
"artist": entry.get("artist") or "Unknown artist",
|
||||
"album": entry.get("album") or "",
|
||||
"year": entry.get("year"),
|
||||
"genre": entry.get("genre"),
|
||||
"starred": bool(entry.get("starred")),
|
||||
"player": entry.get("playerName") or "",
|
||||
"listener": entry.get("username") or "",
|
||||
"minutes_ago": minutes_ago,
|
||||
"duration": duration,
|
||||
"duration_text": _format_time(duration),
|
||||
"elapsed": elapsed,
|
||||
"elapsed_text": _format_time(elapsed),
|
||||
"progress_pct": (elapsed / duration) if duration else 0,
|
||||
}
|
||||
|
||||
def _fetch_cover(self, settings: dict, cover_art_id: str):
|
||||
url = (settings.get("url") or "").strip().rstrip("/")
|
||||
username = (settings.get("username") or "").strip()
|
||||
password = settings.get("password") or ""
|
||||
query = self._auth_params(username, password)
|
||||
query.update({"id": cover_art_id, "size": 600})
|
||||
try:
|
||||
response = requests.get(f"{url}/rest/getCoverArt", params=query, timeout=HTTP_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
image = Image.open(BytesIO(response.content)).convert("RGB")
|
||||
except (requests.exceptions.RequestException, OSError) as exc:
|
||||
logger.warning("Could not load cover art %s: %s", cover_art_id, exc)
|
||||
return None
|
||||
|
||||
if settings.get("boost_cover", True):
|
||||
image = ImageEnhance.Contrast(image).enhance(1.35)
|
||||
image = ImageEnhance.Brightness(image).enhance(1.05)
|
||||
|
||||
if settings.get("grayscale_cover", False):
|
||||
image = image.convert("L").convert("RGB")
|
||||
|
||||
return image
|
||||
|
||||
# ------------------------------------------------------------- layouts
|
||||
|
||||
def _render_empty(self, draw: ImageDraw.ImageDraw, width: int, height: int):
|
||||
font = load_font(size=int(min(width, height) * 0.06))
|
||||
msg = "Nichts läuft gerade"
|
||||
mw = text_width(draw, msg, font)
|
||||
draw.text(((width - mw) / 2, height / 2), msg, font=font, fill=(140, 140, 140))
|
||||
|
||||
def _render_landscape(self, image, draw, track, cover, settings, idle, width, height):
|
||||
pad = int(width * 0.025)
|
||||
# Cap at a fraction of width too, not just height -- a naive full-height
|
||||
# square cover can eat almost the whole canvas on a wide/short panel and
|
||||
# leave the text column too narrow to wrap sensibly.
|
||||
cover_size = min(height - 2 * pad, int(width * 0.5))
|
||||
cover_x, cover_y = pad, pad
|
||||
|
||||
self._paste_cover(image, cover, cover_x, cover_y, cover_size)
|
||||
|
||||
meta_x = cover_x + cover_size + int(width * 0.03)
|
||||
meta_w = width - meta_x - pad
|
||||
self._draw_meta(draw, track, settings, idle, meta_x, pad, meta_w, height - 2 * pad, min(width, height))
|
||||
|
||||
def _render_portrait(self, image, draw, track, cover, settings, idle, width, height):
|
||||
pad = int(width * 0.06)
|
||||
cover_size = width - 2 * pad
|
||||
cover_x, cover_y = pad, pad
|
||||
|
||||
self._paste_cover(image, cover, cover_x, cover_y, cover_size)
|
||||
|
||||
meta_y = cover_y + cover_size + int(height * 0.03)
|
||||
meta_h = height - meta_y - pad
|
||||
self._draw_meta(
|
||||
draw, track, settings, idle, pad, meta_y, width - 2 * pad, meta_h, min(width, height), center=True
|
||||
)
|
||||
|
||||
def _paste_cover(self, image, cover, x, y, size):
|
||||
if cover is not None:
|
||||
fitted = _cover_fit(cover, size)
|
||||
image.paste(fitted, (x, y))
|
||||
else:
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle((x, y, x + size, y + size), outline=(60, 60, 60), width=2)
|
||||
font = load_font(size=int(size * 0.3))
|
||||
note = "♪"
|
||||
nw = text_width(draw, note, font)
|
||||
draw.text((x + (size - nw) / 2, y + size * 0.35), note, font=font, fill=(150, 150, 150))
|
||||
|
||||
def _draw_meta(self, draw, track, settings, idle, x, y, w, h, scale, center=False):
|
||||
status_font = load_font(size=int(scale * 0.038), bold=True)
|
||||
title_font = load_font(size=int(scale * 0.075), bold=True)
|
||||
artist_font = load_font(size=int(scale * 0.05))
|
||||
album_font = load_font(size=int(scale * 0.036))
|
||||
source_font = load_font(size=int(scale * 0.03))
|
||||
|
||||
def centered_x(text, font):
|
||||
tw = text_width(draw, text, font)
|
||||
return x + (w - tw) / 2 if center else x
|
||||
|
||||
cursor = y
|
||||
|
||||
status = "ZULETZT GESPIELT" if idle else "● NOW PLAYING"
|
||||
draw.text((centered_x(status, status_font), cursor), status, font=status_font, fill=(100, 100, 100))
|
||||
cursor += status_font.size * 1.8
|
||||
|
||||
title = track["title"] + (" ★" if track["starred"] else "")
|
||||
for line in wrap_to_lines(draw, title, title_font, w, max_lines=2):
|
||||
draw.text((centered_x(line, title_font), cursor), line, font=title_font, fill="black")
|
||||
cursor += title_font.size * 1.15
|
||||
|
||||
cursor += title_font.size * 0.2
|
||||
artist_line = truncate_to_width(draw, track["artist"], artist_font, w)
|
||||
draw.text((centered_x(artist_line, artist_font), cursor), artist_line, font=artist_font, fill=(30, 30, 30))
|
||||
cursor += artist_font.size * 1.4
|
||||
|
||||
if track["album"]:
|
||||
album_line = track["album"]
|
||||
if track["year"]:
|
||||
album_line += f" · {track['year']}"
|
||||
album_line = truncate_to_width(draw, album_line, album_font, w)
|
||||
draw.text((centered_x(album_line, album_font), cursor), album_line, font=album_font, fill=(110, 110, 110))
|
||||
cursor += album_font.size * 1.4
|
||||
|
||||
if settings.get("show_genre", True) and track["genre"]:
|
||||
genre_line = truncate_to_width(draw, track["genre"].upper(), album_font, w)
|
||||
draw.text((centered_x(genre_line, album_font), cursor), genre_line, font=album_font, fill=(150, 150, 150))
|
||||
cursor += album_font.size * 1.4
|
||||
|
||||
if settings.get("show_progress", False) and not idle and track["duration"]:
|
||||
bar_y = cursor + status_font.size * 0.3
|
||||
bar_h = max(3, int(scale * 0.012))
|
||||
draw.rectangle((x, bar_y, x + w, bar_y + bar_h), outline=(60, 60, 60))
|
||||
fill_w = int(w * min(1.0, track["progress_pct"]))
|
||||
if fill_w > 0:
|
||||
draw.rectangle((x, bar_y, x + fill_w, bar_y + bar_h), fill=(60, 60, 60))
|
||||
cursor = bar_y + bar_h + status_font.size * 0.4
|
||||
times = f"{track['elapsed_text']} / {track['duration_text']}"
|
||||
draw.text((centered_x(times, source_font), cursor), times, font=source_font, fill=(110, 110, 110))
|
||||
cursor += source_font.size * 1.6
|
||||
elif track["duration"]:
|
||||
duration_badge = track["duration_text"]
|
||||
draw.text((centered_x(duration_badge, source_font), cursor), duration_badge, font=source_font, fill=(110, 110, 110))
|
||||
cursor += source_font.size * 1.6
|
||||
|
||||
if settings.get("show_source", True):
|
||||
if idle:
|
||||
source_line = f"vor {track['minutes_ago']} min"
|
||||
else:
|
||||
parts = [p for p in (track["player"], track["listener"]) if p]
|
||||
source_line = " · ".join(parts)
|
||||
if source_line:
|
||||
source_line = truncate_to_width(draw, source_line, source_font, w)
|
||||
# Prefer pinning to the bottom of the available area, but never
|
||||
# overlap whatever the cursor already drew above it -- long
|
||||
# titles/metadata can push past where a fixed bottom position
|
||||
# would otherwise land.
|
||||
bottom_y = max(cursor + status_font.size * 0.4, y + h - source_font.size * 1.3)
|
||||
draw.text((centered_x(source_line, source_font), bottom_y), source_line, font=source_font, fill=(140, 140, 140))
|
||||
|
||||
|
||||
def _cover_fit(image: Image.Image, size: int) -> Image.Image:
|
||||
"""Center-crop to a square, then resize -- mirrors the CSS object-fit: cover behavior."""
|
||||
w, h = image.size
|
||||
side = min(w, h)
|
||||
left = (w - side) // 2
|
||||
top = (h - side) // 2
|
||||
cropped = image.crop((left, top, left + side, top + side))
|
||||
return cropped.resize((size, size), Image.LANCZOS)
|
||||
|
||||
|
||||
def _format_time(seconds) -> str:
|
||||
seconds = int(seconds or 0)
|
||||
return f"{seconds // 60}:{seconds % 60:02d}"
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Shared PIL rendering helpers: font loading and text layout.
|
||||
|
||||
The panel driver dithers whatever RGB image we hand it down to its 6-color
|
||||
palette (see epd4in0e.getbuffer, which quantizes with Floyd-Steinberg
|
||||
dithering by default) -- so screens can draw with normal anti-aliased RGB
|
||||
text and full-color cover art without worrying about the palette themselves.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import ImageFont
|
||||
|
||||
FONTS_DIR = Path(__file__).resolve().parent.parent / "fonts"
|
||||
|
||||
_font_cache: dict[tuple[str, int], ImageFont.FreeTypeFont] = {}
|
||||
|
||||
|
||||
def load_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
|
||||
key = ("bold" if bold else "regular", size)
|
||||
if key not in _font_cache:
|
||||
filename = "DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf"
|
||||
_font_cache[key] = ImageFont.truetype(str(FONTS_DIR / filename), size)
|
||||
return _font_cache[key]
|
||||
|
||||
|
||||
def text_width(draw, text: str, font: ImageFont.FreeTypeFont) -> int:
|
||||
left, _, right, _ = draw.textbbox((0, 0), text, font=font)
|
||||
return right - left
|
||||
|
||||
|
||||
def truncate_to_width(draw, text: str, font: ImageFont.FreeTypeFont, max_width: int) -> str:
|
||||
"""Single-line ellipsis truncation, mirroring the CSS text-overflow: ellipsis look."""
|
||||
if text_width(draw, text, font) <= max_width:
|
||||
return text
|
||||
|
||||
ellipsis = "…"
|
||||
lo, hi = 0, len(text)
|
||||
while lo < hi:
|
||||
mid = (lo + hi + 1) // 2
|
||||
candidate = text[:mid].rstrip() + ellipsis
|
||||
if text_width(draw, candidate, font) <= max_width:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid - 1
|
||||
return text[:lo].rstrip() + ellipsis if lo else ellipsis
|
||||
|
||||
|
||||
def wrap_to_lines(draw, text: str, font: ImageFont.FreeTypeFont, max_width: int, max_lines: int) -> list[str]:
|
||||
"""Word-wrap up to `max_lines`, ellipsis-truncating the last line if it still
|
||||
overflows -- mirrors the CSS -webkit-line-clamp behavior used in the old design."""
|
||||
words = text.split()
|
||||
if not words:
|
||||
return []
|
||||
|
||||
lines: list[str] = []
|
||||
current = words[0]
|
||||
next_index = 1
|
||||
while next_index < len(words):
|
||||
word = words[next_index]
|
||||
candidate = f"{current} {word}"
|
||||
if text_width(draw, candidate, font) <= max_width:
|
||||
current = candidate
|
||||
next_index += 1
|
||||
continue
|
||||
|
||||
lines.append(current)
|
||||
current = word
|
||||
next_index += 1
|
||||
if len(lines) == max_lines - 1:
|
||||
break
|
||||
|
||||
if next_index < len(words):
|
||||
# Ran out of lines with words still remaining -- fold the rest into
|
||||
# the last line and ellipsis-truncate it.
|
||||
current = current + " " + " ".join(words[next_index:])
|
||||
current = truncate_to_width(draw, current, font, max_width)
|
||||
|
||||
lines.append(current)
|
||||
return lines[:max_lines]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Current weather via Open-Meteo (free, no API key -- just a lat/long)."""
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from screens.base import Screen
|
||||
from screens.render_utils import load_font, text_width
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HTTP_TIMEOUT = 10
|
||||
|
||||
# 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", "⛅"),
|
||||
3: ("Bedeckt", "☁"),
|
||||
45: ("Nebel", "☁"),
|
||||
48: ("Nebel", "☁"),
|
||||
51: ("Leichter Nieselregen", "☂"),
|
||||
53: ("Nieselregen", "☂"),
|
||||
55: ("Starker Nieselregen", "☂"),
|
||||
61: ("Leichter Regen", "☔"),
|
||||
63: ("Regen", "☔"),
|
||||
65: ("Starker Regen", "☔"),
|
||||
71: ("Leichter Schneefall", "❄"),
|
||||
73: ("Schneefall", "❄"),
|
||||
75: ("Starker Schneefall", "❄"),
|
||||
80: ("Regenschauer", "☔"),
|
||||
81: ("Regenschauer", "☔"),
|
||||
82: ("Heftige Regenschauer", "☔"),
|
||||
85: ("Schneeschauer", "❄"),
|
||||
86: ("Schneeschauer", "❄"),
|
||||
95: ("Gewitter", "⚡"),
|
||||
96: ("Gewitter mit Hagel", "⚡"),
|
||||
99: ("Gewitter mit Hagel", "⚡"),
|
||||
}
|
||||
|
||||
|
||||
class WeatherScreen(Screen):
|
||||
id = "weather"
|
||||
display_name = "Weather"
|
||||
poll_interval = 1800 # 30 minutes -- weather doesn't need to be fresher
|
||||
|
||||
def default_settings(self) -> dict:
|
||||
return {"latitude": None, "longitude": None}
|
||||
|
||||
def check(self, settings: dict):
|
||||
lat = settings.get("latitude")
|
||||
lon = settings.get("longitude")
|
||||
if lat is None or lon is None:
|
||||
logger.warning("Weather screen has no latitude/longitude configured")
|
||||
return None
|
||||
|
||||
response = requests.get(
|
||||
"https://api.open-meteo.com/v1/forecast",
|
||||
params={"latitude": lat, "longitude": lon, "current_weather": "true"},
|
||||
timeout=HTTP_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
current = response.json().get("current_weather") or {}
|
||||
if "temperature" not in current:
|
||||
return None
|
||||
|
||||
code = int(current.get("weathercode", -1))
|
||||
description, symbol = WMO_CODES.get(code, ("Unbekannt", "?"))
|
||||
return {
|
||||
"temperature": current["temperature"],
|
||||
"windspeed": current.get("windspeed"),
|
||||
"description": description,
|
||||
"symbol": symbol,
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if not state:
|
||||
font = load_font(size=int(min(width, height) * 0.06))
|
||||
msg = "Kein Standort konfiguriert" if not (
|
||||
settings.get("latitude") and settings.get("longitude")
|
||||
) else "Wetterdaten nicht verfügbar"
|
||||
mw = text_width(draw, msg, font)
|
||||
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))
|
||||
|
||||
symbol = state["symbol"]
|
||||
sw = text_width(draw, symbol, symbol_font)
|
||||
draw.text(((width - sw) / 2, height * 0.14), 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")
|
||||
|
||||
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))
|
||||
|
||||
return image
|
||||
Reference in new issue
Block a user