- 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
546 lines
19 KiB
Python
546 lines
19 KiB
Python
"""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/<idx> → 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. <port>/.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_<idx>_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/<int:idx>", 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/<iid>", 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/<plugin_name>", 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()
|