Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7dc008cd7 |
@@ -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
|
||||
|
||||
+79
-12
@@ -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", [])
|
||||
|
||||
@@ -321,6 +321,15 @@
|
||||
</div>
|
||||
<div id="import-result" style="margin-top:.5rem;font-size:.85rem"></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>
|
||||
|
||||
<!-- 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 === "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 = '<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 ───────────────────────────────────────────
|
||||
|
||||
let surplusDevices = [];
|
||||
|
||||
Reference in New Issue
Block a user