ShineBridge v1.11.1 — Feature: Backup-Verwaltung im Einstellungen-Tab

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 <noreply@anthropic.com>
This commit is contained in:
retr0
2026-08-26 12:27:00 +02:00
co-authored by Claude Sonnet 5
parent 3318d1344d
commit b7dc008cd7
3 changed files with 146 additions and 13 deletions
+79 -12
View File
@@ -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/<path:filename>/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/<path:filename>/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", [])