commit 28124c5617243b27ee29df5d0accdfba0d98066a Author: ki Date: Wed Aug 26 14:12:43 2026 +0400 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d450d52 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Admin-UI Credentials. Default im Code ist admin/admin — bitte ändern! +# Nach dem Ändern: admin.py neu starten. +EPAPER_ADMIN_USER=admin +EPAPER_ADMIN_PASSWORD=admin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e3c16c --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +__pycache__/ +*.pyc +*.pyo +*.png +snap_*.png +render_*.png +sizes_*.png +admin_snapshot*.png +minimax_*.png +*.log +.DS_Store +.env +config.json +*.service diff --git a/50-allow-networkmanager.rules b/50-allow-networkmanager.rules new file mode 100644 index 0000000..387faa3 --- /dev/null +++ b/50-allow-networkmanager.rules @@ -0,0 +1,9 @@ +// Erlaubt koptikp (uid 1000) NetworkManager ohne Passwort zu steuern. +// Wir prüfen explizit auf uid, weil subject.local/active für SSH-Sessions +// oft nicht das tut, was man erwartet (logind behandelt SSH-Logins anders). +polkit.addRule(function(action, subject) { + if (action.id.indexOf("org.freedesktop.NetworkManager.") === 0 && + subject.user === "koptikp") { + return polkit.Result.YES; + } +}); diff --git a/README.md b/README.md new file mode 100644 index 0000000..c714f1c --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# 7.3" ePaper Dashboard + +Plugin-basiertes Dashboard für Waveshare 7.3-inch ACeP 7-Color e-Paper (F) HAT auf einem Raspberry Pi 4. + +## Architektur + +- **Renderer (`dashboard.py`)**: Lädt `config.json`, instanziiert Plugins, refresht das Display alle `refresh_interval_s` Sekunden (≥180 empfohlen). Reagiert auf Live-Trigger via Unix-Socket. +- **Web-Admin (`admin.py`)**: HTTP Basic Auth, konfiguriert Slots, Plugins und Secrets im Browser. Triggert Live-Refresh. +- **Plugins (`plugins/`)**: Jedes Widget ist ein Python-Modul mit `Widget`-Klasse (siehe `plugins/base.py`). Eigenes Plugin = eine Datei in `plugins/`. +- **Templates (`templates/index.html`)**: Single-Page-UI, auto-reload-fähig. + +## Setup + +```bash +# Auf dem Pi: +sudo apt install python3-pil python3-numpy python3-flask git +git clone # oder rsync von deinem Dev-Rechner +cd port/ +# SPI in /boot/firmware/config.txt aktivieren (dtparam=spi=on) +# Reboot +python3 admin.py & +python3 dashboard.py & +``` + +Web-UI: +Default Login: `admin` / `admin` — bitte `EPAPER_ADMIN_PASSWORD` in `.env` setzen. + +## Plugin schreiben + +Eine Datei `plugins/my_widget.py`: + +```python +from plugins.base import Widget +from palette import FG, INFO + +class Widget(Widget): + name = "my_widget" + label = "Mein Widget" + description = "Zeigt etwas Nützliches" + category = "info" + + config_schema = [ + {"key": "api_key", "label": "API Key", "type": "secret"}, + {"key": "interval", "label": "Intervall (Sekunden)", "type": "int", "default": 60}, + {"key": "show_extra", "label": "Extra anzeigen", "type": "bool", "default": True}, + ] + default_config = {"api_key": "", "interval": 60, "show_extra": True} + + def fetch(self): + # Daten holen (API call, etc.) + return {"value": 42} + + def render(self, draw, fonts, x, y, w, h): + # In den Slot zeichnen + d = self.fetch() + draw.text((x + 10, y + 10), f"Value: {d['value']}", font=fonts["28"], fill=FG) +``` + +**Feld-Typen**: `string`, `int`, `float`, `bool`, `secret` (write-only), `select` (mit `choices`), `lat_lon`. + +**Palette** (aus `palette.py`): `BLACK, WHITE, GREEN, BLUE, RED, YELLOW, ORANGE` + semantisch `FG, BG, OK, WARN, ALERT, INFO, ACCENT`. + +## Mitgelieferte Plugins + +- `clock` — Uhrzeit / Datum +- `weather` — Open-Meteo Wetter (kein API-Key) +- `system` — Pi CPU/RAM/Uptime +- `hello` — Demo +- `spotify` — Last.fm Scrobble +- `strava` — Strava Aktivitäten +- `gmail` — Gmail Unread + +## Einschränkungen + +- **Full Refresh only**: ACeP-Displays vertragen keine schnellen Partial-Refreshes (Verschleiß). Das Hersteller-empfohlene Minimum ist 180s. +- **Kein Live-Websocket**: Snapshot wird per Refresh-Now-Button ausgelöst. Bei Bedarf ergänzbar. diff --git a/RECOVERY.md b/RECOVERY.md new file mode 100644 index 0000000..e5ad183 --- /dev/null +++ b/RECOVERY.md @@ -0,0 +1,41 @@ +# Recovery wenn der Pi nicht mehr per SSH erreichbar ist + +## Was passiert ist +- Auf dem Pi lief ein WLAN mit SSID `IoT`, Profil-Name `netplan-wlan0-IoT` +- Recovery-AP-Test über `nmcli device wifi hotspot` hat einen WLAN-Hotspot `epaper-test` gestartet +- Dies hat wahrscheinlich die WLAN-Verbindung zum Heimnetz unterbrochen +- SSH-Connect zum Pi schlägt jetzt fehl (Timeout) + +## Sofortige Hilfe + +### Option A: Strom-Reset +1. Pi vom Strom trennen +2. 10 Sekunden warten +3. Strom wieder rein +4. Pi bootet → NetworkManager verbindet sich automatisch mit `IoT` (sofern in Reichweite) +5. Nach ~60 Sekunden: `ssh koptikp@10.11.3.144` sollte wieder gehen + +### Option B: Recovery-Hotspot +Falls Strom-Reset nicht hilft (z.B. weil Profil `netplan-wlan0-IoT` kaputt ist): +1. Auf dem Laptop WLAN-Liste scannen +2. `epaper-recovery` suchen (Passwort `recovery1234`) — falls der Pi im Recovery-Modus ist + oder +3. `epaper-test` suchen (Passwort `test1234`) — falls der Test-Hotspot noch läuft +4. Verbinden, dann im Browser http://10.42.0.1:8080 +5. Bei Auth-Prompt: `admin:changeme123` (oder `admin:admin`) +6. Im Netzwerk-Panel: WLAN neu konfigurieren oder "Force AP Mode" / "Disconnect" + +### Option C: Tastatur + Monitor +1. HDMI + USB-Tastatur an den Pi +2. Anmelden als `koptikp` +3. `sudo nmcli connection show` → sehen was aktiv ist +4. `sudo nmcli connection down epaper-test` (oder welcher Müll-Profile da sind) +5. `sudo nmcli connection delete epaper-test` +6. `sudo nmcli connection up netplan-wlan0-IoT` +7. `sudo reboot` + +## Was ich anders machen würde +- **Hotspot-Tests nie** auf einem produktiven Pi laufen lassen, ohne physischen Zugang als Fallback zu haben +- Watchdog-Code war korrekt; das Problem ist die PolicyKit-Konfiguration +- Polkit-Restart nach Rule-Update: besser nur `polkitd` SIGHUP schicken, nicht systemctl restart + (das hat in dieser Session offenbar den Polkit-Daemon in einem inkonsistenten Zustand hinterlassen) diff --git a/admin.py b/admin.py new file mode 100644 index 0000000..ecfb45f --- /dev/null +++ b/admin.py @@ -0,0 +1,545 @@ +"""Flask-basierte Admin-UI für das 7.3" Dashboard. + +Routes: + GET / → Grid-Builder: Slots, Plugins, Refresh-Intervall + POST /config → Speichert config.json (Slot-Belegung + Intervall) + POST /plugins/ → Speichert Plugin-spezifische Config + POST /refresh → Triggert sofortigen Refresh via IPC + GET /snapshot.png → Letztes gerendertes Bild (PNG) + GET /plugins.json → Liste aller verfügbaren Plugins + Schemas (für UI) +""" +from __future__ import annotations +import os, sys, json, base64, io, time +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +from flask import Flask, request, redirect, url_for, render_template, jsonify, Response, abort + +import dashboard as dashboard_mod +from plugins.base import all_widget_classes +import network_watchdog as net +import layout as layout_mod + +app = Flask(__name__) + +# ============================================================================ +# Auth (HTTP Basic) +# ============================================================================ +def _load_auth(): + """Liest admin_user / admin_password aus .env. + + Reihenfolge (erstes gewinnt): + 1. Umgebungsvariable EPAPER_ADMIN_USER / EPAPER_ADMIN_PASSWORD (nützlich für Container/CI) + 2. /.env (eigene Datei, wird bevorzugt) + 3. ~/.hermes/.env (global) + Default: admin / admin — explizit nur für first-boot, damit der Service + überhaupt erreichbar ist. Empfehlung: nach erstem Start .env anlegen. + """ + user, pw = "admin", "admin" + env_user = os.environ.get("EPAPER_ADMIN_USER") + env_pw = os.environ.get("EPAPER_ADMIN_PASSWORD") + if env_user: user = env_user + if env_pw: pw = env_pw + for envfile in [HERE / ".env", Path.home() / ".hermes" / ".env"]: + if not envfile.exists(): + continue + try: + for line in envfile.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip(); v = v.strip().strip('"').strip("'") + if k == "EPAPER_ADMIN_USER" and v: user = v + if k == "EPAPER_ADMIN_PASSWORD" and v: pw = v + except Exception: + pass + return user, pw + + +def check_auth(): + user, pw = _load_auth() + auth = request.headers.get("Authorization") + if auth and auth.startswith("Basic "): + try: + decoded = base64.b64decode(auth[6:]).decode() + u, p = decoded.split(":", 1) + if u == user and p == pw: + return True + except Exception: + pass + return False + + +def require_auth(): + if not check_auth(): + return Response( + "Authentication required", + 401, + {"WWW-Authenticate": 'Basic realm="epaper-dashboard"'}, + ) + return None + + +# ============================================================================ +# Routes +# ============================================================================ +@app.route("/") +def index(): + a = require_auth() + if a: return a + + cfg = dashboard_mod.load_config() + slots = cfg.get("slots", []) # legacy + classes = dashboard_mod.get_widget_classes() + widgets_meta = [] + for name, cls in classes.items(): + widgets_meta.append({ + "name": name, + "label": cls.label, + "description": cls.description, + "category": cls.category, + "schema": cls.config_schema, + "defaults": cls.default_config, + "default_size": layout_mod.auto_size_for_plugin(name), + }) + widgets_meta.sort(key=lambda w: w["label"]) + + # v2-Layout + layout_items = cfg.get("layout", {}).get("items", []) + plugin_configs = cfg.get("plugin_configs", {}) + grid = cfg.get("layout", {}).get("grid", {"cols": 4, "rows": 4}) + + # Slot n -> (plugin_name, plugin_config_dict) + slot_plugins = [(it.get("plugin", ""), plugin_configs.get(it.get("plugin", ""), {})) + for it in layout_items] + + return render_template("index.html", + # legacy + slots=slots, + widgets=widgets_meta, + refresh_interval=cfg.get("refresh_interval_s", 180), + categories=sorted(set(w["category"] for w in widgets_meta)), + now=int(time.time()), + # v2 + layout_items=layout_items, + plugin_configs=plugin_configs, + grid=grid, + size_presets=layout_mod.SIZE_PRESETS, + slot_plugins=slot_plugins) + + +@app.route("/config", methods=["POST"]) +def save_config(): + a = require_auth() + if a: return a + + cfg = dashboard_mod.load_config() + # refresh interval + if "refresh_interval_s" in request.form: + try: + cfg["refresh_interval_s"] = max(30, int(request.form["refresh_interval_s"])) + except (TypeError, ValueError): + pass + + # slot belegung: pro slot "slot__plugin" + slots = cfg.get("slots", []) + for i in range(dashboard_mod.SLOTS): + plugin_name = request.form.get(f"slot_{i}_plugin", "").strip() + if plugin_name and slots[i]["plugin"] != plugin_name: + slots[i]["plugin"] = plugin_name + slots[i]["config"] = {} # reset auf plugin defaults + cfg["slots"] = slots + dashboard_mod.save_config(cfg) + return redirect(url_for("index")) + + +@app.route("/plugins/", methods=["POST"]) +def save_plugin_config(idx): + a = require_auth() + if a: return a + cfg = dashboard_mod.load_config() + if idx < 0 or idx >= dashboard_mod.SLOTS: + abort(400) + slot = cfg["slots"][idx] + classes = dashboard_mod.get_widget_classes() + cls = classes.get(slot["plugin"]) + if cls is None: + abort(400, "unknown plugin") + new_cfg = dict(slot.get("config", {})) + for field in cls.config_schema: + key = field["key"] + form_val = request.form.get(f"slot_{idx}_{key}") + ftype = field.get("type", "string") + if ftype == "secret": + # Secret-Felder: nur überschreiben wenn Form nicht leer. + # Damit kann der User ein Secret löschen, indem er explizit leeres + # Feld abschickt; ansonsten bleibt der alte Wert erhalten. + if form_val is None: + continue + if form_val == "": + new_cfg[key] = "" + elif form_val == "__UNSET__": + # wird vom UI nie geschickt; reserved für "Secret entfernen" + new_cfg.pop(key, None) + else: + new_cfg[key] = form_val + continue + if form_val is None: + continue + try: + if ftype == "int": + new_cfg[key] = int(form_val) + elif ftype == "float": + new_cfg[key] = float(form_val) + elif ftype == "bool": + new_cfg[key] = (form_val.lower() in ("1", "true", "yes", "on")) + else: + new_cfg[key] = form_val + except (TypeError, ValueError): + new_cfg[key] = form_val + slot["config"] = new_cfg + cfg["slots"][idx] = slot + dashboard_mod.save_config(cfg) + return redirect(url_for("index")) + + +@app.route("/api/layout", methods=["GET", "POST"]) +def api_layout(): + a = require_auth() + if a: return a + cfg = dashboard_mod.load_config() + if request.method == "GET": + return jsonify({ + "grid": cfg.get("layout", {}).get("grid", {"cols": 4, "rows": 4}), + "items": cfg.get("layout", {}).get("items", []), + "overlaps": [list(p) for p in layout_mod.find_overlaps( + [layout_mod.Item.from_dict(d) for d in cfg.get("layout", {}).get("items", [])] + )], + "out_of_bounds": layout_mod.find_out_of_bounds( + [layout_mod.Item.from_dict(d) for d in cfg.get("layout", {}).get("items", [])] + ), + }) + # POST: body form-data with fields per item OR full JSON replacement + data = request.get_json(silent=True) + if data and "items" in data: + items_raw = data["items"] + new_items = [] + for it in items_raw: + try: + item = layout_mod.Item.from_dict(it) + new_items.append(item.to_dict()) + except Exception as e: + return jsonify({"ok": False, "error": f"invalid item: {e}"}), 400 + cfg.setdefault("layout", {})["items"] = new_items + dashboard_mod.save_config(cfg) + # Auto-pack wenn gewünscht + if request.args.get("auto_pack") == "1": + new_items = layout_mod.pack([layout_mod.Item.from_dict(d) for d in new_items]) + cfg["layout"]["items"] = [it.to_dict() for it in new_items] + dashboard_mod.save_config(cfg) + return jsonify({"ok": True, "items": cfg["layout"]["items"]}) + + # Form-encoded: items[item_id][plugin/x/y/w/h] + form = request.form + new_items = [] + # Sammle alle item_ids + item_ids = set() + for k in form.keys(): + if k.startswith("items[") and "][" in k: + iid = k.split("items[")[1].split("]")[0] + item_ids.add(iid) + for iid in item_ids: + try: + item = { + "id": iid, + "plugin": form.get(f"items[{iid}][plugin]", ""), + "x": int(form.get(f"items[{iid}][x]", 0)), + "y": int(form.get(f"items[{iid}][y]", 0)), + "w": int(form.get(f"items[{iid}][w]", 1)), + "h": int(form.get(f"items[{iid}][h]", 1)), + } + new_items.append(item) + except Exception as e: + return jsonify({"ok": False, "error": f"item {iid}: {e}"}), 400 + cfg.setdefault("layout", {})["items"] = new_items + dashboard_mod.save_config(cfg) + return jsonify({"ok": True, "items": new_items}) + + +@app.route("/api/layout/add", methods=["POST"]) +def api_layout_add(): + """Fügt ein neues Item hinzu und packt automatisch.""" + a = require_auth() + if a: return a + plugin = request.form.get("plugin", "hello").strip() + cfg = dashboard_mod.load_config() + classes = dashboard_mod.get_widget_classes() + cls = classes.get(plugin) + if cls is None: + return jsonify({"ok": False, "error": "unknown plugin"}), 400 + w, h = layout_mod.auto_size_for_plugin(plugin) + # Override mit user-input wenn vorhanden + try: w = int(request.form.get("w", w)) + except: pass + try: h = int(request.form.get("h", h)) + except: pass + import secrets + new_id = secrets.token_hex(4) + items = cfg.setdefault("layout", {}).setdefault("items", []) + new_item = layout_mod.Item(new_id, plugin, 0, 0, w, h).to_dict() + items.append(new_item) + # Pack alle (inkl. neue) + packed = layout_mod.pack([layout_mod.Item.from_dict(d) for d in items]) + cfg["layout"]["items"] = [it.to_dict() for it in packed] + dashboard_mod.save_config(cfg) + return jsonify({"ok": True, "id": new_id, "items": cfg["layout"]["items"]}) + + +@app.route("/api/layout/item/", methods=["DELETE", "PATCH"]) +def api_layout_item(iid): + a = require_auth() + if a: return a + cfg = dashboard_mod.load_config() + items = cfg.setdefault("layout", {}).setdefault("items", []) + if request.method == "DELETE": + items = [it for it in items if it.get("id") != iid] + cfg["layout"]["items"] = items + dashboard_mod.save_config(cfg) + return jsonify({"ok": True}) + # PATCH: update single fields + data = request.get_json(silent=True) or {} + for it in items: + if it.get("id") == iid: + for k in ("plugin", "x", "y", "w", "h"): + if k in data: + if k in ("x", "y", "w", "h"): + try: it[k] = int(data[k]) + except: pass + else: + it[k] = data[k] + dashboard_mod.save_config(cfg) + return jsonify({"ok": True, "items": items}) + + +@app.route("/api/layout/pack", methods=["POST"]) +def api_layout_pack(): + """Auto-pack alle Items in den 4x4 grid.""" + a = require_auth() + if a: return a + cfg = dashboard_mod.load_config() + items = [layout_mod.Item.from_dict(d) for d in cfg.get("layout", {}).get("items", [])] + packed = layout_mod.pack(items) + cfg["layout"]["items"] = [it.to_dict() for it in packed] + dashboard_mod.save_config(cfg) + return jsonify({"ok": True, "items": cfg["layout"]["items"]}) + + +@app.route("/api/plugin_config/", methods=["GET", "POST"]) +def api_plugin_config(plugin_name): + """Plugin-spezifische Config (separat vom Layout).""" + a = require_auth() + if a: return a + classes = dashboard_mod.get_widget_classes() + cls = classes.get(plugin_name) + if cls is None: + return jsonify({"ok": False, "error": "unknown plugin"}), 400 + cfg = dashboard_mod.load_config() + if request.method == "GET": + return jsonify({ + "plugin": plugin_name, + "label": cls.label, + "schema": cls.config_schema, + "config": cfg.get("plugin_configs", {}).get(plugin_name, {}), + }) + # POST: update config for this plugin + new_cfg = {} + for field in cls.config_schema: + key = field["key"] + form_val = request.form.get(f"config_{key}") + ftype = field.get("type", "string") + if form_val is None: + continue + if ftype == "secret": + # Nur überschreiben wenn nicht leer + existing = cfg.get("plugin_configs", {}).get(plugin_name, {}).get(key, "") + if form_val == "": + new_cfg[key] = "" + elif form_val == "__UNSET__": + continue + else: + new_cfg[key] = form_val + continue + try: + if ftype == "int": + new_cfg[key] = int(form_val) + elif ftype == "float": + new_cfg[key] = float(form_val) + elif ftype == "bool": + new_cfg[key] = (form_val.lower() in ("1", "true", "yes", "on")) + else: + new_cfg[key] = form_val + except (TypeError, ValueError): + new_cfg[key] = form_val + cfg.setdefault("plugin_configs", {})[plugin_name] = new_cfg + dashboard_mod.save_config(cfg) + return jsonify({"ok": True, "plugin": plugin_name, "config": new_cfg}) + + +@app.route("/refresh", methods=["POST"]) +def refresh_now(): + a = require_auth() + if a: return a + result = dashboard_mod.send_ipc("refresh") + return jsonify({"status": "ok", "ipc_response": result}) + + +@app.route("/snapshot.png") +def snapshot_png(): + a = require_auth() + if a: return a + # Snapshot aus dem laufenden Renderer via IPC anfordern + # Wir machen das nicht über IPC (Renderer schreibt Datei), sondern rendern direkt hier + # für Preview-Zwecke — billiger und deterministisch. + try: + d = dashboard_mod.Dashboard() + # nicht reload() — würde live-config nutzen, ok + img = d.render_once() + buf = io.BytesIO() + img.save(buf, format="PNG") + return Response(buf.getvalue(), mimetype="image/png") + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/plugins.json") +def plugins_json(): + a = require_auth() + if a: return a + out = [] + for cls in all_widget_classes(): + out.append({ + "name": cls.name, + "label": cls.label, + "description": cls.description, + "category": cls.category, + "schema": cls.config_schema, + "defaults": cls.default_config, + }) + return jsonify(out) + + +@app.route("/status.json") +def status_json(): + a = require_auth() + if a: return a + cfg = dashboard_mod.load_config() + return jsonify({ + "config_path": str(dashboard_mod.CONFIG_PATH), + "refresh_interval_s": cfg.get("refresh_interval_s"), + "slots": [{"plugin": s.get("plugin"), "config_keys": list(s.get("config", {}).keys())} for s in cfg.get("slots", [])], + "socket_path": dashboard_mod.SOCKET_PATH, + }) + + +# ============================================================================ +# Network / WiFi Management +# ============================================================================ +@app.route("/api/network/status") +def api_net_status(): + a = require_auth() + if a: return a + return jsonify(net.get_watchdog().get_state()) + + +@app.route("/api/network/scan") +def api_net_scan(): + a = require_auth() + if a: return a + return jsonify({"networks": net.list_wifi_networks()}) + + +@app.route("/api/network/saved") +def api_net_saved(): + a = require_auth() + if a: return a + return jsonify({"saved": net.list_saved_connections()}) + + +@app.route("/api/network/connect", methods=["POST"]) +def api_net_connect(): + a = require_auth() + if a: return a + ssid = request.form.get("ssid", "").strip() + pwd = request.form.get("password", "").strip() + security = request.form.get("security", "wpa-psk").strip() + if not ssid: + return jsonify({"ok": False, "error": "ssid is required"}), 400 + ok, msg = net.save_wifi(ssid, pwd, security) + if not ok: + return jsonify({"ok": False, "error": msg}), 500 + # Falls aktuell im AP-Modus: AP aus + if net.is_ap_active(): + net.stop_ap() + ok, msg = net.connect_wifi(msg) # msg contains connection name + return jsonify({"ok": ok, "message": msg, "ssid": ssid}) + + +@app.route("/api/network/reconnect", methods=["POST"]) +def api_net_reconnect(): + a = require_auth() + if a: return a + saved = net.list_saved_connections() + if not saved: + return jsonify({"ok": False, "error": "kein gespeichertes WLAN"}), 400 + ok, msg = net.connect_wifi(saved[0]["name"]) + return jsonify({"ok": ok, "message": msg}) + + +@app.route("/api/network/disconnect", methods=["POST"]) +def api_net_disconnect(): + a = require_auth() + if a: return a + ok, msg = net.disconnect_wifi() + return jsonify({"ok": ok, "message": msg}) + + +@app.route("/api/network/ap/start", methods=["POST"]) +def api_net_ap_start(): + a = require_auth() + if a: return a + ok, msg = net.start_ap() + return jsonify({"ok": ok, "message": msg}) + + +@app.route("/api/network/ap/stop", methods=["POST"]) +def api_net_ap_stop(): + a = require_auth() + if a: return a + ok, msg = net.stop_ap() + return jsonify({"ok": ok, "message": msg}) + + +@app.route("/api/network/forget", methods=["POST"]) +def api_net_forget(): + a = require_auth() + if a: return a + name = request.form.get("name", "").strip() + if not name: + return jsonify({"ok": False, "error": "name required"}), 400 + rc, out, err = net._nm(["connection", "delete", name], timeout=10) + return jsonify({"ok": rc == 0, "message": out or err}) + + +def main(): + # Wenn direkt ausgeführt: eigenen Renderer starten ist Sache des Run-Skripts. + # Wir nehmen einfach Port 8080. + host = os.environ.get("EPAPER_ADMIN_HOST", "0.0.0.0") + port = int(os.environ.get("EPAPER_ADMIN_PORT", "8080")) + app.run(host=host, port=port, debug=False, use_reloader=False) + + +if __name__ == "__main__": + main() diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..407c064 --- /dev/null +++ b/config.example.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "refresh_interval_s": 180, + "slots": [ + {"plugin": "clock", "config": {}}, + {"plugin": "weather", "config": {"location": "52.52,13.41", "show_uv": true, "show_forecast_hours": 4}}, + {"plugin": "system", "config": {"show_uptime": true, "show_load": true}}, + {"plugin": "hello", "config": {"text": "Edit me in the web UI!", "color": "accent", "size": 40}} + ] +} diff --git a/dashboard.py b/dashboard.py new file mode 100644 index 0000000..97d657f --- /dev/null +++ b/dashboard.py @@ -0,0 +1,449 @@ +"""Layout, Slot-Geometrie, Refresh-Loop. + +Lädt config.json, instanziiert Plugins, läuft Refresh-Loop mit ≥refresh_interval_s +zwischen Full-Refreshes. Reagiert auf Live-Trigger via Unix-Socket. +""" +from __future__ import annotations +import os, sys, time, json, math, threading, signal, socket, logging +from datetime import datetime +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +from PIL import Image, ImageDraw, ImageFont +from palette import BG, FG, OK +from plugins.base import all_widget_classes, Widget +from layout import Item, GRID_COLS, GRID_ROWS, CELL_W, CELL_H, DISPLAY_W as LAYOUT_DISPLAY_W, pack, find_overlaps, find_out_of_bounds + +import importlib, pkgutil +import plugins as _plugins_pkg # ensure package is importable + + +# ============================================================================ +# Config helpers +# ============================================================================ +CONFIG_PATH = HERE / "config.json" +LOG_PATH = HERE / "dashboard.log" +SOCKET_PATH = "/tmp/epaper-dashboard.sock" + +DEFAULT_CONFIG = { + "version": 1, + "refresh_interval_s": 180, + "slots": [ + {"plugin": "clock", "config": {}}, + {"plugin": "weather", "config": {}}, + {"plugin": "system", "config": {}}, + {"plugin": "hello", "config": {}}, + ], +} + +# Display dimensions are imported from layout module (single source of truth) +DISPLAY_W = LAYOUT_DISPLAY_W +DISPLAY_H = GRID_ROWS * CELL_H +# Legacy-v1 GRID bleibt für Migration +LEGACY_SLOTS = 4 + + +def migrate_v1_to_v2(v1: dict) -> dict: + """Konvertiert altes Layout (slots[]) in v2 (layout.items + plugin_configs).""" + layout_items = [] + plugin_configs = {} + # Positioniere quadratisch im 4x4 grid, fall-back auf legacy 2x2 + # Hier nutzen wir das alte 2x2 mapping und packen dann: + # slot 0 -> (0,0) 2x2 + # slot 1 -> (2,0) 2x2 + # slot 2 -> (0,2) 2x2 + # slot 3 -> (2,2) 2x2 + legacy_pos = [(0, 0), (2, 0), (0, 2), (2, 2)] + for i, slot in enumerate(v1.get("slots", [])): + if i >= LEGACY_SLOTS: + break + plugin = slot.get("plugin", "") + cfg = slot.get("config", {}) + if not plugin: + continue + x, y = legacy_pos[i] + item_id = f"migrated_{i}" + layout_items.append({ + "id": item_id, + "plugin": plugin, + "x": x, "y": y, "w": 2, "h": 2, + }) + # Plugin-Config landet in plugin_configs (ohne slot-id, geteilt pro plugin-name) + if cfg and plugin not in plugin_configs: + plugin_configs[plugin] = cfg + return { + "version": 2, + "refresh_interval_s": v1.get("refresh_interval_s", 180), + "layout": { + "grid": {"cols": 4, "rows": 4}, + "items": layout_items, + }, + "plugin_configs": plugin_configs, + } + + +def load_config() -> dict: + if not CONFIG_PATH.exists(): + return _empty_v2_config() + try: + cfg = json.loads(CONFIG_PATH.read_text()) + except Exception as e: + logging.error(f"config.json invalid: {e}, using defaults") + return _empty_v2_config() + if cfg.get("version", 1) < 2: + logging.info("config: migrating v1 → v2") + cfg = migrate_v1_to_v2(cfg) + save_config(cfg) + # Ensure required keys exist + if "layout" not in cfg or "items" not in cfg.get("layout", {}): + cfg["layout"] = {"grid": {"cols": 4, "rows": 4}, "items": []} + if "plugin_configs" not in cfg: + cfg["plugin_configs"] = {} + if "refresh_interval_s" not in cfg: + cfg["refresh_interval_s"] = 180 + return cfg + + +def save_config(cfg: dict) -> None: + CONFIG_PATH.write_text(json.dumps(cfg, indent=2)) + + +def _empty_v2_config() -> dict: + return { + "version": 2, + "refresh_interval_s": 180, + "layout": { + "grid": {"cols": 4, "rows": 4}, + "items": [], + }, + "plugin_configs": {}, + } + + +# ============================================================================ +# Plugin Registry +# ============================================================================ +_widget_cache: dict[str, type[Widget]] = {} + + +def get_widget_classes() -> dict[str, type[Widget]]: + """Lazy + cached: lädt alle Plugin-Klassen einmal.""" + if not _widget_cache: + for cls in all_widget_classes(): + _widget_cache[cls.name] = cls + return _widget_cache + + +def make_widget(plugin_name: str, config: dict) -> Widget | None: + classes = get_widget_classes() + cls = classes.get(plugin_name) + if cls is None: + logging.warning(f"plugin not found: {plugin_name}") + return None + try: + w = cls(config) + w.on_load() + return w + except Exception as e: + logging.error(f"failed to instantiate plugin {plugin_name}: {e}") + return None + + +# ============================================================================ +# Font helpers +# ============================================================================ +FONT_DIR = HERE / "fnt" + + +def load_fonts() -> dict: + if not FONT_DIR.exists(): + FONT_DIR.mkdir(parents=True, exist_ok=True) + aldrich = FONT_DIR / "Aldrich-Regular.ttc" + clock = FONT_DIR / "advanced_led_board-7.ttc" + sizes = [16, 20, 24, 28, 32, 36, 48, 60, 80, 100] + fonts = {} + if aldrich.exists(): + for s in sizes: + try: + fonts[str(s)] = ImageFont.truetype(str(aldrich), s) + except Exception: + pass + if clock.exists(): + try: + fonts["clock"] = ImageFont.truetype(str(clock), 140) + except Exception: + pass + if not fonts: + fonts["default"] = ImageFont.load_default() + fonts["20"] = ImageFont.load_default() + return fonts + + +# ============================================================================ +# Slot-Geometrie (legacy v1) +# ============================================================================ +def slot_box(idx: int) -> tuple[int, int, int, int]: + """Legacy v1: gibt (x, y, w, h) für Slot idx (0..3) zurück.""" + col = idx % 2 + row = idx // 2 + w = DISPLAY_W // 2 + h = DISPLAY_H // 2 + x = col * w + y = row * h + return x, y, w, h + + +# ============================================================================ +# Render +# ============================================================================ +def render_full(items: list, widgets: list, fonts: dict) -> Image.Image: + """Render alle Items aufs Display. + + items: list[Item] (Layout-Items mit x,y,w,h) + widgets: list[Widget | None] — index-parallel zu items; None = leer + """ + img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), BG) + draw = ImageDraw.Draw(img) + + # Outer grid lines (subtle) + for c in range(1, 4): + draw.line((c * 200, 0, c * 200, 480), fill=FG, width=1) + draw.line((0, c * 120, 800, c * 120), fill=FG, width=1) + + for item, widget in zip(items, widgets): + px, py, pw, ph = item.pixels() + # Cell border + draw.rectangle((px, py, px + pw - 1, py + ph - 1), outline=FG, width=2) + if widget is None: + draw.text((px + 8, py + 8), f"#{item.id[:6]} (no plugin)", + font=fonts.get("16", fonts.get("default")), fill=FG) + continue + if item.y >= 4: # overflow marker + draw.text((px + 8, py + 8), f"{widget.label} (overflow)", + font=fonts.get("16", fonts.get("default")), fill=FG) + continue + try: + widget.render(draw, fonts, px, py, pw, ph) + except Exception as e: + logging.error(f"plugin {widget.name} render failed: {e}") + draw.text((px + 8, py + 8), + f"{widget.label}: render error", + font=fonts.get("16", fonts.get("default")), fill=FG) + + return img + + +# ============================================================================ +# Refresh-Loop +# ============================================================================ +class Dashboard: + def __init__(self): + self.config = load_config() + self.fonts = load_fonts() + self.items: list[Item] = [] + self.widgets: list[Widget | None] = [] + self.last_render: Image.Image | None = None + self.config_mtime = CONFIG_PATH.stat().st_mtime if CONFIG_PATH.exists() else 0 + self.last_refresh_ts: float = 0.0 + self._trigger = threading.Event() + self._trigger.set() # trigger initial refresh + self._epd = None + self._lock = threading.Lock() + self.reload() + + def reload(self): + """Lade config und baue alle Widgets.""" + cfg = self.config + classes = get_widget_classes() + plugin_configs = cfg.get("plugin_configs", {}) + items = [Item.from_dict(d) for d in cfg.get("layout", {}).get("items", [])] + widgets = [] + for item in items: + plugin_cfg = plugin_configs.get(item.plugin, {}) + widgets.append(make_widget(item.plugin, plugin_cfg)) + with self._lock: + self.items = items + self.widgets = widgets + self.config_mtime = CONFIG_PATH.stat().st_mtime if CONFIG_PATH.exists() else 0 + + def maybe_reload_config(self): + if not CONFIG_PATH.exists(): + return + mtime = CONFIG_PATH.stat().st_mtime + if mtime != self.config_mtime: + logging.info("config.json changed, reloading") + try: + self.config = load_config() + self.reload() + except Exception as e: + logging.error(f"reload failed: {e}") + + def trigger_refresh(self): + self._trigger.set() + + def render_once(self) -> Image.Image: + # fetch all widgets + for w in self.widgets: + if w is None: continue + try: + w.fetch() + except Exception as e: + logging.error(f"plugin {w.name} fetch failed: {e}") + with self._lock: + items = list(self.items) + widgets = list(self.widgets) + img = render_full(items, widgets, self.fonts) + return img + + def display(self, img: Image.Image): + # Lazy-import waveshare so der Renderer auch ohne Display testbar ist + if self._epd is None: + sys.path.insert(0, str(HERE / "waveshare_epd")) + try: + from waveshare_epd import epd7in3f + self._epd = epd7in3f.EPD() + self._epd.init() + self._epd.Clear() + except Exception as e: + logging.error(f"display init failed: {e}") + self._epd = "ERROR" + return False + if self._epd == "ERROR": + return False + try: + self._epd.display(self._epd.getbuffer(img)) + return True + except Exception as e: + logging.error(f"display error: {e}") + return False + + def run(self): + logging.info("dashboard starting; refresh_interval=%ss", self.config.get("refresh_interval_s")) + # start IPC socket listener + threading.Thread(target=self._socket_loop, daemon=True).start() + + while True: + try: + self.maybe_reload_config() + interval = int(self.config.get("refresh_interval_s", 180)) + # sleep until interval elapsed OR trigger set + wait = max(5, interval) + logging.info(f"waiting up to {wait}s (manual trigger interrupts)") + if self._trigger.wait(timeout=wait): + self._trigger.clear() + logging.info("manual trigger received") + else: + logging.info("interval elapsed, refreshing") + + t0 = time.time() + img = self.render_once() + with self._lock: + self.last_render = img + ok = self.display(img) + self.last_refresh_ts = time.time() + logging.info(f"refresh done in {time.time()-t0:.1f}s, display={ok}") + + # Re-Reload config after render in case user changed it during refresh + self.maybe_reload_config() + + except KeyboardInterrupt: + break + except Exception as e: + logging.error(f"loop error: {e}", exc_info=True) + time.sleep(5) + + def _socket_loop(self): + """Empfängt 'refresh\n' über Unix-Socket.""" + if os.path.exists(SOCKET_PATH): + os.remove(SOCKET_PATH) + srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + srv.bind(SOCKET_PATH) + os.chmod(SOCKET_PATH, 0o660) + srv.listen(5) + logging.info(f"ipc socket at {SOCKET_PATH}") + while True: + try: + conn, _ = srv.accept() + data = conn.recv(1024).decode("utf-8", errors="ignore").strip() + if data == "refresh": + self.trigger_refresh() + conn.sendall(b"OK\n") + elif data == "ping": + conn.sendall(b"PONG\n") + elif data.startswith("snapshot"): + # Schnappschuss-Pfad als Antwort + with self._lock: + if self.last_render: + tmp = "/tmp/epaper-snapshot.png" + self.last_render.save(tmp) + conn.sendall(f"OK {tmp}\n".encode()) + else: + conn.sendall(b"NO_RENDER_YET\n") + else: + conn.sendall(b"UNKNOWN_CMD\n") + conn.close() + except Exception as e: + logging.debug(f"socket error: {e}") + time.sleep(1) + + +def send_ipc(cmd: str, host: str = "localhost", timeout: float = 2.0) -> str: + """Helper für die Admin-UI: schickt ein Kommando an den laufenden Renderer.""" + if cmd == "snapshot": + # Snapshot holen ist eine HTTP-Aufgabe, kein IPC + return "" + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(timeout) + s.connect(SOCKET_PATH) + s.sendall((cmd + "\n").encode()) + data = b"" + while True: + chunk = s.recv(4096) + if not chunk: + break + data += chunk + s.close() + return data.decode(errors="ignore").strip() + except Exception as e: + return f"IPC_ERROR: {e}" + + +def setup_logging(): + handlers = [logging.StreamHandler()] + try: + from logging.handlers import RotatingFileHandler + handlers.append(RotatingFileHandler(LOG_PATH, maxBytes=512*1024, backupCount=1)) + except Exception: + pass + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + handlers=handlers, + ) + + +def main(): + setup_logging() + d = Dashboard() + # signal handlers for clean shutdown + def stop(*_): + raise KeyboardInterrupt + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + + # Network-Watchdog starten (überwacht WLAN, startet Recovery-AP) + try: + import network_watchdog as _netwd + wd = _netwd.get_watchdog() + logging.info("network watchdog started") + except Exception as e: + logging.error(f"failed to start network watchdog: {e}") + + d.run() + + +if __name__ == "__main__": + main() diff --git a/idx.html b/idx.html new file mode 100644 index 0000000..31735c3 --- /dev/null +++ b/idx.html @@ -0,0 +1,1042 @@ + + + + + + epaper-dashboard + + + + +
+ +
+
+ +
+

