Initial commit: epaper-dashboard for 7.3" ACeP 7-Color display

- dashboard.py: plugin-based renderer with 4x4 grid layout
- admin.py: web UI with layout editor + plugin configs
- layout.py: pack algorithm, item placement, grid system
- plugins/: clock, weather, system, spotify, strava, gmail, minimax, hello
- network_watchdog.py: WiFi AP/client mode management
- waveshare_epd_init.py: vendor driver stub
This commit is contained in:
ki
2026-08-26 14:12:43 +04:00
commit 28124c5617
38 changed files with 5408 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Plugins package
+69
View File
@@ -0,0 +1,69 @@
"""Plugin ABC für das Waveshare 7.3" Dashboard.
Ein Plugin ist ein Python-Modul in /plugins/, das eine Klasse Widget
exportiert. Die Klasse wird beim Start dynamisch geladen und in der
Admin-UI zur Auswahl angeboten.
Minimal-Beispiel siehe plugins/hello.py.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class Widget(ABC):
# ---- Metadaten (Klassenattribute) ----
name: str = "" # Eindeutiger Identifier, lowercase, keine Leerzeichen
label: str = "" # Anzeigename in der UI
description: str = "" # Kurzbeschreibung in der UI
category: str = "general" # "info" | "system" | "weather" | "smart-home" | ...
# Optional: Schema der Config-Felder (für UI-Form-Generierung).
# Liste von Dicts mit keys: key, label, type, default, secret, choices, help
# type ∈ {"string", "int", "float", "bool", "secret", "select", "lat_lon"}
config_schema: list[dict] = []
default_config: dict = {}
def __init__(self, config: dict):
# Merge defaults mit übergebener Config
merged = dict(self.default_config)
merged.update(config or {})
self.config = merged
@abstractmethod
def fetch(self) -> dict:
"""Daten holen. Sollte schnell sein — wird alle refresh_interval Sekunden
aufgerufen, plus einmal vor jedem Render. Exceptions werden geloggt und
führen zur Beibehaltung der letzten Daten."""
@abstractmethod
def render(self, draw, fonts, x: int, y: int, w: int, h: int) -> None:
"""Zeichne in den gegebenen Slot (x,y,w,h) auf den draw-Context.
Renderer nutzt RGB-Palette aus palette.py."""
# ---- Optionale Lifecycle-Hooks ----
def on_load(self) -> None:
"""Wird einmal beim Plugin-Load aufgerufen."""
def on_unload(self) -> None:
"""Wird beim Beenden aufgerufen."""
# ---- Helper für Plugins ----
def cfg(self, key: str, default: Any = None) -> Any:
return self.config.get(key, default)
def all_widget_classes() -> list[type[Widget]]:
"""Lade alle Plugin-Klassen aus dem plugins/-Ordner."""
import os, importlib, pkgutil
plugins_pkg = os.path.join(os.path.dirname(__file__), "..", "plugins")
plugins_pkg = os.path.abspath(plugins_pkg)
classes: list[type[Widget]] = []
for _, modname, _ in pkgutil.iter_modules([plugins_pkg]):
mod = importlib.import_module(f"plugins.{modname}")
cls = getattr(mod, "Widget", None)
if cls and isinstance(cls, type) and issubclass(cls, Widget) and cls is not Widget:
classes.append(cls)
# alphabetisch
classes.sort(key=lambda c: c.label or c.name)
return classes
+70
View File
@@ -0,0 +1,70 @@
"""Clock: Uhrzeit + Datum. Responsive fuer alle Slot-Groessen."""
import os, sys
from datetime import datetime
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from plugins.base import Widget
from palette import FG, INFO, ACCENT, measure, fit_font, centered_text, is_small, is_wide
class Widget(Widget):
name = "clock"
label = "Uhrzeit / Datum"
description = "Aktuelle Uhrzeit und Datum. Responsives Layout fuer 1x1 bis 4x4."
category = "info"
config_schema = [
{"key": "format_24h", "label": "24-Stunden-Format", "type": "bool", "default": True},
{"key": "show_seconds", "label": "Sekunden anzeigen", "type": "bool", "default": False},
{"key": "show_date", "label": "Datum anzeigen", "type": "bool", "default": True},
{"key": "show_weekday", "label": "Wochentag anzeigen", "type": "bool", "default": True},
{"key": "accent_color", "label": "Akzentfarbe", "type": "select",
"choices": ["fg", "info", "accent", "ok", "warn", "alert"], "default": "fg"},
]
default_config = {"format_24h": True, "show_seconds": False,
"show_date": True, "show_weekday": True, "accent_color": "fg"}
def fetch(self):
return {}
def render(self, draw, fonts, x, y, w, h):
from palette import fill_for, measure
now = datetime.now()
pad = 8
accent = fill_for(self.cfg("accent_color", "fg"))
time_str = now.strftime("%H:%M:%S" if self.cfg("show_seconds") else "%H:%M")
date_str = now.strftime("%d %b %Y")
day_str = now.strftime("%A").upper()
if is_small(w, h):
# mini: nur Uhr, zentriert
font = fit_font(draw, time_str, fonts, w - 2 * pad, h - 2 * pad)
centered_text(draw, time_str, x, y, w, h, font, FG)
return
if is_wide(w, h):
# Wide strip: Uhrzeit links gross, Datum rechts klein
font_time = fit_font(draw, time_str, fonts, w // 2 - 2 * pad, h - 2 * pad)
centered_text(draw, time_str, x, y, w // 2, h, font_time, FG)
if self.cfg("show_date"):
font_date = fit_font(draw, date_str, fonts, w // 2 - 2 * pad, h // 2)
centered_text(draw, date_str, x + w // 2, y, w // 2, h // 2, font_date, accent)
if self.cfg("show_weekday"):
font_day = fit_font(draw, day_str, fonts, w // 2 - 2 * pad, h // 2)
centered_text(draw, day_str, x + w // 2, y + h // 2, w // 2, h // 2, font_day, INFO)
return
# Standard: Header (Datum), Big Time, Day unten
# Big Time zentriert
font_time = fit_font(draw, time_str, fonts, w - 2 * pad, int(h * 0.55))
centered_text(draw, time_str, x, y + pad, w, int(h * 0.55), font_time, FG)
# Datum darunter
if self.cfg("show_date"):
font_date = fit_font(draw, date_str, fonts, w - 2 * pad, h // 5)
centered_text(draw, date_str, x, y + int(h * 0.55), w, h // 5, font_date, accent)
# Wochentag
if self.cfg("show_weekday"):
font_day = fit_font(draw, day_str, fonts, w - 2 * pad, h // 6)
centered_text(draw, day_str, x, y + int(h * 0.78), w, h // 6, font_day, INFO)
+58
View File
@@ -0,0 +1,58 @@
"""Gmail Unread - responsive."""
import os, sys, json
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from plugins.base import Widget
from palette import FG, INFO, OK, WARN, measure, fit_font, centered_text, is_small, is_wide
class Widget(Widget):
name = "gmail"
label = "Gmail Unread"
description = "Anzahl ungelesener Emails im Posteingang."
category = "info"
config_schema = [
{"key": "oauth_token_json", "label": "OAuth Token (JSON)", "type": "secret",
"help": "token.json aus einem einmaligen OAuth-Flow."},
]
default_config = {"oauth_token_json": ""}
def fetch(self):
tok = self.cfg("oauth_token_json")
if not tok:
return {"_error": "OAuth-Token fehlt"}
try:
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
creds = Credentials.from_authorized_user_info(json.loads(tok))
if creds.expired and creds.refresh_token:
creds.refresh(Request())
service = build("gmail", "v1", credentials=creds, cache_discovery=False)
label = service.users().labels().get(userId="me", id="INBOX").execute()
return {"unread": label.get("messagesUnread", 0)}
except ImportError:
return {"_error": "google-api-python-client nicht installiert"}
except Exception as e:
return {"_error": str(e)}
def render(self, draw, fonts, x, y, w, h):
pad = 8
draw.text((x + pad, y + pad), "GMAIL", font=fonts.get("24", fonts.get("20")), fill=INFO)
d = self.fetch()
if "_error" in d:
font = fit_font(draw, "Konfig fehlt", fonts, w - 2 * pad, h - 80)
centered_text(draw, "Konfig fehlt", x, y + 60, w, h - 60, font, WARN)
return
unread = d["unread"]
color = OK if unread == 0 else WARN if unread < 10 else FG
text = str(unread)
# Big number centered
if is_small(w, h):
font = fit_font(draw, text, fonts, w - 2 * pad, h - 50)
centered_text(draw, text, x, y, w, h - 30, font, color)
else:
font = fit_font(draw, text, fonts, w - 2 * pad, int(h * 0.6))
centered_text(draw, text, x, y, w, int(h * 0.6), font, color)
font_lbl = fit_font(draw, "Ungelesen", fonts, w - 2 * pad, 24)
centered_text(draw, "Ungelesen", x, y + int(h * 0.7), w, h // 5, font_lbl, FG)
+27
View File
@@ -0,0 +1,27 @@
"""Demo-Plugin: konfigurierbarer Text."""
import os, sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from plugins.base import Widget
from palette import fill_for, measure, fit_font, centered_text
class Widget(Widget):
name = "hello"
label = "Hello / Demo"
description = "Zeigt einen konfigurierbaren Text. Responsiv."
category = "general"
config_schema = [
{"key": "text", "label": "Text", "type": "string", "default": "Hello!"},
{"key": "color", "label": "Farbe", "type": "select",
"choices": ["fg", "info", "ok", "warn", "alert", "accent"], "default": "fg"},
]
default_config = {"text": "Hello!", "color": "fg"}
def fetch(self):
return {}
def render(self, draw, fonts, x, y, w, h):
text = self.cfg("text", "Hello!")
font = fit_font(draw, text, fonts, w - 16, h - 16)
centered_text(draw, text, x, y, w, h, font, fill_for(self.cfg("color", "fg")))
+271
View File
@@ -0,0 +1,271 @@
"""MiniMax Token-Usage Widget - responsive."""
import os, sys, json
import urllib.request, urllib.error
from datetime import datetime
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from plugins.base import Widget
from palette import FG, INFO, OK, WARN, ALERT, ORANGE, BG, measure, fit_font, hbar, centered_text, is_small, is_wide, parse_thresholds, DEFAULT_THRESHOLDS
def _fmt_timedelta_short(seconds):
if seconds is None: return ""
s = int(seconds)
if s <= 0: return "now"
days, rem = divmod(s, 86400)
h, rem = divmod(rem, 3600)
m = rem // 60
if days: return f"{days}d {h}h"
if h: return f"{h}h {m}m"
return f"{m}m"
def _deep_get(d, *keys, default=None):
for k in keys:
if isinstance(d, dict) and k in d:
v = d[k]
if v is not None:
return v
return default
def _fetch_remains(subscription_key, base_url="https://api.minimax.io"):
url = f"{base_url.rstrip('/')}/v1/token_plan/remains"
req = urllib.request.Request(url, headers={
"Authorization": f"Bearer {subscription_key}",
"Content-Type": "application/json", "Accept": "application/json",
})
try:
with urllib.request.urlopen(req, timeout=10) as r:
raw = r.read()
try:
return {"ok": True, "data": json.loads(raw), "raw": raw.decode("utf-8", "ignore")}
except json.JSONDecodeError as e:
return {"ok": False, "error": f"invalid json: {e}",
"raw": raw.decode("utf-8", "ignore")[:500]}
except urllib.error.HTTPError as e:
body = ""
try: body = e.read().decode("utf-8", "ignore")[:300]
except Exception: pass
return {"ok": False, "error": f"HTTP {e.code} {e.reason}: {body}".strip()}
def _check_base_resp(data):
base = _deep_get(data, "base_resp")
if not isinstance(base, dict): return None
code = base.get("status_code", 0)
if code in (0, None, "0", ""): return None
return f"{base.get('status_msg') or base.get('message') or 'API-Fehler'} (code {code})"
def _parse_model_remains(items):
now_ms = int(datetime.now().timestamp() * 1000)
parsed = []
for entry in items:
if not isinstance(entry, dict): continue
model_name = entry.get("model_name", "model")
for win_label, rem_pct, end_time_raw, status_raw in [
("5-Hour", entry.get("current_interval_remaining_percent"),
entry.get("end_time"), entry.get("current_interval_status")),
("Weekly", entry.get("current_weekly_remaining_percent"),
entry.get("weekly_end_time"), entry.get("current_weekly_status")),
]:
if rem_pct is None: continue
try: status = int(status_raw) if status_raw is not None else 1
except (TypeError, ValueError): status = 1
if status != 1: continue
reset_sec = None
if isinstance(end_time_raw, (int, float)) and end_time_raw > 1e12:
reset_sec = max(0, int((end_time_raw - now_ms) / 1000))
elif isinstance(end_time_raw, (int, float)):
reset_sec = max(0, int(end_time_raw))
used_pct = max(0.0, min(100.0, 100.0 - float(rem_pct)))
parsed.append({
"label": win_label,
"used_pct": used_pct,
"remaining_pct": float(rem_pct),
"reset_seconds": reset_sec,
"raw": entry,
"model_name": model_name,
})
return parsed
def _parse_windows(data):
model_remains = data.get("model_remains")
if isinstance(model_remains, list) and model_remains:
return _parse_model_remains(model_remains)
candidates = [
data.get("remains"),
_deep_get(data, "data", "remains"),
_deep_get(data, "data", "windows"),
data.get("windows"),
_deep_get(data, "plan", "windows"),
]
raw_windows = None
for c in candidates:
if isinstance(c, list) and c:
raw_windows = c; break
if not isinstance(raw_windows, list):
return []
parsed = []
for w in raw_windows:
if not isinstance(w, dict): continue
reset_sec = w.get("reset_in") or w.get("reset_in_seconds")
if reset_sec is None:
reset_iso = w.get("reset_at") or w.get("resets_at")
if isinstance(reset_iso, str):
try:
dt = datetime.fromisoformat(reset_iso.replace("Z", "+00:00"))
reset_sec = (dt - datetime.now(dt.tzinfo)).total_seconds()
except Exception:
reset_sec = None
usage_raw = w.get("usage") or w.get("used_pct") or w.get("utilization")
used_pct = None
if isinstance(usage_raw, (int, float)):
used_pct = float(usage_raw) * 100 if float(usage_raw) <= 1.0 else float(usage_raw)
if used_pct is None: continue
name = (w.get("name") or w.get("window") or w.get("label") or "window").lower()
parsed.append({
"label": name.upper(),
"used_pct": min(100.0, max(0.0, used_pct)),
"remaining_pct": max(0.0, 100.0 - used_pct),
"reset_seconds": int(reset_sec) if reset_sec else None,
})
return parsed
def _parse_credits(data):
points = _deep_get(data, "points_balance", "credits_balance", "balance", "points", "credits")
if isinstance(points, (int, float)):
return {"points": float(points)}
nested = _deep_get(data, "plan", "credits")
if isinstance(nested, (int, float)):
return {"points": float(nested)}
return None
class Widget(Widget):
name = "minimax"
label = "MiniMax Token Usage"
description = "Quota für MiniMax Token-Plan (5-Hour + Weekly). Responsive."
category = "info"
config_schema = [
{"key": "subscription_key", "label": "Subscription Key", "type": "secret",
"help": "Token-Plan Subscription Key. NICHT der pay-as-you-go API-Key."},
{"key": "show_credits", "label": "Credit-Balance anzeigen", "type": "bool", "default": True},
{"key": "api_base", "label": "API Base URL", "type": "string", "default": "https://api.minimax.io"},
{"key": "bar_thresholds", "label": "Quota-Schwellen (ok,warn,alert)",
"type": "string", "default": "ok@50,warn@80,alert@95",
"help": "Format: 'ok@50,warn@80,alert@95' oder 'ok,warn,alert' (default 50/80/95)"},
{"key": "bar_gradient", "label": "Verlaufsmodus",
"type": "bool", "default": True,
"help": "Wenn aus, einfarbige Bar in der Farbe der aktuellen Schwelle."},
]
default_config = {"subscription_key": "", "show_credits": True,
"api_base": "https://api.minimax.io",
"bar_thresholds": "ok@50,warn@80,alert@95",
"bar_gradient": True}
def _bar_args(self):
return (parse_thresholds(self.cfg("bar_thresholds")),
bool(self.cfg("bar_gradient", True)))
def fetch(self):
key = self.cfg("subscription_key")
if not key:
return {"_error": "Subscription Key fehlt — in der Admin-UI setzen."}
try:
r = _fetch_remains(key, self.cfg("api_base", "https://api.minimax.io"))
if not r.get("ok"):
return {"_error": r.get("error", "unbekannter Fehler")}
data = r["data"]
base_err = _check_base_resp(data)
if base_err:
return {"_error": base_err,
"_raw_keys": list(data.keys()) if isinstance(data, dict) else None}
windows = _parse_windows(data)
credits = _parse_credits(data) if self.cfg("show_credits") else None
if not windows and not credits:
return {"_error": "Schema unbekannt.", "_raw_keys": list(data.keys()) if isinstance(data, dict) else None}
return {"windows": windows, "credits": credits}
except Exception as e:
return {"_error": str(e)}
def _render_window(self, draw, fonts, x, y, w, h, wn, compact=False):
from palette import measure
# Label + Reset rechts
label = wn["label"]
reset_txt = ("R " + _fmt_timedelta_short(wn["reset_seconds"])) if wn.get("reset_seconds") else ""
font_label = fit_font(draw, label, fonts, w - 60, 22)
draw.text((x, y), label, font=font_label, fill=FG)
if reset_txt and not compact:
font_r = fit_font(draw, reset_txt, fonts, 60, 20)
rw, rh = measure(draw, reset_txt, font_r)
draw.text((x + w - rw - 4, y + 2), reset_txt, font=font_r, fill=FG)
# Bar
bar_y = y + (22 if not compact else 18)
bar_h = max(10, h - (bar_y - y) - 4)
pct = wn["used_pct"]
thresholds, gradient = self._bar_args()
hbar(draw, x, bar_y, w, bar_h, pct,
thresholds=thresholds, gradient=gradient)
# Prozent overlay
if not compact:
pct_txt = f"{int(pct + 0.5)}%"
font_pct = fit_font(draw, pct_txt, fonts, 60, bar_h - 4)
tw, th = measure(draw, pct_txt, font_pct)
draw.text((x + w - tw - 8, bar_y + 2), pct_txt, font=font_pct, fill=BG)
def render(self, draw, fonts, x, y, w, h):
from palette import measure
pad = 8
draw.text((x + pad, y + pad), "MINIMAX", font=fonts.get("24", fonts.get("20")), fill=INFO)
d = self.fetch()
if "_error" in d:
draw.text((x + pad, y + 60), "Konfiguration fehlt", font=fonts.get("24", fonts.get("20")), fill=WARN)
err = d["_error"][:50]
draw.text((x + pad, y + 90), err, font=fonts.get("20", fonts.get("16")), fill=FG)
if d.get("_raw_keys"):
draw.text((x + pad, y + 120), f"raw keys: {','.join(d['_raw_keys'][:6])}",
font=fonts.get("16", fonts.get("default")), fill=FG)
return
windows = d.get("windows", [])
if is_small(w, h):
# mini: nur "5h: 38%" etc kompakt
line_y = y + 50
for wn in windows[:2]:
label = f"{wn['label'][:1]}{wn['remaining_pct']:.0f}%"
font = fit_font(draw, label, fonts, w - 2 * pad, h // 3)
draw.text((x + pad, line_y), label, font=font, fill=FG)
line_y += h // 3
return
if is_wide(w, h):
# Wide strip: alle windows nebeneinander
n = max(1, len(windows))
col_w = w // n
for i, wn in enumerate(windows):
cx = x + i * col_w
self._render_window(draw, fonts, cx + pad // 2, y + 30,
col_w - pad, h - 35, wn, compact=True)
return
# Standard: vertikale Liste mit Bars
row_h = max(40, (h - 60) // max(1, len(windows)))
for i, wn in enumerate(windows):
row_y = y + 50 + i * row_h
self._render_window(draw, fonts, x + pad, row_y,
w - 2 * pad, row_h - 6, wn)
# Credits
credits = d.get("credits")
if credits and self.cfg("show_credits") and h > 220:
pts = credits.get("points")
if isinstance(pts, (int, float)):
txt = f"Credits: {pts:,.0f}"
font = fit_font(draw, txt, fonts, w - 2 * pad, 22)
draw.text((x + pad, y + h - 28), txt, font=font, fill=ORANGE)
+78
View File
@@ -0,0 +1,78 @@
"""Spotify via Last.fm Scrobble - responsive."""
import os, sys, json
import urllib.request, urllib.error, urllib.parse
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from plugins.base import Widget
from palette import FG, INFO, OK, WARN, measure, fit_font, centered_text, is_small, is_wide
def _fetch_lastfm(api_key, user):
url = (f"http://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks"
f"&user={urllib.parse.quote(user)}&api_key={api_key}"
f"&format=json&limit=2")
with urllib.request.urlopen(url, timeout=8) as r:
return json.loads(r.read())
class Widget(Widget):
name = "spotify"
label = "Spotify (Last.fm)"
description = "Zeigt aktuell gespielten Spotify-Track via Last.fm Scrobble."
category = "info"
config_schema = [
{"key": "api_key", "label": "Last.fm API Key", "type": "secret"},
{"key": "username", "label": "Last.fm Username", "type": "string", "default": ""},
]
default_config = {"api_key": "", "username": ""}
def fetch(self):
api_key = self.cfg("api_key")
user = self.cfg("username")
if not api_key or not user:
return {"_error": "API-Key oder Username fehlt"}
try:
return _fetch_lastfm(api_key, user)
except Exception as e:
return {"_error": str(e)}
def render(self, draw, fonts, x, y, w, h):
pad = 8
draw.text((x + pad, y + pad), "SPOTIFY", font=fonts.get("24", fonts.get("20")), fill=INFO)
d = self.fetch()
if "_error" in d:
font = fit_font(draw, "Konfig fehlt", fonts, w - 2 * pad, h - 80)
centered_text(draw, "Konfig fehlt", x, y + 60, w, h - 60, font, WARN)
return
try:
tracks = d.get("recenttracks", {}).get("track", [])
if isinstance(tracks, dict): tracks = [tracks]
if not tracks:
font = fit_font(draw, "Kein Track", fonts, w - 2 * pad, h - 80)
centered_text(draw, "Kein Track", x, y + 60, w, h - 60, font, FG)
return
current = tracks[0]
is_playing = current.get("@attr", {}).get("nowplaying") == "true"
artist = current.get("artist", {}).get("#text", "?")
track = current.get("name", "?")
color = OK if is_playing else WARN
if is_small(w, h):
txt = "" if is_playing else ""
font = fit_font(draw, txt, fonts, w - 2 * pad, h - 2 * pad)
centered_text(draw, txt, x, y, w, h, font, color)
return
# Header symbol
status_str = "" if is_playing else ""
font_st = fit_font(draw, status_str, fonts, 50, h // 4)
draw.text((x + pad, y + 50), status_str, font=font_st, fill=color)
# Track info
font_a = fit_font(draw, artist, fonts, w - 60, h // 4)
font_t = fit_font(draw, track, fonts, w - 20, h // 4)
draw.text((x + 60, y + 50), artist[:30], font=font_a, fill=FG)
draw.text((x + 60, y + 50 + font_a.size + 8), track[:35], font=font_t, fill=FG)
except Exception as e:
font = fit_font(draw, f"Fehler: {e}", fonts, w - 2 * pad, h - 80)
centered_text(draw, f"Fehler: {str(e)[:40]}", x, y + 60, w, h - 60, font, WARN)
+99
View File
@@ -0,0 +1,99 @@
"""Strava Stats - responsive."""
import os, sys, time, json
import urllib.request, urllib.error
from datetime import datetime
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from plugins.base import Widget
from palette import FG, INFO, OK, WARN, measure, fit_font, centered_text, is_small, is_wide
def _strava_refresh(client_id, client_secret, refresh_token):
data = (f"client_id={client_id}&client_secret={client_secret}"
f"&grant_type=refresh_token&refresh_token={refresh_token}").encode()
req = urllib.request.Request("https://www.strava.com/oauth/token", data=data)
with urllib.request.urlopen(req, timeout=8) as r:
return json.loads(r.read())
def _strava_activities(access_token, page=1):
url = f"https://www.strava.com/api/v3/athlete/activities?page={page}&per_page=100"
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {access_token}"})
with urllib.request.urlopen(req, timeout=8) as r:
return json.loads(r.read())
def _stats(activities):
total = sum(a.get("distance", 0) for a in activities)
rides = sum(1 for a in activities if a.get("type") in ("Ride", "VirtualRide"))
hike = sum(a.get("distance", 0) for a in activities if a.get("type") in ("Hike", "Walk"))
year = datetime.now().year
year_start = datetime(year, 1, 1).timestamp()
year_dist = sum(a.get("distance", 0) for a in activities
if datetime.strptime(a["start_date"][:19], "%Y-%m-%dT%H:%M:%S").timestamp() >= year_start)
return {"total_km": total / 1000, "year_km": year_dist / 1000,
"rides": rides, "hike_km": hike / 1000}
class Widget(Widget):
name = "strava"
label = "Strava Aktivitäten"
description = "Distanz und Rides aus Strava. Setze Client-ID/Secret/Refresh-Token unten."
category = "fitness"
config_schema = [
{"key": "client_id", "label": "Strava Client ID", "type": "secret"},
{"key": "client_secret", "label": "Strava Client Secret", "type": "secret"},
{"key": "refresh_token", "label": "Refresh Token", "type": "secret",
"help": "Einmaliger OAuth-Token. Plugin holt sich access_tokens on-demand."},
]
default_config = {"client_id": "", "client_secret": "", "refresh_token": ""}
def fetch(self):
cid = self.cfg("client_id")
csec = self.cfg("client_secret")
rtok = self.cfg("refresh_token")
if not (cid and csec and rtok):
return {"_error": "Tokens fehlen"}
try:
tok = _strava_refresh(cid, csec, rtok)
activities = _strava_activities(tok["access_token"], page=1)
return _stats(activities)
except Exception as e:
return {"_error": str(e)}
def render(self, draw, fonts, x, y, w, h):
pad = 8
draw.text((x + pad, y + pad), "STRAVA", font=fonts.get("24", fonts.get("20")), fill=INFO)
d = self.fetch()
if "_error" in d:
font = fit_font(draw, "Konfig fehlt", fonts, w - 2 * pad, h - 80)
centered_text(draw, "Konfig fehlt", x, y + 50, w, h - 60, font, WARN)
return
if is_small(w, h):
txt = f"{d['year_km']:.0f}km"
font = fit_font(draw, txt, fonts, w - 2 * pad, h - 2 * pad)
centered_text(draw, txt, x, y, w, h, font, OK)
return
if is_wide(w, h):
stats = [f"{d['year_km']:.0f} km YTD", f"{d['rides']} rides",
f"{d['total_km']:.0f} km total"]
col_w = w // len(stats)
for i, s in enumerate(stats):
cx = x + i * col_w
font = fit_font(draw, s, fonts, col_w - 2 * pad, h - 50)
centered_text(draw, s, cx, y + 30, col_w, h - 30, font, FG)
return
# Standard
font_b = fit_font(draw, f"{d['year_km']:.0f} km", fonts, w - 2 * pad, h // 3)
draw.text((x + pad, y + 50), f"{d['year_km']:.0f} km", font=font_b, fill=FG)
draw.text((x + pad, y + 50 + font_b.size + 8),
f"in {datetime.now().year}", font=fonts.get("24", fonts.get("20")), fill=OK)
font_r = fit_font(draw, f"{d['rides']} rides", fonts, w - 2 * pad, 28)
draw.text((x + pad, y + h - 100), f"{d['rides']} rides", font=font_r, fill=OK)
font_h = fit_font(draw, f"{d['hike_km']:.1f} km hike", fonts, w - 2 * pad, 24)
draw.text((x + pad, y + h - 70), f"{d['hike_km']:.1f} km hike", font=font_h, fill=FG)
font_t = fit_font(draw, f"Total: {d['total_km']:.0f} km", fonts, w - 2 * pad, 20)
draw.text((x + pad, y + h - 40), f"Total: {d['total_km']:.0f} km", font=font_t, fill=FG)
+163
View File
@@ -0,0 +1,163 @@
"""System-Plugin: CPU-Last, RAM, Uptime. Responsive."""
import os, sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from plugins.base import Widget
from palette import FG, INFO, OK, WARN, ALERT, measure, fit_font, is_small, is_wide, hbar, parse_thresholds, DEFAULT_THRESHOLDS
def _read_loadavg():
try:
with open("/proc/loadavg") as f:
parts = f.read().split()
return float(parts[0])
except Exception:
return 0.0
def _read_mem():
try:
with open("/proc/meminfo") as f:
lines = f.readlines()
total = int([l for l in lines if l.startswith("MemTotal")][0].split()[1])
avail = int([l for l in lines if l.startswith("MemAvailable")][0].split()[1])
return total, total - avail, avail
except Exception:
return 1, 0, 1
def _read_uptime():
try:
with open("/proc/uptime") as f:
return float(f.read().split()[0])
except Exception:
return 0.0
def _fmt_uptime(s):
days, rem = divmod(int(s), 86400)
h, rem = divmod(rem, 3600)
m = rem // 60
if days: return f"{days}d {h}h"
if h: return f"{h}h {m}m"
return f"{m}m"
def _system_stats():
ncpu = os.cpu_count() or 1
la = _read_loadavg()
total, used, avail = _read_mem()
return {
"cpu_pct": min(int((la / ncpu) * 100), 100),
"ram_used_mb": used // 1024,
"ram_total_mb": total // 1024,
"ram_pct": int(used * 100 / total) if total else 0,
"uptime_s": _read_uptime(),
}
class Widget(Widget):
name = "system"
label = "System (CPU/RAM)"
description = "Live CPU-Last, RAM-Auslastung, Uptime. Responsive Layout fuer 1x1 bis 4x4."
category = "system"
config_schema = [
{"key": "show_uptime", "label": "Uptime anzeigen", "type": "bool", "default": True},
{"key": "show_load", "label": "Load Average anzeigen", "type": "bool", "default": True},
{"key": "compact", "label": "Kompaktmodus (nur Prozent)", "type": "bool", "default": False},
{"key": "bar_thresholds", "label": "CPU/RAM-Schwellen (Format: ok,warn,alert oder ok@50,warn@80,alert@95)",
"type": "string", "default": "ok@50,warn@80,alert@95",
"help": "Legt fest, ab wann eine Bar grün/gelb/rot wird. Mit '@P' setzt du die Schwelle in Prozent."},
{"key": "bar_gradient", "label": "Verlaufsmodus (Balken zeigt alle Stufen gleichzeitig)",
"type": "bool", "default": True,
"help": "Wenn aus, ist die Bar einfarbig in der Farbe der aktuellen Schwelle."},
]
default_config = {"show_uptime": True, "show_load": True, "compact": False,
"bar_thresholds": "ok@50,warn@80,alert@95", "bar_gradient": True}
def _bar_args(self):
return (parse_thresholds(self.cfg("bar_thresholds")),
bool(self.cfg("bar_gradient", True)))
def fetch(self):
return _system_stats()
def render(self, draw, fonts, x, y, w, h):
from palette import fill_for, measure
pad = 8
s = self.fetch()
cpu, ram = s["cpu_pct"], s["ram_pct"]
thresholds, gradient = self._bar_args()
# Im compact/small mode bleiben die Fallback-Farben (Text-Label reicht)
if self.cfg("compact") or is_small(w, h):
# mini/compact: nur CPU + RAM als 2 kleine bars
label_font = fit_font(draw, "CPU", fonts, w - 2 * pad, 20)
draw.text((x + pad, y + pad), "CPU", font=label_font, fill=FG)
bar_y = y + pad + 22
bar_w = w - 2 * pad
bar_h = max(8, (h - 30) // 4)
hbar(draw, x + pad, bar_y, bar_w, bar_h, cpu,
thresholds=thresholds, gradient=gradient)
ram_y = bar_y + bar_h + 6
draw.text((x + pad, ram_y), "RAM", font=label_font, fill=FG)
hbar(draw, x + pad, ram_y + 22, bar_w, bar_h, ram,
thresholds=thresholds, gradient=gradient)
return
if is_wide(w, h):
# Wide strip: CPU% | RAM% | Uptime (each third)
col_w = w // 3
for i, (label, pct) in enumerate([
("CPU", cpu), ("RAM", ram),
]):
cx = x + i * col_w
# Color = passende Schwelle
col_color = WARN
if thresholds:
for max_p, c in thresholds:
if pct <= max_p:
col_color = c; break
label_font = fit_font(draw, label, fonts, col_w - 2 * pad, 22)
draw.text((cx + pad, y + pad), f"{label} {pct}%",
font=label_font, fill=col_color)
if self.cfg("show_uptime"):
up_str = "up " + _fmt_uptime(s["uptime_s"])
font_up = fit_font(draw, up_str, fonts, col_w - 2 * pad, 22)
draw.text((x + 2 * col_w + pad, y + pad), up_str, font=font_up, fill=INFO)
return
# Standard: Header, CPU-Bar, RAM-Bar, Uptime
header_font = fonts.get("28") or fonts.get("24")
draw.text((x + pad, y + pad), "SYSTEM", font=header_font, fill=INFO)
# Big numbers + bars
cur_y = y + 40
# CPU row
text = f"CPU: {cpu}%"
draw.text((x + pad, cur_y), text, font=fonts.get("32", fonts.get("24")), fill=FG)
bar_y = cur_y + 36
bar_h = 28
hbar(draw, x + pad, bar_y, w - 2 * pad, bar_h, cpu,
thresholds=thresholds, gradient=gradient)
# RAM row
cur_y = bar_y + bar_h + 12
text = f"RAM: {s['ram_used_mb']}/{s['ram_total_mb']} MB ({ram}%)"
font_text = fit_font(draw, text, fonts, w - 2 * pad, 28)
draw.text((x + pad, cur_y), text, font=font_text, fill=FG)
bar_y2 = cur_y + 30
hbar(draw, x + pad, bar_y2, w - 2 * pad, bar_h, ram,
thresholds=thresholds, gradient=gradient)
# Bottom: load + uptime
bottom_y = bar_y2 + bar_h + 8
if self.cfg("show_load") or self.cfg("show_uptime"):
parts = []
if self.cfg("show_load"):
parts.append(f"load {s['cpu_pct']/100.0 * (os.cpu_count() or 1):.2f}")
if self.cfg("show_uptime"):
parts.append("up " + _fmt_uptime(s["uptime_s"]))
line = " · ".join(parts)
font_bot = fit_font(draw, line, fonts, w - 2 * pad, 22)
draw.text((x + pad, bottom_y), line, font=font_bot, fill=FG)
+179
View File
@@ -0,0 +1,179 @@
"""Wetter-Plugin (Open-Meteo, kein API-Key). Responsive."""
import os, sys, json, math
from datetime import datetime
import urllib.request, urllib.error
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from plugins.base import Widget
from palette import FG, INFO, OK, WARN, ALERT, ORANGE, RED, BLUE, measure, fit_font, centered_text, is_small, is_wide
def _fetch_openmeteo(lat, lon):
url = (f"https://api.open-meteo.com/v1/forecast?"
f"latitude={lat}&longitude={lon}"
f"&current=temperature_2m,relative_humidity_2m,weather_code,"
f"wind_speed_10m,wind_direction_10m,surface_pressure,uv_index,is_day"
f"&hourly=temperature_2m,weather_code"
f"&forecast_days=2&timezone=auto")
try:
with urllib.request.urlopen(url, timeout=8) as r:
return json.loads(r.read())
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as e:
return {"_error": str(e)}
class Widget(Widget):
name = "weather"
label = "Wetter (Open-Meteo)"
description = "Aktuelles Wetter, Forecast und Windrose. Kein API-Key. Responsive."
category = "weather"
config_schema = [
{"key": "location", "label": "Standort (lat,lon)", "type": "lat_lon",
"default": "52.52,13.41", "help": "Beispiel: 52.52,13.41 für Berlin"},
{"key": "show_uv", "label": "UV-Index anzeigen", "type": "bool", "default": True},
{"key": "show_forecast_hours", "label": "Vorhersage-Stunden", "type": "int",
"default": 4, "help": "Wie viele Stunden voraus (1-8)"},
{"key": "show_wind_compass", "label": "Windrose anzeigen", "type": "bool", "default": True},
]
default_config = {"location": "52.52,13.41", "show_uv": True,
"show_forecast_hours": 4, "show_wind_compass": True}
def fetch(self):
loc = self.cfg("location", "52.52,13.41")
try:
parts = [s.strip() for s in loc.split(",")]
lat = float(parts[0]); lon = float(parts[1])
except Exception:
lat, lon = 52.52, 13.41
data = _fetch_openmeteo(lat, lon)
if "_error" not in data:
data["_lat"] = lat; data["_lon"] = lon
return data
def _draw_compass(self, draw, cx, cy, r, deg, fonts):
draw.ellipse((cx-r, cy-r, cx+r, cy+r), outline=FG, width=2)
for a in range(0, 360, 45):
rad = math.radians(a - 90)
inner = r - 8 if a % 90 == 0 else r - 4
x1 = cx + inner * math.cos(rad); y1 = cy + inner * math.sin(rad)
x2 = cx + r * math.cos(rad); y2 = cy + r * math.sin(rad)
draw.line((x1, y1, x2, y2), fill=FG, width=2)
f20 = fonts.get("20", fonts.get("16"))
for a, t in [(270,"N"), (0,"E"), (90,"S"), (180,"W")]:
rad = math.radians(a - 90)
tx = cx + (r+12) * math.cos(rad) - 6
ty = cy + (r+12) * math.sin(rad) - 9
draw.text((tx, ty), t, font=f20, fill=FG)
rad_arrow = math.radians(deg - 90)
tip_x = cx + (r - 14) * math.cos(rad_arrow)
tip_y = cy + (r - 14) * math.sin(rad_arrow)
base = math.radians(150)
lx = cx + 18 * math.cos(rad_arrow + base); ly = cy + 18 * math.sin(rad_arrow + base)
rx = cx + 18 * math.cos(rad_arrow - base); ry = cy + 18 * math.sin(rad_arrow - base)
draw.polygon([(tip_x, tip_y), (lx, ly), (rx, ry)], fill=RED)
draw.ellipse((cx-3, cy-3, cx+3, cy+3), fill=FG)
def render(self, draw, fonts, x, y, w, h):
from palette import fill_for
pad = 8
d = self.fetch()
if "_error" in d:
draw.text((x + pad, y + pad), "WETTER", font=fonts.get("24", fonts.get("20")), fill=INFO)
draw.text((x + pad, y + 60), f"Fehler: {d['_error'][:40]}", font=fonts.get("20", fonts.get("16")), fill=ALERT)
return
cur = d.get("current", {})
temp = cur.get("temperature_2m", 0)
temp_color = RED if temp >= 28 else ORANGE if temp >= 20 else BLUE if temp <= 5 else FG
if is_small(w, h):
# mini: nur Big Temp + ggf UV
temp_str = f"{int(temp + 0.5)}°"
font = fit_font(draw, temp_str, fonts, w - 2 * pad, int(h * 0.6))
centered_text(draw, temp_str, x, y, w, int(h * 0.6), font, temp_color)
if self.cfg("show_uv"):
uv_int = int(cur.get("uv_index", 0) + 0.5)
uv_str = f"UV {uv_int}"
font_uv = fit_font(draw, uv_str, fonts, w - 2 * pad, h // 4)
centered_text(draw, uv_str, x, y + int(h * 0.62), w, h // 4, font_uv, FG)
return
if is_wide(w, h):
# Wide strip: Temp + Humidity + Wind
temp_str = f"{int(temp + 0.5)}°"
font = fit_font(draw, temp_str, fonts, w // 3 - 2 * pad, h - 2 * pad)
centered_text(draw, temp_str, x, y, w // 3, h, font, temp_color)
hum = cur.get("relative_humidity_2m", 0)
ws = cur.get("wind_speed_10m", 0)
wd = cur.get("wind_direction_10m", 0)
stats = f"Hum {hum}%\nWind {ws} km/h\nDir {wd}°"
font_s = fit_font(draw, "Hum 100%", fonts, w - w // 3 - 2 * pad, h // 3)
y_off = y + pad
for line in stats.split("\n"):
centered_text(draw, line, x + w // 3, y_off, w - w // 3, h // 3, font_s, FG)
y_off += h // 3
return
# Standard: Big temp, info, forecast
# Header
draw.text((x + pad, y + pad), "WETTER", font=fonts.get("24", fonts.get("20")), fill=INFO)
# Big temp
temp_str = f"{int(temp + 0.5)}°"
font = fit_font(draw, temp_str, fonts, w // 2 - 2 * pad, int(h * 0.6))
draw.text((x + pad, y + 50), temp_str, font=font, fill=temp_color)
# Stats right
hum = cur.get("relative_humidity_2m", 0)
ws = cur.get("wind_speed_10m", 0)
wd = cur.get("wind_direction_10m", 0)
stats_y = y + 60
for line, col in [(f"Hum {hum}%", BLUE), (f"Wind {ws} km/h", INFO), (f"Dir {wd}°", FG)]:
font_l = fit_font(draw, line, fonts, w // 2 - 2 * pad, 24)
draw.text((x + w // 2 + pad, stats_y), line, font=font_l, fill=col)
stats_y += 30
# UV box top right
if self.cfg("show_uv"):
uv = cur.get("uv_index", 0)
uv_int = int(uv + 0.5)
box_w = min(80, w // 5)
box_x = x + w - box_w - pad
box_y = y + pad
box_h = 36
if uv_int >= 6:
draw.rectangle((box_x, box_y, box_x + box_w, box_y + box_h), fill=ALERT)
draw.text((box_x + 6, box_y + 4), f"UV {uv_int}", font=fonts.get("20", fonts.get("16")), fill=BG)
else:
draw.rectangle((box_x, box_y, box_x + box_w, box_y + box_h), outline=FG, width=2)
draw.text((box_x + 6, box_y + 4), f"UV {uv_int}", font=fonts.get("20", fonts.get("16")), fill=FG)
# Compass optional
if self.cfg("show_wind_compass") and w >= 280 and h >= 280:
cr = min(60, w // 8, h // 6)
cx = x + w - cr - pad
cy = y + h - cr - pad
self._draw_compass(draw, cx, cy, cr, wd, fonts)
# Forecast bottom
fc_h = max(1, min(int(self.cfg("show_forecast_hours", 4)), 8))
times = d.get("hourly", {}).get("time", [])
temps = d.get("hourly", {}).get("temperature_2m", [])
if times and h > 200:
cur_iso = datetime.now().strftime("%Y-%m-%dT%H:00")
try: idx0 = times.index(cur_iso)
except ValueError: idx0 = 0
forecast_y = y + h - 70
# Forecast-Strip nimmt 60% der Slot-Breite, von links
strip_w = int(w * 0.6)
cell_w = strip_w // fc_h
for i in range(fc_h):
j = idx0 + i + 1
if j >= len(times): break
hh = times[j].split("T")[1][:5]
tt = int(temps[j] + 0.5)
color = ORANGE if tt >= 25 else BLUE if tt <= 5 else FG
cx2 = x + pad + i * cell_w
draw.text((cx2, forecast_y), hh, font=fonts.get("20", fonts.get("16")), fill=FG)
font_t = fit_font(draw, f"{tt}°", fonts, cell_w - 4, 40)
draw.text((cx2, forecast_y + 22), f"{tt}°", font=font_t, fill=color)