From b7dc008cd7a89ece7b89776b64829862073d7c0c Mon Sep 17 00:00:00 2001 From: retr0 <42kdesigners@gmail.com> Date: Wed, 26 Aug 2026 12:27:00 +0200 Subject: [PATCH] =?UTF-8?q?ShineBridge=20v1.11.1=20=E2=80=94=20Feature:=20?= =?UTF-8?q?Backup-Verwaltung=20im=20Einstellungen-Tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Sektion 'Automatische Backups': - Aufbewahrung (Tage) konfigurierbar statt hartcodierter 14 Tage (config_backup_retention_days, Teil der normalen Config-Persistenz) - Liste aller vorhandenen Backups (Datum, Größe), neueste zuerst - Download-Button pro Backup - Wiederherstellen-Button pro Backup (mit Bestätigungsdialog, da überschreibend) Backend: Validierung+Anwendung aus /api/import-config in _apply_import_data() extrahiert, von Datei-Upload und neuem Backup-Restore-Endpunkt gemeinsam genutzt. Neue Endpunkte GET /api/config-backups, GET .../download, POST .../restore — mit Path-Traversal-Schutz (_safe_backup_filename, nur Dateinamen aus dem Backup-Ordner selbst). Co-Authored-By: Claude Sonnet 5 --- haos-addon/config.yaml | 2 +- haos-addon/src/main.py | 91 ++++++++++++++++++++++++++++++----- haos-addon/src/web/index.html | 66 +++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 13 deletions(-) diff --git a/haos-addon/config.yaml b/haos-addon/config.yaml index 521ccd4..5a5712e 100644 --- a/haos-addon/config.yaml +++ b/haos-addon/config.yaml @@ -1,5 +1,5 @@ name: ShineBridge -version: "1.11.0" +version: "1.11.1" slug: shinebridge description: Energiemanagement für Growatt/Goodwe-Wechselrichter — PV-Überschussladen, Wallbox-EMS, Tarifvergleich, Zigbee2MQTT-Überschussgeräte url: https://gitea.bitfire.work/retr0/shinebridge diff --git a/haos-addon/src/main.py b/haos-addon/src/main.py index dc43322..15a3d9a 100644 --- a/haos-addon/src/main.py +++ b/haos-addon/src/main.py @@ -124,6 +124,7 @@ def _defaults() -> Dict[str, Any]: "inverters": [], "surplus_devices": [], "z2m_base": "zigbee2mqtt", + "config_backup_retention_days": 14, } def _load_json_safe(path: str) -> Optional[Dict]: @@ -174,6 +175,7 @@ def save_config(): "inverters": State.inverters_cfg, "surplus_devices": State.surplus_devices_cfg, "z2m_base": State.z2m_base, + "config_backup_retention_days": State.mqtt_cfg.get("config_backup_retention_days", 14), } # Backup der letzten guten Config anlegen if os.path.exists(CONFIG_PATH): @@ -518,7 +520,7 @@ def api_save_config(): State.mqtt_cfg[k] = float(data[k]) except (ValueError, TypeError): pass - for k in ("billing_day", "billing_month"): + for k in ("billing_day", "billing_month", "config_backup_retention_days"): if k in data and data[k] is not None: try: State.mqtt_cfg[k] = int(data[k]) @@ -824,7 +826,7 @@ def _build_export_data() -> Dict[str, Any]: CONFIG_BACKUP_DIR = "/share/shinebridge-backups" -CONFIG_BACKUP_RETENTION_DAYS = 14 +CONFIG_BACKUP_RETENTION_DAYS_DEFAULT = 14 def _export_config_backup(): @@ -839,7 +841,8 @@ def _export_config_backup(): json.dump(_build_export_data(), f, indent=2, ensure_ascii=False) log.info("Config-Backup geschrieben: %s", path) - cutoff = time.time() - CONFIG_BACKUP_RETENTION_DAYS * 86400 + retention_days = State.mqtt_cfg.get("config_backup_retention_days", CONFIG_BACKUP_RETENTION_DAYS_DEFAULT) + cutoff = time.time() - retention_days * 86400 for name in os.listdir(CONFIG_BACKUP_DIR): fpath = os.path.join(CONFIG_BACKUP_DIR, name) if name.startswith("config-") and name.endswith(".json") and os.path.getmtime(fpath) < cutoff: @@ -870,27 +873,28 @@ def api_export_config(): ) -@app.post("/api/import-config") -def api_import_config(): - data = request.get_json(force=True) or {} +def _apply_import_data(data: Dict[str, Any]) -> "tuple[bool, str, int]": + """Validiert + übernimmt eine ShineBridge-Export-Struktur. Gemeinsam genutzt + von /api/import-config (Datei-Upload) und /api/config-backups/.../restore + (automatisches Backup). Gibt (ok, error, anzahl_geräte) zurück.""" if not data.get("shinebridge_export"): - return jsonify({"error": "Keine gültige ShineBridge-Export-Datei"}), 400 + return False, "Keine gültige ShineBridge-Export-Datei", 0 inverters = data.get("inverters", []) if not isinstance(inverters, list): - return jsonify({"error": "inverters ungültig"}), 400 + return False, "inverters ungültig", 0 for inv in inverters: model_id = inv.get("inverter_model") if model_id not in INVERTERS: - return jsonify({"error": f"Unbekanntes Modell: {model_id}"}), 400 + return False, f"Unbekanntes Modell: {model_id}", 0 protocol = INVERTERS[model_id].protocol # modbus_ip wird als generisches Host-Feld fuer alle IP-basierten Protokolle # wiederverwendet (modbus, goodwe_udp, kathrein) — nur ha_entity braucht keins. if protocol == "ha_entity": if not inv.get("ha_entity_prefix"): - return jsonify({"error": f"ha_entity_prefix fehlt in Gerät {inv.get('name', '?')}"}), 400 + return False, f"ha_entity_prefix fehlt in Gerät {inv.get('name', '?')}", 0 elif not inv.get("modbus_ip"): - return jsonify({"error": f"modbus_ip fehlt in Gerät {inv.get('name', '?')}"}), 400 + return False, f"modbus_ip fehlt in Gerät {inv.get('name', '?')}", 0 with State.lock: if mqtt := data.get("mqtt"): @@ -904,7 +908,69 @@ def api_import_config(): save_config() threading.Thread(target=_restart_all, daemon=True).start() - return jsonify({"ok": True, "inverters": len(inverters)}) + return True, "", len(inverters) + + +@app.post("/api/import-config") +def api_import_config(): + data = request.get_json(force=True) or {} + ok, error, count = _apply_import_data(data) + if not ok: + return jsonify({"error": error}), 400 + return jsonify({"ok": True, "inverters": count}) + + +def _safe_backup_filename(filename: str) -> Optional[str]: + """Nur Dateinamen aus CONFIG_BACKUP_DIR selbst zulassen, kein Path-Traversal.""" + name = os.path.basename(filename) + if name != filename or not name.startswith("config-") or not name.endswith(".json"): + return None + path = os.path.join(CONFIG_BACKUP_DIR, name) + return path if os.path.isfile(path) else None + + +@app.get("/api/config-backups") +def api_list_config_backups(): + try: + files = [f for f in os.listdir(CONFIG_BACKUP_DIR) if f.startswith("config-") and f.endswith(".json")] + except OSError: + files = [] + backups = [] + for name in files: + path = os.path.join(CONFIG_BACKUP_DIR, name) + try: + st = os.stat(path) + backups.append({"filename": name, "modified_at": int(st.st_mtime), "size_bytes": st.st_size}) + except OSError: + continue + backups.sort(key=lambda b: b["modified_at"], reverse=True) + return jsonify({ + "backups": backups, + "retention_days": State.mqtt_cfg.get("config_backup_retention_days", CONFIG_BACKUP_RETENTION_DAYS_DEFAULT), + }) + + +@app.get("/api/config-backups//download") +def api_download_config_backup(filename): + path = _safe_backup_filename(filename) + if not path: + return jsonify({"error": "Datei nicht gefunden"}), 404 + from flask import send_file + return send_file(path, mimetype="application/json", as_attachment=True, download_name=os.path.basename(path)) + + +@app.post("/api/config-backups//restore") +def api_restore_config_backup(filename): + path = _safe_backup_filename(filename) + if not path: + return jsonify({"error": "Datei nicht gefunden"}), 404 + data = _load_json_safe(path) + if data is None: + return jsonify({"error": "Backup-Datei ist beschädigt"}), 400 + ok, error, count = _apply_import_data(data) + if not ok: + return jsonify({"error": error}), 400 + return jsonify({"ok": True, "inverters": count}) _hist_spot_cache: Dict[str, Any] = {} # key → {"ts", "avg_ct"} @@ -976,6 +1042,7 @@ if __name__ == "__main__": "price_import", "price_export", "billing_day", "billing_month", "tariff_type", "spot_country", "spot_markup", "spot_chart", "billing_tracker_enabled", "monthly_rate_eur", "grundpreis_eur_per_month", + "config_backup_retention_days", ) if k in cfg} State.inverters_cfg = cfg.get("inverters", []) State.surplus_devices_cfg = cfg.get("surplus_devices", []) diff --git a/haos-addon/src/web/index.html b/haos-addon/src/web/index.html index 0538a31..2b8fc50 100644 --- a/haos-addon/src/web/index.html +++ b/haos-addon/src/web/index.html @@ -321,6 +321,15 @@
+ +
+

