Compare commits

..
6 Commits
Author SHA1 Message Date
retr0andClaude Sonnet 5 202759cc84 ShineBridge v1.11.2 — Feature: PV-Überschuss batteriebewusst berechnen
_get_pv_surplus() zieht jetzt die aktuelle Batterie-Entladeleistung vom
Netzexport ab, bevor er als PV-Überschuss fürs Auto verwendet wird. Verhindert,
dass PV-Überschussladen bei bewölktem Wetter versehentlich den Hausspeicher
anzapft, wenn der Export eigentlich aus der Batterie statt frischer PV-Produktion
kommt. Betrifft nur PV-Überschussladen — Zwangsladen bleibt bewusst unverändert
netzbasiert. Anlagen ohne Batterie (bat_discharge_power fehlt) verhalten sich
unverändert wie zuvor.

Teil 1 von 2 des 'Prognosebasiertes Laden'-Roadmap-Punkts — Teil 2
(Wetterprognose + Sonnenstand) folgt als eigener Schritt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 15:41:41 +02:00
retr0andClaude Sonnet 5 0f564a561f ROADMAP: endgültige Root Cause des 6,5-Minuten-Zyklus — Schütz in der Wallbox zog nicht an
Reines Hardware-Problem (Leistungskontakt), Steuerungs-/Kommunikationsebene der
Wallbox lief einwandfrei. ShineBridge zu 100% als Ursache ausgeschlossen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 15:10:06 +02:00
retr0andClaude Sonnet 5 c524a0189a ROADMAP: 6,5-Minuten-Zyklus live diagnostiziert — kein ShineBridge-Bug
Live-Test 2026-08-26: Wallbox gibt korrekt und stabil 16A/11kW frei, tatsächlicher
Strom/Leistung blieb bei 0 auf allen drei Phasen. Nutzer bestätigt Auto hing in
einem Lade-Loop. ShineBridge/EMS damit endgültig als Ursache ausgeschlossen —
Verhandlungs-/Kompatibilitätsproblem zwischen Kathrein-Wallbox und Fahrzeug.
Discord-Alerts-Punkt damit auch nicht mehr blockiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 14:31:27 +02:00
retr0andClaude Sonnet 5 4ff91a2674 ROADMAP: Discord-Alerts bewusst hinter Zyklus-Bug-Fix zurückgestellt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 12:50:35 +02:00
retr0andClaude Sonnet 5 117a2d2beb ROADMAP: Backup-Verwaltung als erledigt markiert
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 12:27:23 +02:00
retr0andClaude Sonnet 5 b7dc008cd7 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>
2026-08-26 12:27:00 +02:00
5 changed files with 163 additions and 19 deletions
+2
View File
@@ -80,6 +80,8 @@ Konfigurierbar pro Gerät im Web UI:
> >
> Die EMS-Funktion muss zusätzlich in der Kathrein easyOperate-App aktiviert sein. > Die EMS-Funktion muss zusätzlich in der Kathrein easyOperate-App aktiviert sein.
**Batterie-Schutz beim PV-Überschussladen:** Bei Anlagen mit Hausbatterie (z. B. Goodwe) wird die aktuelle Batterie-Entladeleistung vom berechneten PV-Überschuss abgezogen — ein Netzexport, der eigentlich aus dem Speicher kommt (z. B. bei bewölktem Wetter), wird so nicht fälschlich als PV-Überschuss fürs Auto verwendet. Betrifft nur PV-Überschussladen, Zwangsladen bleibt unverändert netzbasiert.
## Netzleistung (grid_power) ## Netzleistung (grid_power)
Der Sensor `grid_power` zeigt die Netzleistung intuitiv: Der Sensor `grid_power` zeigt die Netzleistung intuitiv:
+6 -4
View File
@@ -4,7 +4,7 @@
## Bekannte Einschränkungen ## Bekannte Einschränkungen
- [ ] Firma: Wallbox lädt in einem ~6,5-Minuten-Zyklus (Laden→Abgeschlossen→Pausiert→Laden) statt durchzuladen — Root Cause noch offen, live-Diagnose folgt beim nächsten Ladevorgang - [x] Firma: Wallbox lädt in einem ~6,5-Minuten-Zyklus (Laden→Abgeschlossen→Pausiert→Laden) statt durchzuladen — **Ursache endgültig gefunden 2026-08-26: Schütz in der Wallbox hat nicht angezogen.** Live-Diagnose zeigte Wallbox meldete korrekt "lädt, 16A frei" (`gewährter Strom`/`ems_stromsollwert` 16000mA), aber 0A auf allen drei Phasen — Steuerungs-/Kommunikationsebene lief einwandfrei, nur der Leistungskontakt selbst hat nie geschlossen. Erklärt auch den Loop: Auto negiert Ladevorgang, bekommt nie echten Strom, setzt immer wieder neu an. **Reines Hardware-Problem, ShineBridge zu 100% ausgeschlossen.** Nach Wallbox-Neustart Status offen — falls Schütz weiter nicht anzieht, braucht es einen Elektriker/Ersatzteil.
- [ ] Growatt-only: Netzbezug nicht direkt messbar — nur Einspeisung via `power_to_grid` - [ ] Growatt-only: Netzbezug nicht direkt messbar — nur Einspeisung via `power_to_grid`
- [ ] Kathrein EVSE-Register 0x0060 (Charging-State) nur lesbar wenn Auto angeschlossen - [ ] Kathrein EVSE-Register 0x0060 (Charging-State) nur lesbar wenn Auto angeschlossen
@@ -14,11 +14,13 @@
Reihenfolge nach Aufwand/Abhängigkeit, nicht nach Wichtigkeit. Reihenfolge nach Aufwand/Abhängigkeit, nicht nach Wichtigkeit.
1. ~~**Automatischer täglicher Config-Export**~~ — erledigt (v1.11.0): täglicher Export nach `/share/shinebridge-backups/`, 14 Tage Aufbewahrung, sofort beim Start plus alle 24h. 1. ~~**Automatischer täglicher Config-Export**~~ — erledigt (v1.11.0–1.11.1): täglicher Export nach `/share/shinebridge-backups/`, Aufbewahrung konfigurierbar (Default 14 Tage), sofort beim Start plus alle 24h. Backup-Liste + Download + Wiederherstellen direkt im Einstellungen-Tab.
2. **Discord-Alerts** — bei Zwangsladen-Start, Fehlerzuständen (unbekannter Ladezustand), besonders günstigen Spot-Preisfenstern. Nutzt den bestehenden Homelab-Discord-Bot. 2. **Discord-Alerts** — bei Zwangsladen-Start, Fehlerzuständen (unbekannter Ladezustand), besonders günstigen Spot-Preisfenstern. Nutzt den bestehenden Homelab-Discord-Bot. War zurückgestellt bis zur Klärung des 6,5-Minuten-Zyklus — **ist jetzt geklärt** (siehe "Bekannte Einschränkungen", war kein ShineBridge-Bug), Punkt kann wieder angegangen werden. Braucht trotzdem generelles Debounce (Mindestabstand pro Alert-Typ, Zustand muss erst eine Weile stabil sein, günstige Preise als tägliche Zusammenfassung statt Live-Alert) — normales Alert-Design, keine Sonderbehandlung mehr nötig.
3. **Sessions pro Fahrzeug trennen** — falls mehrere Fahrer/Autos an einer Wallbox laden, aktuell nur eine Gesamt-Statistik. 3. **Sessions pro Fahrzeug trennen** — falls mehrere Fahrer/Autos an einer Wallbox laden, aktuell nur eine Gesamt-Statistik.
4. **Lastspitzenkappung (Peak Shaving)** — Ladestrom drosseln, wenn Netzbezug (mehrere Autos + Haushalt gleichzeitig) einen Grenzwert überschreitet. Eigenes Sicherheits-Design nötig (was passiert bei Messfehler/Ausfall — nie ungebremst weiterladen). 4. **Lastspitzenkappung (Peak Shaving)** — Ladestrom drosseln, wenn Netzbezug (mehrere Autos + Haushalt gleichzeitig) einen Grenzwert überschreitet. Eigenes Sicherheits-Design nötig (was passiert bei Messfehler/Ausfall — nie ungebremst weiterladen).
5. **Prognosebasiertes Laden** — PV-Wetterprognose (z.B. Open-Meteo/Solcast) + Spot-Preis kombinieren, damit Zwangsladen nicht stur nach Timeout entscheidet, sondern z.B. "morgen wird's sonnig, noch etwas warten" berücksichtigt. Größter Umfang, externe API-Anbindung nötig. 5. **Prognosebasiertes Laden** — in zwei Teilen, hintereinander gebaut:
- ~~**Teil A: Batteriebewusster PV-Überschuss**~~ — erledigt (v1.11.2): Batterie-Entladeleistung wird vom PV-Überschuss abgezogen, damit PV-Überschussladen nie versehentlich den Hausspeicher anzapft (Zwangsladen bleibt unverändert netzbasiert).
- **Teil B: Wetterprognose + Sonnenstand** — PV-Wetterprognose (z.B. Open-Meteo/Solcast) + Spot-Preis kombinieren, damit Zwangsladen nicht stur nach Timeout entscheidet, sondern z.B. "morgen wird's sonnig, noch etwas warten" berücksichtigt. Größter Umfang, externe API-Anbindung nötig.
6. **Chart-Feintuning** — läuft nebenbei mit, keine feste Priorität (z.B. Live-Kosten-Ticker statt nur Rückblick). 6. **Chart-Feintuning** — läuft nebenbei mit, keine feste Priorität (z.B. Live-Kosten-Ticker statt nur Rückblick).
7. **Auto-Cleanup verwaister MQTT-Geräte** — wenn ein Gerät aus ShineBridge entfernt wird, dessen MQTT-Discovery-Topics aktiv abmelden statt sie als tote HA-Geräte-Leichen zurückzulassen (siehe Fund unten). 7. **Auto-Cleanup verwaister MQTT-Geräte** — wenn ein Gerät aus ShineBridge entfernt wird, dessen MQTT-Discovery-Topics aktiv abmelden statt sie als tote HA-Geräte-Leichen zurückzulassen (siehe Fund unten).
+1 -1
View File
@@ -1,5 +1,5 @@
name: ShineBridge name: ShineBridge
version: "1.11.0" version: "1.11.2"
slug: shinebridge slug: shinebridge
description: Energiemanagement für Growatt/Goodwe-Wechselrichter — PV-Überschussladen, Wallbox-EMS, Tarifvergleich, Zigbee2MQTT-Überschussgeräte description: Energiemanagement für Growatt/Goodwe-Wechselrichter — PV-Überschussladen, Wallbox-EMS, Tarifvergleich, Zigbee2MQTT-Überschussgeräte
url: https://gitea.bitfire.work/retr0/shinebridge url: https://gitea.bitfire.work/retr0/shinebridge
+88 -14
View File
@@ -124,6 +124,7 @@ def _defaults() -> Dict[str, Any]:
"inverters": [], "inverters": [],
"surplus_devices": [], "surplus_devices": [],
"z2m_base": "zigbee2mqtt", "z2m_base": "zigbee2mqtt",
"config_backup_retention_days": 14,
} }
def _load_json_safe(path: str) -> Optional[Dict]: def _load_json_safe(path: str) -> Optional[Dict]:
@@ -174,6 +175,7 @@ def save_config():
"inverters": State.inverters_cfg, "inverters": State.inverters_cfg,
"surplus_devices": State.surplus_devices_cfg, "surplus_devices": State.surplus_devices_cfg,
"z2m_base": State.z2m_base, "z2m_base": State.z2m_base,
"config_backup_retention_days": State.mqtt_cfg.get("config_backup_retention_days", 14),
} }
# Backup der letzten guten Config anlegen # Backup der letzten guten Config anlegen
if os.path.exists(CONFIG_PATH): if os.path.exists(CONFIG_PATH):
@@ -221,9 +223,16 @@ def _compute_aggregates(allow_stale: bool = False) -> Dict[str, float]:
# ── EMS Hilfsfunktionen ─────────────────────────────────────── # ── EMS Hilfsfunktionen ───────────────────────────────────────
def _get_pv_surplus() -> float: def _get_pv_surplus() -> float:
"""PV-Überschuss in Watt. Nutzt grid_power-Aggregat (negativ = Einspeisung).""" """PV-Überschuss in Watt für PV-Überschussladen (nicht für Zwangsladen — das
bleibt unverändert netzbasiert). Netzexport (grid_power negativ = Einspeisung)
abzüglich aktueller Batterie-Entladeleistung, damit ein Überschuss, der
tatsächlich aus dem Hausspeicher kommt (z.B. bei bewölktem Wetter), nicht
fälschlich als PV-Überschuss fürs Auto verwendet wird. Anlagen ohne Batterie
(bat_discharge_power fehlt) verhalten sich unverändert wie zuvor."""
agg = _compute_aggregates(allow_stale=True) agg = _compute_aggregates(allow_stale=True)
return max(0.0, -agg.get("grid_power", 0.0)) grid_export = max(0.0, -agg.get("grid_power", 0.0))
bat_discharge = agg.get("bat_discharge_power", 0.0)
return max(0.0, grid_export - bat_discharge)
# ── Poll Loop ───────────────────────────────────────────────── # ── Poll Loop ─────────────────────────────────────────────────
@@ -518,7 +527,7 @@ def api_save_config():
State.mqtt_cfg[k] = float(data[k]) State.mqtt_cfg[k] = float(data[k])
except (ValueError, TypeError): except (ValueError, TypeError):
pass 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: if k in data and data[k] is not None:
try: try:
State.mqtt_cfg[k] = int(data[k]) State.mqtt_cfg[k] = int(data[k])
@@ -824,7 +833,7 @@ def _build_export_data() -> Dict[str, Any]:
CONFIG_BACKUP_DIR = "/share/shinebridge-backups" CONFIG_BACKUP_DIR = "/share/shinebridge-backups"
CONFIG_BACKUP_RETENTION_DAYS = 14 CONFIG_BACKUP_RETENTION_DAYS_DEFAULT = 14
def _export_config_backup(): def _export_config_backup():
@@ -839,7 +848,8 @@ def _export_config_backup():
json.dump(_build_export_data(), f, indent=2, ensure_ascii=False) json.dump(_build_export_data(), f, indent=2, ensure_ascii=False)
log.info("Config-Backup geschrieben: %s", path) 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): for name in os.listdir(CONFIG_BACKUP_DIR):
fpath = os.path.join(CONFIG_BACKUP_DIR, name) fpath = os.path.join(CONFIG_BACKUP_DIR, name)
if name.startswith("config-") and name.endswith(".json") and os.path.getmtime(fpath) < cutoff: if name.startswith("config-") and name.endswith(".json") and os.path.getmtime(fpath) < cutoff:
@@ -870,27 +880,28 @@ def api_export_config():
) )
@app.post("/api/import-config") def _apply_import_data(data: Dict[str, Any]) -> "tuple[bool, str, int]":
def api_import_config(): """Validiert + übernimmt eine ShineBridge-Export-Struktur. Gemeinsam genutzt
data = request.get_json(force=True) or {} 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"): 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", []) inverters = data.get("inverters", [])
if not isinstance(inverters, list): if not isinstance(inverters, list):
return jsonify({"error": "inverters ungültig"}), 400 return False, "inverters ungültig", 0
for inv in inverters: for inv in inverters:
model_id = inv.get("inverter_model") model_id = inv.get("inverter_model")
if model_id not in INVERTERS: 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 protocol = INVERTERS[model_id].protocol
# modbus_ip wird als generisches Host-Feld fuer alle IP-basierten Protokolle # modbus_ip wird als generisches Host-Feld fuer alle IP-basierten Protokolle
# wiederverwendet (modbus, goodwe_udp, kathrein) — nur ha_entity braucht keins. # wiederverwendet (modbus, goodwe_udp, kathrein) — nur ha_entity braucht keins.
if protocol == "ha_entity": if protocol == "ha_entity":
if not inv.get("ha_entity_prefix"): 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"): 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: with State.lock:
if mqtt := data.get("mqtt"): if mqtt := data.get("mqtt"):
@@ -904,7 +915,69 @@ def api_import_config():
save_config() save_config()
threading.Thread(target=_restart_all, daemon=True).start() 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"} _hist_spot_cache: Dict[str, Any] = {} # key → {"ts", "avg_ct"}
@@ -976,6 +1049,7 @@ if __name__ == "__main__":
"price_import", "price_export", "billing_day", "billing_month", "price_import", "price_export", "billing_day", "billing_month",
"tariff_type", "spot_country", "spot_markup", "spot_chart", "tariff_type", "spot_country", "spot_markup", "spot_chart",
"billing_tracker_enabled", "monthly_rate_eur", "grundpreis_eur_per_month", "billing_tracker_enabled", "monthly_rate_eur", "grundpreis_eur_per_month",
"config_backup_retention_days",
) if k in cfg} ) if k in cfg}
State.inverters_cfg = cfg.get("inverters", []) State.inverters_cfg = cfg.get("inverters", [])
State.surplus_devices_cfg = cfg.get("surplus_devices", []) State.surplus_devices_cfg = cfg.get("surplus_devices", [])
+66
View File
@@ -321,6 +321,15 @@
</div> </div>
<div id="import-result" style="margin-top:.5rem;font-size:.85rem"></div> <div id="import-result" style="margin-top:.5rem;font-size:.85rem"></div>
</div> </div>
<div class="settings-section">
<h3>Automatische Backups</h3>
<p style="color:var(--text-dim);font-size:.85rem;margin:0 0 .75rem">Täglich automatisch nach <code style="font-size:11px;background:var(--surface2);padding:1px 5px;border-radius:3px">/share/shinebridge-backups/</code> gesichert — übersteht auch einen Add-on-Uninstall+Reinstall.</p>
<div class="field" style="max-width:200px"><label>Aufbewahrung (Tage)</label>
<input type="number" id="cfg-backup-retention" min="1" max="365" value="14"></div>
<button class="btn btn-primary" onclick="saveBackupRetention()">Speichern</button>
<div id="backup-list" style="margin-top:1rem;display:flex;flex-direction:column;gap:6px"></div>
</div>
</div> </div>
<!-- Flash/OTA-Panel ausgelagert nach growatt-diagnose-tool (v1.9.0) --> <!-- Flash/OTA-Panel ausgelagert nach growatt-diagnose-tool (v1.9.0) -->
@@ -477,6 +486,7 @@ function switchTab(name) {
}); });
if (name === "energy" || name === "live") startRefresh(); else stopRefresh(); if (name === "energy" || name === "live") startRefresh(); else stopRefresh();
if (name === "finance") loadFinance(); if (name === "finance") loadFinance();
if (name === "settings") loadBackupList();
} }
// ── Energy Dashboard ────────────────────────────────────────── // ── Energy Dashboard ──────────────────────────────────────────
@@ -1227,6 +1237,7 @@ async function loadSettings() {
document.getElementById("billing-tracker-fields").style.display = trackerEnabled ? "" : "none"; document.getElementById("billing-tracker-fields").style.display = trackerEnabled ? "" : "none";
document.getElementById("cfg-monthly-rate").value = cfg.monthly_rate_eur ?? 0; 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-grundpreis").value = cfg.grundpreis_eur_per_month ?? 0;
document.getElementById("cfg-backup-retention").value = cfg.config_backup_retention_days ?? 14;
} }
async function savePrices() { async function savePrices() {
@@ -1304,6 +1315,61 @@ async function importConfig(input) {
input.value = ""; 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 = '<div class="no-data">Fehler beim Laden</div>'; return; }
const backups = data.backups || [];
if (backups.length === 0) {
el.innerHTML = `<div style="font-size:12px;color:var(--text-dim)">Noch keine Backups — das erste entsteht beim nächsten Add-on-Start oder spätestens morgen.</div>`;
return;
}
el.innerHTML = backups.map(b => {
const date = new Date(b.modified_at * 1000).toLocaleString('de-DE');
return `<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;background:var(--surface2);border:1px solid var(--border);border-radius:6px;padding:8px 12px;font-size:13px">
<div><strong>${esc(b.filename)}</strong><br><span style="color:var(--text-dim);font-size:11px">${date} · ${fBytes(b.size_bytes)}</span></div>
<div style="display:flex;gap:6px;flex-shrink:0">
<button class="btn" style="padding:4px 10px;font-size:12px" onclick="downloadBackup('${esc(b.filename)}')">Download</button>
<button class="btn btn-danger" style="padding:4px 10px;font-size:12px" onclick="restoreBackup('${esc(b.filename)}')">Wiederherstellen</button>
</div>
</div>`;
}).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 ─────────────────────────────────────────── // ── Surplus Devices ───────────────────────────────────────────
let surplusDevices = []; let surplusDevices = [];