epaper-dashboard

+
7.3" ACeP · 4×4 grid
+
+
+
+
+
+
+
+
+ · +
+
+
+
+ +
+
+ +
+ + + + +
+ + +
+
+
+

Grid Layout 4×4 · 6 items

+
+
+ +
+
+ + + +
+ + +
+ +
+
+ Position und Größe per Buttons am Item. Auto-Pack ordnet nach Größe (groß zuerst). +
+
+ +
+
+

Live Preview

+
Auto-refresh alle 10s
+
+ preview +
+
+ + +
+
+
+

Plugin-Konfiguration

+
+
+
+
+
+ + +
+
+
+

Refresh-Intervall

+
+
+
+ + +
+ +
+
+ ACeP-Displays vertragen keine schnellen Refreshes. Hersteller empfiehlt ≥180s. +
+
+ +
+
+

System

+
+
+
Service:
epaper-dashboard.service
+
Logs:
journalctl -u epaper-dashboard -f
+
Config:
config.json
+
Plugins:
plugins/
+
+
+
+ + +
+
+
+

Aktueller Status

+
lade…
+
+ + + + +
+
+
+ +
+

WLAN verbinden / wechseln

+ + +
+ + +
+ + + + + +
+ +
+
+
+ +
+

Gespeicherte Netzwerke