Automatische Backups

+

Täglich automatisch nach /share/shinebridge-backups/ gesichert — übersteht auch einen Add-on-Uninstall+Reinstall.

+
+
+ +
+
@@ -477,6 +486,7 @@ function switchTab(name) { }); if (name === "energy" || name === "live") startRefresh(); else stopRefresh(); if (name === "finance") loadFinance(); + if (name === "settings") loadBackupList(); } // ── Energy Dashboard ────────────────────────────────────────── @@ -1227,6 +1237,7 @@ async function loadSettings() { document.getElementById("billing-tracker-fields").style.display = trackerEnabled ? "" : "none"; document.getElementById("cfg-monthly-rate").value = cfg.monthly_rate_eur ?? 0; document.getElementById("cfg-grundpreis").value = cfg.grundpreis_eur_per_month ?? 0; + document.getElementById("cfg-backup-retention").value = cfg.config_backup_retention_days ?? 14; } async function savePrices() { @@ -1304,6 +1315,61 @@ async function importConfig(input) { input.value = ""; } +// ── Automatische Backups ───────────────────────────────────── + +async function saveBackupRetention() { + const days = parseInt(document.getElementById("cfg-backup-retention").value) || 14; + try { + await fetchJSON(api("api/config"), { + method: "POST", headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ config_backup_retention_days: days }), + }); + showToast("Gespeichert!", "ok"); + } catch(e) { showToast("Fehler beim Speichern", "err"); } +} + +function fBytes(n) { + if (n < 1024) return `${n} B`; + if (n < 1024*1024) return `${(n/1024).toFixed(1)} KB`; + return `${(n/1024/1024).toFixed(1)} MB`; +} + +async function loadBackupList() { + const el = document.getElementById("backup-list"); + let data; + try { data = await fetchJSON(api("api/config-backups")); } + catch(e) { el.innerHTML = '
Fehler beim Laden
'; return; } + + const backups = data.backups || []; + if (backups.length === 0) { + el.innerHTML = `
Noch keine Backups — das erste entsteht beim nächsten Add-on-Start oder spätestens morgen.
`; + return; + } + el.innerHTML = backups.map(b => { + const date = new Date(b.modified_at * 1000).toLocaleString('de-DE'); + return `
+
${esc(b.filename)}
${date} · ${fBytes(b.size_bytes)}
+
+ + +
+
`; + }).join(""); +} + +function downloadBackup(filename) { + window.location.href = api(`api/config-backups/${encodeURIComponent(filename)}/download`); +} + +async function restoreBackup(filename) { + if (!confirm(`"${filename}" wiederherstellen? Das überschreibt die aktuelle Konfiguration (Geräte, MQTT) komplett.`)) return; + try { + const res = await fetchJSON(api(`api/config-backups/${encodeURIComponent(filename)}/restore`), { method: "POST" }); + showToast(`${res.inverters} Gerät(e) wiederhergestellt`, "ok"); + setTimeout(() => { loadSettings(); loadInverters(); }, 3000); + } catch(e) { showToast("Wiederherstellen fehlgeschlagen", "err"); } +} + // ── Surplus Devices ─────────────────────────────────────────── let surplusDevices = [];