Sync to Pi: alle Features die live deployed sind
Aus dem Backup und Live-Pull vom Pi (10.11.3.144): - dashboard.py: Grid-Linien nur im freien Hintergrund (nicht durch Widgets) - templates/index.html: komplett redesigned mit Sidebar + Topbar + Toast + Modal - plugins/clock.py: responsive Layout (1x1 bis 4x4) - plugins/system.py, weather.py, minimax.py: mit Threshold-Bars und Color-Variants - plugins/base.py: NEU — fetch_with_retry Helper (3x retry mit backoff) + render_error_banner für fehlgeschlagene API-Plugins (grosses rotes "!" Icon mit Plugin-Name und Fehler statt Crash) Cleanup: Helfer-Chaos (renderer.py/2/3, design_a/b/c.html, clock_classic.py, 23x clock_*.png, alte test_*.py) wurde bereits im vorherigen Commit entfernt. Co-Authored-By: Hermes <noreply@hermes.local>
This commit is contained in:
co-authored by
Hermes
parent
af9a99a379
commit
1f142f5245
@@ -0,0 +1 @@
|
||||
# Plugins package
|
||||
@@ -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
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Clock-Plugin: Uhrzeit + Datum, responsive fuer alle Slot-Groessen.
|
||||
|
||||
Layout-Strategie pro Slot-Groesse:
|
||||
is_small (1x1 ~200x120): nur Big-Time zentriert
|
||||
is_wide (4x1/2x1): Time links gross, Datum rechts klein
|
||||
sonst (1x2/2x2/4x4): Datum oben klein, Big-Time mittig, Wochentag unten
|
||||
"""
|
||||
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, BLUE, OK,
|
||||
measure, fit_font, centered_text,
|
||||
is_small, is_wide, is_tall)
|
||||
|
||||
|
||||
class Widget(Widget):
|
||||
name = "clock"
|
||||
label = "Uhrzeit / Datum"
|
||||
description = "Aktuelle Uhrzeit und Datum. Responsiv fuer 1x1 bis 4x4 Slots."
|
||||
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": ["info", "blue", "accent", "ok", "warn", "alert"],
|
||||
"default": "info",
|
||||
"help": "Farbe fuer Datum und Wochentag"},
|
||||
{"key": "weekday_color", "label": "Wochentag-Farbe",
|
||||
"type": "select",
|
||||
"choices": ["accent", "blue", "info", "ok", "warn", "alert"],
|
||||
"default": "accent"},
|
||||
]
|
||||
default_config = {
|
||||
"format_24h": True, "show_seconds": False,
|
||||
"show_date": True, "show_weekday": True,
|
||||
"accent_color": "info", "weekday_color": "accent",
|
||||
}
|
||||
|
||||
def fetch(self):
|
||||
return {}
|
||||
|
||||
def render(self, draw, fonts, x, y, w, h):
|
||||
from palette import fill_for
|
||||
now = datetime.now()
|
||||
pad = 10
|
||||
|
||||
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()
|
||||
|
||||
accent = fill_for(self.cfg("accent_color", "info"))
|
||||
weekday_c = fill_for(self.cfg("weekday_color", "accent"))
|
||||
|
||||
# === Mini-Modus: 1x1 oder sehr klein ===
|
||||
if is_small(w, h):
|
||||
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
|
||||
|
||||
# === Wide-Strip-Modus: 4x1 oder 2x1 ===
|
||||
if is_wide(w, h):
|
||||
# Big Time links
|
||||
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)
|
||||
# Datum + Wochentag rechts
|
||||
if self.cfg("show_date"):
|
||||
font_date = fit_font(draw, date_str, fonts, w // 2 - 2 * pad, h // 2 - 4)
|
||||
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 - 4)
|
||||
centered_text(draw, day_str, x + w // 2, y + h // 2, w // 2, h // 2, font_day, weekday_c)
|
||||
return
|
||||
|
||||
# === Tall-Modus: schmal aber hoch ===
|
||||
if is_tall(w, h):
|
||||
# Vertikal: Time oben, dann Date, dann Weekday
|
||||
font_time = fit_font(draw, time_str, fonts, w - 2 * pad, int(h * 0.45))
|
||||
centered_text(draw, time_str, x, y, w, int(h * 0.45), font_time, FG)
|
||||
if self.cfg("show_date"):
|
||||
font_date = fit_font(draw, date_str, fonts, w - 2 * pad, int(h * 0.25))
|
||||
centered_text(draw, date_str, x, y + int(h * 0.45), w, int(h * 0.25), font_date, accent)
|
||||
if self.cfg("show_weekday"):
|
||||
font_day = fit_font(draw, day_str, fonts, w - 2 * pad, int(h * 0.20))
|
||||
centered_text(draw, day_str, x, y + int(h * 0.70), w, int(h * 0.20), font_day, weekday_c)
|
||||
return
|
||||
|
||||
# === Standard-Modus: 2x2 oder groesser, quadratisch oder landscape ===
|
||||
# Drei Sektionen: Date (15%) | Time (60%) | Weekday (25%)
|
||||
date_h = int(h * 0.18) if self.cfg("show_date") else 0
|
||||
weekday_h = int(h * 0.18) if self.cfg("show_weekday") else 0
|
||||
time_h = h - date_h - weekday_h
|
||||
cur_y = y
|
||||
|
||||
if self.cfg("show_date"):
|
||||
font_date = fit_font(draw, date_str, fonts, w - 2 * pad, date_h - 4)
|
||||
centered_text(draw, date_str, x, cur_y, w, date_h, font_date, accent)
|
||||
cur_y += date_h
|
||||
|
||||
time_x = x
|
||||
time_w = w
|
||||
# Wenn weekday und date beide aus: Time zentriert ueber alles
|
||||
if not (self.cfg("show_date") or self.cfg("show_weekday")):
|
||||
time_y = y
|
||||
else:
|
||||
time_y = cur_y
|
||||
font_time = fit_font(draw, time_str, fonts, time_w - 2 * pad, time_h - 4)
|
||||
centered_text(draw, time_str, time_x, time_y, time_w, time_h, font_time, FG)
|
||||
cur_y += time_h
|
||||
|
||||
if self.cfg("show_weekday"):
|
||||
font_day = fit_font(draw, day_str, fonts, w - 2 * pad, weekday_h - 4)
|
||||
centered_text(draw, day_str, x, cur_y, w, weekday_h, font_day, weekday_c)
|
||||
@@ -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)
|
||||
@@ -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")))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user