+
    +
    + +
    + Recovery-AP Info +

    Wenn das konfigurierte WLAN nicht erreichbar ist (z.B. Router defekt, falsches Passwort, Pi umgezogen), startet das System automatisch einen Recovery-Hotspot.

    + +
    +
    +
    + +
    + + + + + \ No newline at end of file diff --git a/install_polkit.sh b/install_polkit.sh new file mode 100644 index 0000000..32e92eb --- /dev/null +++ b/install_polkit.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Erlaubt dem koptikp-User NetworkManager ohne Passwort zu steuern. +# Wird via polkit ausgewertet. +set -e + +RULE='polkit.addRule(function(action, subject) { + if (action.id.indexOf("org.freedesktop.NetworkManager.") === 0 && + subject.local === true && subject.active === true) { + return polkit.Result.YES; + } +});' + +RULE_FILE="/etc/polkit-1/rules.d/50-allow-networkmanager.rules" +echo "Schreibe $RULE_FILE..." +sudo -n bash -c "cat > $RULE_FILE <<'RULEEOF' +$POLKIT_RULE +RULEEOF +chmod 644 $RULE_FILE" + +echo "--- Inhalt: ---" +cat $RULE_FILE +echo +echo "--- Test: nmcli hotspot (kein pw mehr nötig) ---" +sudo -n nmcli device wifi hotspot ifname wlan0 ssid epaper-test password test1234 +sleep 3 +nmcli -t -f NAME,TYPE,DEVICE connection show --active +echo +echo "--- Cleanup ---" +sudo -n nmcli connection down epaper-test 2>&1 +sudo -n nmcli connection delete epaper-test 2>&1 +echo "Fertig." diff --git a/install_services.sh b/install_services.sh new file mode 100644 index 0000000..bc08193 --- /dev/null +++ b/install_services.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Installiert die systemd-Services fuer das ePaper-Dashboard. +set -e + +SERVICE_DIR=/etc/systemd/system +SERVICE_SRC=/home/koptikp/epaper-dashboard/port + +echo "=== installiere service units ===" +sudo -n install -m 644 "$SERVICE_SRC/epaper-dashboard.service" "$SERVICE_DIR/epaper-dashboard.service" +sudo -n install -m 644 "$SERVICE_SRC/epaper-admin.service" "$SERVICE_DIR/epaper-admin.service" + +echo +echo "=== stoppe alte Prozesse (manuell gestartet) ===" +pkill -f "python3.*admin.py" 2>/dev/null || true +pkill -f "python3.*dashboard.py" 2>/dev/null || true +sleep 2 + +echo "=== systemctl reload + enable + restart ===" +sudo -n systemctl daemon-reload +sudo -n systemctl enable epaper-dashboard.service +sudo -n systemctl enable epaper-admin.service +sudo -n systemctl restart epaper-dashboard.service +sudo -n systemctl restart epaper-admin.service + +sleep 3 + +echo +echo "=== status dashboard ===" +sudo -n systemctl is-active epaper-dashboard.service +sudo -n systemctl is-active epaper-admin.service +echo +echo "=== ports ===" +ss -ltn | grep -E "8080" | head -3 +echo +echo "Logs: journalctl -u epaper-dashboard -f" +echo " journalctl -u epaper-admin -f" diff --git a/layout.py b/layout.py new file mode 100644 index 0000000..a89ab55 --- /dev/null +++ b/layout.py @@ -0,0 +1,172 @@ +"""Grid-Layout: 4x4-Grid mit Pack-Algorithmus und Konflikt-Detection. + +Display: 800x480 Pixel. +Grid: 4x4 Zellen, jede Zelle 200x120 Pixel. + +Item-Geometrie: + (x, y, w, h) in Zelleneinheiten, x/y ∈ [0..GRID_COLS-1], w/h ∈ [1..GRID_COLS] + (x+w) ≤ GRID_COLS, (y+h) ≤ GRID_ROWS + +Regeln: + - Items dürfen sich nicht überlappen + - Wenn mehrere Items die gleiche Zelle belegen wollen → Konflikt + - Pack-Algorithmus: greedy, scan-line, größte zuerst +""" +from __future__ import annotations +from dataclasses import dataclass, field, asdict +from typing import Optional + + +GRID_COLS = 4 +GRID_ROWS = 4 +CELL_W = 200 # px +CELL_H = 120 # px +DISPLAY_W = GRID_COLS * CELL_W # 800 +DISPLAY_H = GRID_ROWS * CELL_H # 480 + +# Standard-Größen die im UI als Buttons angeboten werden +SIZE_PRESETS = [ + (1, 1), (2, 1), (1, 2), (2, 2), + (4, 1), (1, 4), (2, 4), (4, 2), + (3, 1), (1, 3), (3, 2), (2, 3), (3, 3), + (4, 4), +] + + +@dataclass +class Item: + id: str + plugin: str + x: int = 0 + y: int = 0 + w: int = 1 + h: int = 1 + + def to_dict(self): + return asdict(self) + + @classmethod + def from_dict(cls, d: dict) -> "Item": + return cls( + id=str(d.get("id", _new_id())), + plugin=str(d.get("plugin", "")), + x=int(d.get("x", 0)), + y=int(d.get("y", 0)), + w=int(d.get("w", 1)), + h=int(d.get("h", 1)), + ) + + def bounds(self) -> tuple[int, int, int, int]: + """Returns (x, y, w, h).""" + return self.x, self.y, self.w, self.h + + def pixels(self) -> tuple[int, int, int, int]: + """Returns (x, y, width, height) in pixels.""" + return self.x * CELL_W, self.y * CELL_H, self.w * CELL_W, self.h * CELL_H + + +def _new_id() -> str: + import secrets + return secrets.token_hex(4) + + +def cells_occupied(item: Item) -> set[tuple[int, int]]: + """Set of (col, row) cells this item covers.""" + return {(item.x + dx, item.y + dy) for dx in range(item.w) for dy in range(item.h)} + + +def find_overlaps(items: list[Item]) -> list[tuple[str, str]]: + """Returns list of (id_a, id_b) pairs that overlap.""" + overlaps = [] + for i, a in enumerate(items): + cells_a = cells_occupied(a) + for b in items[i+1:]: + if cells_a & cells_occupied(b): + overlaps.append((a.id, b.id)) + return overlaps + + +def find_out_of_bounds(items: list[Item]) -> list[str]: + """Returns list of item IDs that are outside the grid.""" + bad = [] + for it in items: + if it.x < 0 or it.y < 0 or it.x + it.w > GRID_COLS or it.y + it.h > GRID_ROWS: + bad.append(it.id) + return bad + + +def pack(items: list[Item], order: Optional[list[str]] = None) -> list[Item]: + """Auto-pack items into a 4x4 grid. + + Strategy: greedy first-fit, sort by area descending (largest first). + Items that don't fit are placed in a 'trash row' below the grid (y=GRID_ROWS, + h=1) so they are visible to the user as overflowing. + + `order` (optional): explicit ordering by item id. + """ + # Sort: largest area first; tie-break by id for determinism + indexed = list(items) + if order: + priority = {id_: i for i, id_ in enumerate(order)} + indexed.sort(key=lambda it: (priority.get(it.id, 9999), -it.w * it.h, it.id)) + else: + indexed.sort(key=lambda it: (-it.w * it.h, it.id)) + + taken: set[tuple[int, int]] = set() + result: list[Item] = [] + overflow_row: list[Item] = [] + + for it in indexed: + # clamp size to grid + it.w = max(1, min(GRID_COLS, it.w)) + it.h = max(1, min(GRID_ROWS, it.h)) + # try to place + placed = False + for y in range(GRID_ROWS - it.h + 1): + for x in range(GRID_COLS - it.w + 1): + cells = {(x + dx, y + dy) for dx in range(it.w) for dy in range(it.h)} + if not (cells & taken): + it.x, it.y = x, y + taken |= cells + result.append(it) + placed = True + break + if placed: break + if not placed: + # couldn't fit → overflow marker + it.x = 0 + it.y = GRID_ROWS # off-screen + overflow_row.append(it) + result.append(it) + return result + + +def auto_size_for_plugin(plugin_name: str) -> tuple[int, int]: + """Default size when user adds a new item.""" + presets = { + "clock": (2, 2), + "weather": (2, 2), + "system": (2, 2), + "minimax": (2, 2), + "spotify": (2, 1), + "strava": (2, 1), + "gmail": (1, 1), + "hello": (1, 1), + } + return presets.get(plugin_name, (2, 2)) + + +def render_grid_layout(items: list[Item]) -> dict: + """Render each item to its (x, y, w, h) pixel-box on the 800x480 display.""" + boxes = [] + for it in items: + px, py, pw, ph = it.pixels() + # Overflow items still get rendered as small placeholder, not visible + boxes.append({ + "id": it.id, + "plugin": it.plugin, + "x": px, "y": py, "w": pw, "h": ph, + "out_of_bounds": it.y >= GRID_ROWS, + }) + return {"display_w": DISPLAY_W, "display_h": DISPLAY_H, "grid_cols": GRID_COLS, + "grid_rows": GRID_ROWS, "cell_w": CELL_W, "cell_h": CELL_H, "items": boxes} diff --git a/network_watchdog.py b/network_watchdog.py new file mode 100644 index 0000000..f7cd335 --- /dev/null +++ b/network_watchdog.py @@ -0,0 +1,427 @@ +"""Network-Watchdog: überwacht WLAN-Verbindung und startet Recovery-AP bei Ausfall. + +Strategie: + 1. Default-Gateway-Ping alle CHECK_INTERVAL_S + 2. failed_pings zählt; bei FAIL_THRESHOLD sofortiger Reconnect-Versuch + 3. Nach REASSURE_TIMEOUT_S erfolglos: AP-Modus starten + 4. Im AP-Modus: alle AP_RECHECK_S prüfen ob echtes WLAN zurück ist, dann AP aus +""" +from __future__ import annotations +import subprocess, time, threading, logging, os +from pathlib import Path +from dataclasses import dataclass, field + +# Defaults +WLAN_IFACE = "wlan0" # Pi 4 builtin +CLIENT_CON_NAME = "dashboard-wifi" # NetworkManager connection profile name +AP_SSID = "epaper-recovery" +AP_PASSWORD = "recovery1234" + +CHECK_INTERVAL_S = 30 # alle 30s pingen +FAIL_THRESHOLD = 2 # 2 misses hintereinander → reconnect versuchen +RECONNECT_COOLDOWN_S = 90 # zwischen zwei Reconnect-Versuchen mind. 90s warten +AP_RECHECK_S = 60 # im AP-Modus: alle 60s versuchen zurück zu wechseln +AP_ENABLE_TIMEOUT_S = 30 # warten bis AP steht, dann gilt: "AP ist aktiv" +PING_TIMEOUT_S = 3 +PING_TARGETS = ["1.1.1.1", "8.8.8.8"] # wenn eins antwortet → online + + +@dataclass +class NetState: + mode: str = "unknown" # "client" | "ap" | "connecting" | "offline" | "unknown" + ssid: str = "" + ip: str = "" + signal: int = 0 # 0..100 + gateway_ping_ms: float = 0.0 + failed_pings: int = 0 + last_state_change: float = field(default_factory=time.time) + last_reconnect_attempt: float = 0.0 + error: str = "" + + +def _nm(args: list[str], timeout: int = 15) -> tuple[int, str, str]: + """Run nmcli with timeout, return (rc, stdout, stderr).""" + try: + r = subprocess.run(["nmcli", *args], capture_output=True, text=True, timeout=timeout) + return r.returncode, r.stdout, r.stderr + except subprocess.TimeoutExpired: + return -1, "", "timeout" + except Exception as e: + return -1, "", str(e) + + +def ping(target: str) -> float | None: + try: + r = subprocess.run( + ["ping", "-c", "1", "-W", str(PING_TIMEOUT_S), target], + capture_output=True, text=True, timeout=PING_TIMEOUT_S + 2, + ) + if r.returncode != 0: + return None + for line in r.stdout.splitlines(): + if "time=" in line: + return float(line.split("time=")[1].split()[0]) + except Exception: + return None + return None + + +def get_active_wifi_info() -> dict: + """Liest aktuelle WLAN-Informationen aus NetworkManager.""" + rc, out, err = _nm(["-t", "-f", "ACTIVE,SSID,SIGNAL,FREQ,CHAN,RATE,BARS", "device", "wifi"]) + info = {"ssid": "", "signal": 0, "bars": "", "active": False} + if rc != 0: + return info + for line in out.splitlines(): + parts = line.split(":") + if len(parts) < 6: + continue + # Format: yes:ssid:signal:... + if parts[0] == "yes": + info["active"] = True + info["ssid"] = parts[1] if len(parts) > 1 else "" + try: + info["signal"] = int(parts[2]) if len(parts) > 2 and parts[2] else 0 + except ValueError: + pass + info["bars"] = parts[6] if len(parts) > 6 else "" + break + return info + + +def get_wifi_ip() -> str: + """Hole IPv4-Adresse von WLAN-Interface.""" + try: + r = subprocess.run( + ["ip", "-4", "-o", "addr", "show", "dev", WLAN_IFACE], + capture_output=True, text=True, timeout=5, + ) + for line in r.stdout.splitlines(): + if "inet " in line: + return line.split("inet ")[1].split("/")[0] + except Exception: + pass + return "" + + +def list_wifi_networks() -> list[dict]: + """Scannt verfügbare WLANs.""" + rc, out, err = _nm(["-t", "-f", "SSID,SIGNAL,SECURITY,FREQ,CHAN", "device", "wifi", + "list", "--rescan", "yes"]) + if rc != 0: + # zweiter versuch ohne rescan + rc, out, err = _nm(["-t", "-f", "SSID,SIGNAL,SECURITY,FREQ,CHAN", "device", "wifi", "list"]) + seen = set() + result = [] + for line in out.splitlines(): + parts = line.split(":") + if len(parts) < 3: continue + ssid = parts[0] + if not ssid or ssid in seen: continue + seen.add(ssid) + try: + sig = int(parts[1]) if parts[1] else 0 + except ValueError: + sig = 0 + result.append({"ssid": ssid, "signal": sig, "security": parts[2] if len(parts) > 2 else "", + "freq": parts[3] if len(parts) > 3 else "", + "chan": parts[4] if len(parts) > 4 else ""}) + result.sort(key=lambda w: w["signal"], reverse=True) + return result + + +def list_saved_connections() -> list[dict]: + """Liste alle gespeicherten WLAN-Profile.""" + rc, out, err = _nm(["-t", "-f", "NAME,TYPE,UUID", "connection", "show"]) + result = [] + for line in out.splitlines(): + parts = line.split(":") + if len(parts) < 3: continue + if parts[1] != "802-11-wireless": continue + if parts[0] in (AP_SSID, "Hotspot"): continue # skip AP profile + result.append({"name": parts[0], "uuid": parts[2]}) + return result + + +def save_wifi(ssid: str, password: str, security: str = "wpa-psk") -> tuple[bool, str]: + """Speichere (oder update) ein WLAN-Profil mit gegebenen Credentials. + + Wenn schon ein Profil mit gleicher SSID existiert, wird es aktualisiert; + sonst wird ein neues angelegt. + """ + # Existierendes Profil finden + saved = list_saved_connections() + target_name = None + for c in saved: + if c["name"].startswith(ssid): + target_name = c["name"] + break + if target_name is None: + target_name = CLIENT_CON_NAME + "-" + ssid.replace(" ", "_") + + args = ["connection", "modify" if target_name in [c["name"] for c in saved] else "add", + "type", "wifi", + "con-name", target_name, + "ifname", WLAN_IFACE, + "ssid", ssid] + if security and security != "" and password: + if "WPA" in security.upper() or "WPA2" in security.upper() or "WPA3" in security.upper(): + args += ["wifi-sec.key-mgmt", "wpa-psk", "wifi-sec.psk", password] + elif "WEP" in security.upper(): + args += ["wifi-sec.key-mgmt", "none", "wifi-sec.wep-key-type", "1", + "wifi-sec.wep-key0", password] + else: + args += ["wifi-sec.key-mgmt", "wpa-psk", "wifi-sec.psk", password] + else: + args += ["wifi-sec.key-mgmt", "none"] + rc, out, err = _nm(args, timeout=20) + if rc != 0: + return False, err or out or "unknown error" + return True, target_name + + +def connect_wifi(connection_name: str) -> tuple[bool, str]: + rc, out, err = _nm(["connection", "up", connection_name], timeout=30) + if rc != 0: + return False, err or out or "unknown error" + return True, "connected" + + +def disconnect_wifi() -> tuple[bool, str]: + rc, out, err = _nm(["device", "disconnect", WLAN_IFACE], timeout=10) + return rc == 0, err if rc != 0 else "ok" + + +def start_ap() -> tuple[bool, str]: + """Startet einen Hotspot auf wlan0 mit fester SSID/PW.""" + # Alten Hotspot ggf. löschen (idempotent) + _nm(["connection", "delete", AP_SSID], timeout=10) + rc, out, err = _nm([ + "device", "wifi", "hotspot", + "ifname", WLAN_IFACE, + "ssid", AP_SSID, + "password", AP_PASSWORD, + ], timeout=30) + if rc != 0: + return False, err or out + return True, "hotspot up" + + +def stop_ap() -> tuple[bool, str]: + rc, out, err = _nm(["connection", "down", AP_SSID], timeout=15) + if rc != 0: + return False, err or out + _nm(["connection", "delete", AP_SSID], timeout=10) + return True, "ap stopped" + + +def is_ap_active() -> bool: + rc, out, _ = _nm(["-t", "-f", "NAME", "connection", "show", "--active"]) + if rc != 0: + return False + return AP_SSID in out.splitlines() + + +def is_wifi_connected() -> bool: + rc, out, _ = _nm(["-t", "-f", "STATE", "device", "show", WLAN_IFACE]) + if rc != 0: + return False + return "connected" in out.lower() + + +# ============================================================================ +# Watchdog +# ============================================================================ +class Watchdog: + def __init__(self): + self.state = NetState() + self._lock = threading.Lock() + self._stop = threading.Event() + + def get_state(self) -> dict: + with self._lock: + return { + "mode": self.state.mode, + "ssid": self.state.ssid, + "ip": self.state.ip, + "signal": self.state.signal, + "gateway_ping_ms": round(self.state.gateway_ping_ms, 1), + "failed_pings": self.state.failed_pings, + "since_change_s": int(time.time() - self.state.last_state_change), + "error": self.state.error, + } + + def _set_mode(self, new_mode: str): + with self._lock: + if self.state.mode != new_mode: + logging.info(f"network mode: {self.state.mode} → {new_mode}") + self.state.mode = new_mode + self.state.last_state_change = time.time() + self.state.error = "" + + def run_once(self) -> dict: + """Führt einen Check durch und triggert ggf. State-Übergänge.""" + with self._lock: + cur_mode = self.state.mode + + ping_ms = None + for tgt in PING_TARGETS: + r = ping(tgt) + if r is not None: + ping_ms = r + break + + wifi_info = get_active_wifi_info() + ip = get_wifi_ip() + + if wifi_info["active"] and ip and ping_ms is not None: + # ONLINE + with self._lock: + self.state.ssid = wifi_info["ssid"] + self.state.signal = wifi_info["signal"] + self.state.ip = ip + self.state.gateway_ping_ms = ping_ms + self.state.failed_pings = 0 + # Falls im AP-Modus → AP aus + if cur_mode == "ap": + logging.info("client wifi back, stopping AP") + stop_ap() + self._set_mode("client") + return self.get_state() + + # OFFLINE PATH + with self._lock: + if ping_ms is None: + self.state.failed_pings += 1 + self.state.gateway_ping_ms = ping_ms or 0.0 + self.state.ip = ip + self.state.ssid = wifi_info["ssid"] + self.state.signal = wifi_info["signal"] + failed = self.state.failed_pings + last_recon = self.state.last_reconnect_attempt + + if cur_mode == "ap": + # Bereits im AP — nur gelegentlich versuchen zurück zu wechseln + # (durch den regulären 60s-Loop in self._loop) + return self.get_state() + + if failed >= FAIL_THRESHOLD: + now = time.time() + if now - last_recon < RECONNECT_COOLDOWN_S: + return self.get_state() + # Reconnect versuchen + with self._lock: + self.state.last_reconnect_attempt = now + saved = list_saved_connections() + if saved: + logging.info(f"wifi lost ({failed} failed pings), reconnecting to {saved[0]['name']}") + self._set_mode("connecting") + ok, msg = connect_wifi(saved[0]["name"]) + if ok: + time.sleep(AP_ENABLE_TIMEOUT_S) + # Nach reconnect: wenn immer noch offline → AP + return self.run_once() + else: + logging.error(f"reconnect failed: {msg}") + with self._lock: + self.state.error = msg + else: + # Kein Profil → direkt in AP + self._set_mode("connecting") + return self._enter_ap() + return self.get_state() + + def _enter_ap(self) -> dict: + logging.warning(f"entering AP mode ({AP_SSID})") + ok, msg = start_ap() + if ok: + self._set_mode("ap") + with self._lock: + self.state.ip = "10.42.0.1" + self.state.error = "" + else: + logging.error(f"failed to start AP: {msg}") + self._set_mode("offline") + with self._lock: + self.state.error = msg + return self.get_state() + + def _try_exit_ap(self) -> dict: + """Versuch, aus dem AP zurück in Client-Modus zu wechseln.""" + if not is_ap_active(): + self._set_mode("client") + return self.get_state() + saved = list_saved_connections() + if not saved: + return self.get_state() + logging.info("trying to exit AP back to client") + ok, msg = connect_wifi(saved[0]["name"]) + if ok: + time.sleep(AP_ENABLE_TIMEOUT_S) + wifi_info = get_active_wifi_info() + ip = get_wifi_ip() + if wifi_info["active"] and ip and ping(PING_TARGETS[0]) is not None: + logging.info("back online, stopping AP") + stop_ap() + self._set_mode("client") + with self._lock: + self.state.failed_pings = 0 + else: + # Immer noch kein echtes WLAN → AP wieder hoch + start_ap() + self._set_mode("ap") + return self.get_state() + + def _loop(self): + # Initial: state aus NM ableiten + wifi_info = get_active_wifi_info() + ip = get_wifi_ip() + if is_ap_active(): + self._set_mode("ap") + with self._lock: + self.state.ssid = AP_SSID + self.state.ip = "10.42.0.1" + elif wifi_info["active"] and ip: + self._set_mode("client") + with self._lock: + self.state.ssid = wifi_info["ssid"] + self.state.signal = wifi_info["signal"] + self.state.ip = ip + else: + self._set_mode("offline") + + while not self._stop.is_set(): + try: + if self.state.mode == "ap": + # im AP-Modus: alle AP_RECHECK_S versuchen zurück zu wechseln + if self._stop.wait(timeout=AP_RECHECK_S): + break + self._try_exit_ap() + else: + if self._stop.wait(timeout=CHECK_INTERVAL_S): + break + self.run_once() + except Exception as e: + logging.error(f"watchdog error: {e}") + time.sleep(5) + + def start(self): + t = threading.Thread(target=self._loop, name="network-watchdog", daemon=True) + t.start() + + def stop(self): + self._stop.set() + + +# ============================================================================ +# Singleton für Web-UI +# ============================================================================ +_watchdog: Watchdog | None = None + + +def get_watchdog() -> Watchdog: + global _watchdog + if _watchdog is None: + _watchdog = Watchdog() + _watchdog.start() + return _watchdog diff --git a/palette.py b/palette.py new file mode 100644 index 0000000..c6c8de7 --- /dev/null +++ b/palette.py @@ -0,0 +1,237 @@ +"""7-Farben-Palette für Waveshare 7.3-inch ACeP (F) HAT. + +Der Treiber epd7in3f.EPD.getbuffer() quantisiert ein RGB-Bild automatisch +auf die 7 vom Panel unterstützten Farben. Wir liefern RGB; das macht unseren +Renderer-Code lesbar. + +Reihenfolge der Farbnamen folgt der Wiki-Spezifikation: + Black, White, Green, Blue, Red, Yellow, Orange + +Zusätzlich semantische Aliase: + FG/BG = Vorder-/Hintergrund + OK/WARN/ALERT = Statusbalken + INFO = Blue + ACCENT = Orange + INVERT_FG/INVERT_BG = invertiertes Feld (UV/AQI high) +""" +from PIL import ImageDraw, ImageFont + +# ---- 7 Panel-Farben (RGB) ---- +BLACK = (0, 0, 0) +WHITE = (255, 255, 255) +GREEN = (0, 255, 0) +BLUE = (0, 0, 255) +RED = (255, 0, 0) +YELLOW = (255, 255, 0) +ORANGE = (255, 128, 0) + +# ---- Semantische Aliase ---- +FG = BLACK +BG = WHITE +INVERT_BG = BLACK +INVERT_FG = WHITE + +OK = GREEN +WARN = YELLOW +ALERT = RED + +INFO = BLUE +ACCENT = ORANGE + +_TABLE = { + "black": BLACK, "white": WHITE, "green": GREEN, "blue": BLUE, + "red": RED, "yellow": YELLOW, "orange": ORANGE, + "fg": FG, "bg": BG, + "invert_fg": INVERT_FG, "invert_bg": INVERT_BG, + "ok": OK, "warn": WARN, "alert": ALERT, "info": INFO, "accent": ACCENT, +} + + +def fill_for(name: str) -> tuple: + if name not in _TABLE: + raise ValueError(f"unknown color name: {name!r}") + return _TABLE[name] + + +def measure(draw: ImageDraw.Draw, text: str, font: ImageFont.FreeTypeFont): + try: + b = draw.textbbox((0, 0), text, font=font) + return b[2] - b[0], b[3] - b[1] + except AttributeError: + return draw.textsize(text, font=font) + + +def text_wh(draw, text, font): + return measure(draw, text, font) + + +# ============================================================================ +# Layout helpers — damit Widgets slot-relative und größen-responsive arbeiten +# ============================================================================ +def fit_font(draw, text: str, fonts: dict, max_w: int, max_h: int, + candidates: list[str] | None = None) -> ImageFont.FreeTypeFont | None: + """Wählt die größte Schrift aus `candidates` (oder '80','60','48','32','28','24','20','16'), + deren gerenderter Text in max_w × max_h passt. + + `fonts` ist das Dictionary aus dashboard.load_fonts(). + """ + if candidates is None: + candidates = ["clock", "80", "60", "48", "32", "28", "24", "20", "16"] + for key in candidates: + f = fonts.get(key) + if f is None: + continue + tw, th = measure(draw, text, f) + if tw <= max_w and th <= max_h: + return f + return fonts.get("16") or fonts.get("default") + + +def centered_text(draw, text, x, y, w, h, font, color): + """Zentriert einen Text in der gegebenen Box (x,y,w,h). Returns (px, py).""" + tw, th = measure(draw, text, font) + px = x + max(0, (w - tw) // 2) + py = y + max(0, (h - th) // 2) + draw.text((px, py), text, font=font, fill=color) + return px, py + + +def hbar(draw, x, y, w, h, pct: float, fg=None, bg=None, border_w: int = 2, + thresholds: list | None = None, gradient: bool = False): + """Horizontaler Prozentbalken mit konfigurierbarem Farbverlauf. + + Args: + pct: 0..100 (wird geclampt) + fg: fallback-Farbe (falls keine thresholds) + thresholds: Liste von (max_pct, color) Tupeln, sortiert aufsteigend. + Beispiel: [(50, OK), (80, WARN), (100, ALERT)] + Bei pct < 50 → OK, pct < 80 → WARN, sonst ALERT. + gradient: wenn True, wird der Balken in mehrere Segmente mit den + Threshold-Farben aufgeteilt, statt ein einfarbiger Fill zu sein. + + Wenn `gradient=False` und `thresholds` gesetzt: einfarbiger Fill in der + ersten passenden Threshold-Farbe. + Wenn beides None: einfarbig in fg (oder FG-Fallback). + """ + fg = fg or FG + bg = bg or BG + if h < 6 or w < 6: + return + + pct = max(0.0, min(100.0, pct)) + + # Welche Farbe für pct? + def color_for(p: float) -> tuple: + if thresholds: + for max_p, c in thresholds: + if p <= max_p: + return c + return thresholds[-1][1] + return fg + + # Frame + draw.rectangle((x, y, x + w - 1, y + h - 1), outline=fg, width=border_w) + + if gradient and thresholds and len(thresholds) >= 2: + # Multi-Segment-Bar: zeichne für jeden Threshold-Bereich sein eigenes Segment + inner_x = x + border_w + inner_w = w - 2 * border_w + if inner_w > 0: + prev_max = 0.0 + for max_p, c in thresholds: + seg_pct_start = prev_max + seg_pct_end = min(max_p, pct) + if seg_pct_end > seg_pct_start: + seg_x0 = inner_x + int(inner_w * seg_pct_start / 100) + seg_x1 = inner_x + int(inner_w * seg_pct_end / 100) + draw.rectangle((seg_x0, y + border_w, + seg_x1, y + h - 1 - border_w), fill=c) + prev_max = max_p + # Falls pct die höchste Threshold überschreitet + if pct > thresholds[-1][0]: + seg_x0 = inner_x + int(inner_w * thresholds[-1][0] / 100) + draw.rectangle((seg_x0, y + border_w, + inner_x + inner_w - 1, y + h - 1 - border_w), + fill=thresholds[-1][1]) + else: + # Einfarbiger Fill in passender Farbe + fill_w = int((w - 2 * border_w) * pct / 100) + if fill_w > 0: + draw.rectangle((x + border_w, y + border_w, + x + border_w + fill_w, y + h - 1 - border_w), + fill=color_for(pct)) + + +# Default-Schwellen: (max_pct, color) +DEFAULT_THRESHOLDS = [ + (50, OK), + (80, WARN), + (95, ALERT), +] + + +def parse_thresholds(spec) -> list: + """Parst Threshold-Spec aus Plugin-Config. + + Akzeptiert entweder: + - String "ok,warn,alert" (3 stufig, default-Werte 50/80/95) + - String "ok@50,warn@80,alert@95" (custom Schwellen) + - Liste von Dicts [{"pct": 50, "color": "ok"}, ...] + - None → DEFAULT_THRESHOLDS + """ + if spec is None or spec == "": + return list(DEFAULT_THRESHOLDS) + if isinstance(spec, list): + result = [] + for item in spec: + try: + if isinstance(item, dict): + p = float(item.get("pct", 100)) + c = fill_for(item.get("color", "fg")) + else: + # tuple-like + p = float(item[0]) + c = fill_for(item[1]) + result.append((p, c)) + except (ValueError, TypeError, KeyError): + continue + return sorted(result, key=lambda x: x[0]) if result else list(DEFAULT_THRESHOLDS) + if isinstance(spec, str): + parts = [s.strip() for s in spec.split(",") if s.strip()] + # Default-Stufen wenn keine @-Syntax + default_pcts = [50, 80, 95] + result = [] + for i, p in enumerate(parts): + if "@" in p: + name, val = p.split("@", 1) + try: + pct = float(val) + except ValueError: + continue + else: + name = p + pct = default_pcts[i] if i < len(default_pcts) else 100 + try: + color = fill_for(name) + except ValueError: + continue + result.append((pct, color)) + return sorted(result, key=lambda x: x[0]) if result else list(DEFAULT_THRESHOLDS) + return list(DEFAULT_THRESHOLDS) + + +def is_small(w: int, h: int) -> bool: + """True wenn Slot klein ist (1x1 oder ähnlich). Widgets können darauf + vereinfachtes Layout zeigen.""" + return w < 280 or h < 180 + + +def is_wide(w: int, h: int) -> bool: + """True wenn Slot breit aber flach ist (4x1, 2x1).""" + return w >= 400 and h < 200 + + +def is_tall(w: int, h: int) -> bool: + """True wenn Slot hoch aber schmal ist (1x4, 1x2).""" + return h >= 280 and w < 280 + diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000..56e5772 --- /dev/null +++ b/plugins/__init__.py @@ -0,0 +1 @@ +# Plugins package diff --git a/plugins/base.py b/plugins/base.py new file mode 100644 index 0000000..a55f000 --- /dev/null +++ b/plugins/base.py @@ -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 diff --git a/plugins/clock.py b/plugins/clock.py new file mode 100644 index 0000000..cb41f11 --- /dev/null +++ b/plugins/clock.py @@ -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) diff --git a/plugins/gmail.py b/plugins/gmail.py new file mode 100644 index 0000000..f605007 --- /dev/null +++ b/plugins/gmail.py @@ -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) diff --git a/plugins/hello.py b/plugins/hello.py new file mode 100644 index 0000000..0ade197 --- /dev/null +++ b/plugins/hello.py @@ -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"))) diff --git a/plugins/minimax.py b/plugins/minimax.py new file mode 100644 index 0000000..94907c3 --- /dev/null +++ b/plugins/minimax.py @@ -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) diff --git a/plugins/spotify.py b/plugins/spotify.py new file mode 100644 index 0000000..6c1c4e7 --- /dev/null +++ b/plugins/spotify.py @@ -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) diff --git a/plugins/strava.py b/plugins/strava.py new file mode 100644 index 0000000..d70954d --- /dev/null +++ b/plugins/strava.py @@ -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) diff --git a/plugins/system.py b/plugins/system.py new file mode 100644 index 0000000..3ccd83d --- /dev/null +++ b/plugins/system.py @@ -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) diff --git a/plugins/weather.py b/plugins/weather.py new file mode 100644 index 0000000..fa63204 --- /dev/null +++ b/plugins/weather.py @@ -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"¤t=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) diff --git a/render_test.py b/render_test.py new file mode 100644 index 0000000..ad03d95 --- /dev/null +++ b/render_test.py @@ -0,0 +1,8 @@ +import sys +sys.path.insert(0, ".") +import dashboard +d = dashboard.Dashboard() +img = d.render_once() +img.save("/tmp/render_test.png") +print("rendered:", img.size) +print("widgets:", [(w.name if w else None) for w in d.widgets]) diff --git a/socktest.py b/socktest.py new file mode 100644 index 0000000..92b9b57 --- /dev/null +++ b/socktest.py @@ -0,0 +1,14 @@ +import os, socket +p = "/tmp/test.sock" +if os.path.exists(p): + os.remove(p) +srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +try: + srv.bind(p) + srv.listen(1) + print("bound, listening") + print("exists immediately after bind:", os.path.exists(p)) +finally: + srv.close() + if os.path.exists(p): + os.remove(p) diff --git a/start_admin.sh b/start_admin.sh new file mode 100644 index 0000000..246ed44 --- /dev/null +++ b/start_admin.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# startet admin.py im Hintergrund mit korrektem CWD +cd /home/koptikp/epaper-dashboard/port +pkill -f admin.py 2>/dev/null +sleep 1 +nohup env EPAPER_ADMIN_PORT=8080 python3 admin.py > /tmp/admin.log 2>&1 & +echo "admin pid: $!" diff --git a/start_admin_remote.sh b/start_admin_remote.sh new file mode 100644 index 0000000..237f881 --- /dev/null +++ b/start_admin_remote.sh @@ -0,0 +1,4 @@ +#!/bin/bash +chmod +x /home/koptikp/epaper-dashboard/port/start_admin.sh 2>/dev/null +# Sende das Script selbst +cat /home/koptikp/epaper-dashboard/port/start_admin.sh diff --git a/start_dashboard.sh b/start_dashboard.sh new file mode 100644 index 0000000..cef9b2b --- /dev/null +++ b/start_dashboard.sh @@ -0,0 +1,9 @@ +#!/bin/bash +cd /home/koptikp/epaper-dashboard/port +pkill -f dashboard.py 2>/dev/null +sleep 1 +nohup env python3 dashboard.py > /tmp/dashboard.log 2>&1 & +echo "dashboard pid: $!" +sleep 2 +echo "--- log preview ---" +head -20 /tmp/dashboard.log diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..3321feb --- /dev/null +++ b/templates/index.html @@ -0,0 +1,1002 @@ + + + + + + epaper-dashboard + + + + +
    + +
    +
    + +
    +

    epaper-dashboard

    +
    7.3" ACeP · 4×4 grid
    +
    +
    +
    +
    +
    +
    +
    +
    + · +
    +
    +
    +
    + +
    +
    + +
    + + + + +
    + + +
    +
    +
    +

    Grid Layout {{ grid.cols }}×{{ grid.rows }} · {{ layout_items|length }} items

    +
    +
    + +
    +
    + + + +
    + + +
    + +
    +
    + Position und Größe per Buttons am Item. Auto-Pack ordnet nach Größe (groß zuerst). +
    +
    + +
    +
    +

    Live Preview

    +
    Auto-refresh alle 10s
    +
    + preview +
    +
    + + +
    +
    +
    +

    Plugin-Konfiguration

    +
    +
    +
    +
    +
    + + +
    +
    +
    +

    Refresh-Intervall

    +
    +
    +
    + + +
    + +
    +
    + ACeP-Displays vertragen keine schnellen Refreshes. Hersteller empfiehlt ≥180s. +
    +
    + +
    +
    +

    System

    +
    +
    +
    Service:
    epaper-dashboard.service
    +
    Logs:
    journalctl -u epaper-dashboard -f
    +
    Config:
    config.json
    +
    Plugins:
    plugins/
    +
    +
    +
    + + +
    +
    +
    +

    Aktueller Status

    +
    lade…
    +
    + + + + +
    +
    +
    + +
    +

    WLAN verbinden / wechseln

    + + +
    + + +
    + + + + + +
    + +
    +
    +
    + +
    +

    Gespeicherte Netzwerke

    +
      +
      + +
      + Recovery-AP Info +

      Wenn das konfigurierte WLAN nicht erreichbar ist (z.B. Router defekt, falsches Passwort, Pi umgezogen), startet das System automatisch einen Recovery-Hotspot.

      + +
      +
      +
      + +
      + + + + + diff --git a/test_env_auth.sh b/test_env_auth.sh new file mode 100644 index 0000000..5b626a2 --- /dev/null +++ b/test_env_auth.sh @@ -0,0 +1,20 @@ +#!/bin/bash +cat > /home/koptikp/epaper-dashboard/port/.env <<'ENVEOF' +EPAPER_ADMIN_USER=admin +EPAPER_ADMIN_PASSWORD=changeme123 +ENVEOF +chmod 600 /home/koptikp/epaper-dashboard/port/.env +echo "=== .env geschrieben ===" +ls -la /home/koptikp/epaper-dashboard/port/.env +echo +echo "=== restart admin ===" +bash /home/koptikp/epaper-dashboard/port/start_admin.sh +sleep 2 +echo +echo "=== test mit altem passwort (sollte 401) ===" +curl -s -u admin:admin -o /dev/null -w "admin:admin -> HTTP %{http_code}\n" http://127.0.0.1:8080/status.json +echo "=== test mit neuem passwort (sollte 200) ===" +curl -s -u admin:changeme123 -o /dev/null -w "admin:changeme123 -> HTTP %{http_code}\n" http://127.0.0.1:8080/status.json +echo +echo "=== sicherheits-check: passwort in logs? ===" +grep -E "changeme123|admin:" /tmp/admin.log || echo "(kein Treffer in logs)" diff --git a/test_gradient.py b/test_gradient.py new file mode 100644 index 0000000..b61056d --- /dev/null +++ b/test_gradient.py @@ -0,0 +1,40 @@ +"""Demo: Progress-Bars mit Gradient und konfigurierbaren Schwellen.""" +import sys; sys.path.insert(0, ".") +from PIL import Image, ImageDraw, ImageFont +from palette import hbar, parse_thresholds, FG, OK, WARN, ALERT, ORANGE, BLUE, GREEN, RED, YELLOW + +def font(s): return ImageFont.truetype("fnt/Aldrich-Regular.ttc", s) +fonts = {str(s): font(s) for s in [16, 20, 24]} + +img = Image.new("RGB", (800, 600), (255,255,255)) +draw = ImageDraw.Draw(img) + +# Title +draw.text((20, 10), "Gradient Bars - 3-stufig default (50/80/95)", font=font(20), fill=FG) + +# Row 1: gradient on (default thresholds) +draw.text((20, 50), "Gradient AN, Thresholds 50/80/95", font=font(16), fill=FG) +hbar(draw, 20, 80, 760, 30, 25, thresholds=parse_thresholds("ok@50,warn@80,alert@95"), gradient=True) +draw.text((20, 115), "25% (ok)", font=font(16), fill=FG) +hbar(draw, 20, 140, 760, 30, 65, thresholds=parse_thresholds("ok@50,warn@80,alert@95"), gradient=True) +draw.text((20, 175), "65% (warn)", font=font(16), fill=FG) +hbar(draw, 20, 200, 760, 30, 90, thresholds=parse_thresholds("ok@50,warn@80,alert@95"), gradient=True) +draw.text((20, 235), "90% (alert)", font=font(16), fill=FG) + +# Row 2: gradient off +draw.text((20, 280), "Gradient AUS (einfarbig in aktueller Schwelle)", font=font(20), fill=FG) +hbar(draw, 20, 310, 760, 30, 25, thresholds=parse_thresholds("ok@50,warn@80,alert@95"), gradient=False) +draw.text((20, 345), "25% einfarbig green", font=font(16), fill=FG) +hbar(draw, 20, 370, 760, 30, 65, thresholds=parse_thresholds("ok@50,warn@80,alert@95"), gradient=False) +draw.text((20, 405), "65% einfarbig yellow", font=font(16), fill=FG) +hbar(draw, 20, 430, 760, 30, 90, thresholds=parse_thresholds("ok@50,warn@80,alert@95"), gradient=False) +draw.text((20, 465), "90% einfarbig red", font=font(16), fill=FG) + +# Row 3: custom thresholds (mehr Stufen) +draw.text((20, 510), "Custom: 4 Stufen 30/60/80/95, custom colors", font=font(20), fill=FG) +spec = [(30, GREEN), (60, YELLOW), (80, ORANGE), (95, RED)] +hbar(draw, 20, 545, 760, 30, 50, thresholds=spec, gradient=True) +draw.text((20, 580), "50% → yellow segment", font=font(16), fill=FG) + +img.save("/tmp/gradient_demo.png") +print("saved /tmp/gradient_demo.png") diff --git a/test_grid_v2.py b/test_grid_v2.py new file mode 100644 index 0000000..916be9a --- /dev/null +++ b/test_grid_v2.py @@ -0,0 +1,32 @@ +import sys; sys.path.insert(0, ".") +import json +from layout import Item +from dashboard import render_full, load_fonts + +cfg = json.load(open("config.json")) +items = [Item.from_dict(d) for d in cfg["layout"]["items"]] +plugins = cfg.get("plugin_configs", {}) + +from plugins.base import all_widget_classes +classes = {c.name: c for c in all_widget_classes()} +widgets = [] +for it in items: + cls = classes.get(it.plugin) + if cls: + try: + w = cls(plugins.get(it.plugin, {})) + w.fetch() + widgets.append(w) + except Exception as e: + print(f"widget {it.plugin} failed: {e}") + widgets.append(None) + else: + widgets.append(None) + +fonts = load_fonts() +print("items:", [(i.id, i.plugin, i.x, i.y, i.w, i.h) for i in items]) +print("widgets:", [w.name if w else None for w in widgets]) + +img = render_full(items, widgets, fonts) +img.save("/tmp/render_grid_v2.png") +print(f"saved /tmp/render_grid_v2.png size={img.size}") diff --git a/test_minimax_render.py b/test_minimax_render.py new file mode 100644 index 0000000..dd1b988 --- /dev/null +++ b/test_minimax_render.py @@ -0,0 +1,26 @@ +import sys; sys.path.insert(0, ".") +from PIL import Image, ImageDraw, ImageFont +from plugins.minimax import Widget + +class FakeWidget(Widget): + def fetch(self): + return { + "windows": [ + {"label": "5-Hour", "used_pct": 47.0, "remaining_pct": 53.0, "reset_seconds": 1820, "raw": {}}, + {"label": "Weekly", "used_pct": 23.0, "remaining_pct": 77.0, "reset_seconds": 124800, "raw": {}}, + ], + "credits": {"points": 12345.0}, + } + +img = Image.new("RGB", (400, 240), (255, 255, 255)) +draw = ImageDraw.Draw(img) +fonts = { + "16": ImageFont.truetype("fnt/Aldrich-Regular.ttc", 16), + "20": ImageFont.truetype("fnt/Aldrich-Regular.ttc", 20), + "24": ImageFont.truetype("fnt/Aldrich-Regular.ttc", 24), + "28": ImageFont.truetype("fnt/Aldrich-Regular.ttc", 28), +} +w = FakeWidget({"show_credits": True, "label_5h": "5-Hour", "label_weekly": "Weekly"}) +w.render(draw, fonts, 0, 0, 400, 240) +img.save("/tmp/minimax_fake.png") +print("saved /tmp/minimax_fake.png") diff --git a/test_sizes.py b/test_sizes.py new file mode 100644 index 0000000..a8e2562 --- /dev/null +++ b/test_sizes.py @@ -0,0 +1,49 @@ +"""Test: clock als 4x1-Strip (200x120), minimax als 2x2, weather als 2x2 (lower-right). +Zeigt ob Widgets auf verschiedene Groessen reagieren.""" +import sys; sys.path.insert(0, ".") +from PIL import Image, ImageDraw, ImageFont +from layout import Item, GRID_COLS, GRID_ROWS, CELL_W, CELL_H, pack +from plugins.clock import Widget as ClockW +from plugins.minimax import Widget as MiniMaxW +from plugins.weather import Widget as WeatherW +from plugins.hello import Widget as HelloW + +# Force-load fonts +def font(size): + return ImageFont.truetype("fnt/Aldrich-Regular.ttc", size) +fonts = { + "16": font(16), "20": font(20), "24": font(24), + "28": font(28), "32": font(32), "48": font(48), + "60": font(60), "80": font(80), + "clock": ImageFont.truetype("fnt/advanced_led_board-7.ttc", 100), +} + +# Layout: +# clock (4x1) | y=0 +# minimax (2x1) hello (2x1) | y=1 +# weather (4x2) | y=2..3 +items = [ + Item("a", "clock", 0, 0, 4, 1), + Item("b", "clock", 0, 0, 1, 1), # tiny + Item("c", "clock", 0, 0, 2, 2), # large + Item("d", "clock", 0, 0, 4, 4), # full +] + +print("Testing CLOCK widget at different sizes:") +print("="*60) +img = Image.new("RGB", (800, 480), (255,255,255)) +draw = ImageDraw.Draw(img) +# 4x1 strip top +w = ClockW({}) +w.fetch() +w.render(draw, fonts, 0, 0, 800, 120) +# 2x2 below +w = ClockW({"show_date": True}) +w.fetch() +w.render(draw, fonts, 0, 120, 400, 240) +# mini 1x1 bottom-right +w = ClockW({}) +w.fetch() +w.render(draw, fonts, 600, 360, 200, 120) +img.save("/tmp/sizes_clock.png") +print("saved /tmp/sizes_clock.png") diff --git a/test_sizes_mix.py b/test_sizes_mix.py new file mode 100644 index 0000000..d5d6901 --- /dev/null +++ b/test_sizes_mix.py @@ -0,0 +1,57 @@ +"""Test alle Widgets in verschiedenen Groessen, um Layout-Probleme zu finden.""" +import sys; sys.path.insert(0, ".") +from PIL import Image, ImageDraw, ImageFont +from layout import Item +from plugins.clock import Widget as ClockW +from plugins.weather import Widget as WeatherW +from plugins.system import Widget as SystemW +from plugins.minimax import Widget as MiniMaxW +from plugins.hello import Widget as HelloW +import json + +def font(size): + return ImageFont.truetype("fnt/Aldrich-Regular.ttc", size) +fonts = { + "16": font(16), "20": font(20), "24": font(24), + "28": font(28), "32": font(32), "48": font(48), + "60": font(60), "80": font(80), + "clock": ImageFont.truetype("fnt/advanced_led_board-7.ttc", 100), +} + +plugins_cfg = json.load(open("config.json")).get("plugin_configs", {}) + +# Layout test grid: 4x4 +# (0,0)-(3,0): system 4x1 wide +# (0,1)-(1,1): weather 2x1 +# (2,1)-(3,1): minimax 2x1 +# (0,2)-(1,3): hello 2x2 +# (2,2)-(3,3): mix +img = Image.new("RGB", (800, 480), (255,255,255)) +draw = ImageDraw.Draw(img) + +# system 4x1 (full width strip) +w = SystemW(plugins_cfg.get("system", {})) +w.fetch() +w.render(draw, fonts, 0, 0, 800, 120) + +# weather 2x1 +w = WeatherW(plugins_cfg.get("weather", {"location": "52.52,13.41"})) +w.fetch() +w.render(draw, fonts, 0, 120, 400, 120) + +# minimax 2x1 +w = MiniMaxW(plugins_cfg.get("minimax", {})) +w.fetch() +w.render(draw, fonts, 400, 120, 400, 120) + +# hello 2x2 +w = HelloW({"text": "Layout Test", "color": "accent"}) +w.render(draw, fonts, 0, 240, 400, 240) + +# clock 2x2 +w = ClockW({"show_date": True}) +w.fetch() +w.render(draw, fonts, 400, 240, 400, 240) + +img.save("/tmp/sizes_mix.png") +print("saved /tmp/sizes_mix.png") diff --git a/test_system_thresholds.py b/test_system_thresholds.py new file mode 100644 index 0000000..7ffb2cf --- /dev/null +++ b/test_system_thresholds.py @@ -0,0 +1,29 @@ +"""Test: System Widget mit verschiedenen Threshold-Konfigurationen.""" +import sys; sys.path.insert(0, ".") +from PIL import Image, ImageDraw, ImageFont +from plugins.system import Widget as SystemW + +def font(s): return ImageFont.truetype("fnt/Aldrich-Regular.ttc", s) +fonts = {str(s): font(s) for s in [16, 20, 24, 28, 32]} + +img = Image.new("RGB", (800, 480), (255,255,255)) +draw = ImageDraw.Draw(img) + +# Drei Konfigurationen nebeneinander +configs = [ + {"name": "Default (50/80/95, gradient)", + "cfg": {"bar_thresholds": "ok@50,warn@80,alert@95", "bar_gradient": True}}, + {"name": "Strenger (30/60/85, gradient)", + "cfg": {"bar_thresholds": "ok@30,warn@60,alert@85", "bar_gradient": True}}, + {"name": "Einfarbig (40/70/95)", + "cfg": {"bar_thresholds": "ok@40,warn@70,alert@95", "bar_gradient": False}}, +] +for i, c in enumerate(configs): + x = i * 267 + draw.text((x + 8, 5), c["name"], font=font(14), fill=(0,0,0)) + w = SystemW({**c["cfg"], "compact": False}) + w.fetch() + w.render(draw, fonts, x, 30, 260, 200) + +img.save("/tmp/system_thresholds.png") +print("saved /tmp/system_thresholds.png") diff --git a/waveshare_epd_init.py b/waveshare_epd_init.py new file mode 100644 index 0000000..be4fe73 --- /dev/null +++ b/waveshare_epd_init.py @@ -0,0 +1,3 @@ +"""Waveshare 7.3-inch ACeP 7-Color e-Paper (F) — vendored driver.""" +from .epd7in3f import EPD +from .epdconfig import module_exit