Compare commits

...
3 Commits
Author SHA1 Message Date
retr0andClaude Sonnet 5 8be3a7eb47 ShineBridge v1.12.2 — Feature: Solarprognose-Diagramm im Energie-Dashboard
Neues 24h-Balkendiagramm direkt unter dem EPEX-Spot-Chart im Energie-Tab,
nach demselben Muster (renderSpotChart als Vorbild) — stündliche Solar-
strahlung (GHI), aktueller Wert hervorgehoben, Balken über der Sonnenschwelle
gelb statt grau. Nur sichtbar, wenn ein Standort gesetzt ist.

/api/weather-forecast liefert jetzt zusätzlich die rohe 24h-Stundenreihe
(hourly: [{ts, ghi}]) fürs Diagramm, nicht nur die Zusammenfassung fürs
Einstellungen-Tab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:19:45 +02:00
retr0andClaude Sonnet 5 1ce98df596 ShineBridge v1.12.1 — Feature: Wetterprognose-Vorschau im Einstellungen-Tab
v1.12.0 hat die Prognose nur intern verrechnet, nirgends sichtbar gemacht.
Neuer Endpunkt GET /api/weather-forecast (weather_forecast.summary()) liefert
aktuelle Solarstrahlung (naechste 3h/12h Maximum) + ein Beispiel, wie sich der
Zwangslade-Timeout dadurch gerade veraendern wuerde (Basis: erster konfigurierter
Kathrein-Timeout, sonst 2h-Default). Wird direkt unter den Standort-Feldern
angezeigt, aktualisiert sich nach dem Speichern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 16:28:34 +02:00
retr0andClaude Sonnet 5 8f5106e3a0 ShineBridge v1.12.0 — Feature: Prognosebasierter Zwangslade-Timeout (Open-Meteo)
Neuer Standort-Bereich im Einstellungen-Tab (Breitengrad/Längengrad, optional,
site-weit statt pro Gerät). Neues Modul weather_forecast.py fragt Open-Meteos
kostenlose stündliche Solarstrahlungs-Prognose (shortwave_radiation, 48h
voraus, timezone=auto) ab, gecacht alle 3h.

adjusted_timeout_h() passt den Zwangslade-Timeout adaptiv an:
- bald nennenswerte Sonne zu erwarten (naechste 3h) -> Timeout verlaengern
  (x1.75, gedeckelt auf Basiswert+4h)
- auch im 12h-Fenster keine nennenswerte Sonne zu erwarten -> Timeout
  verkuerzen (x0.5, min. 0.5h) statt sinnlos den vollen Timeout abzuwarten
- ohne Standort (lat/lon leer): unveraendertes Verhalten, Feature ist Opt-in

EmsController.update() bekommt einen neuen optionalen timeout_override_h-
Parameter statt selbst Standortdaten zu kennen — Wetter-Logik bleibt im
Poll-Loop, EmsController fokussiert auf die eigentliche Lade-Steuerung.

Auf Sonnenstand-Berechnung bewusst verzichtet — die stuendliche Strahlungs-
prognose bildet Tag/Nacht-Verlauf implizit schon ab, eigene Ephemeriden-
Berechnung waere doppelte Arbeit ohne Mehrwert.

Teil 2 von 2 des 'Prognosebasiertes Laden'-Roadmap-Punkts, damit komplett.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 15:57:04 +02:00
7 changed files with 274 additions and 13 deletions
+4
View File
@@ -80,6 +80,10 @@ Konfigurierbar pro Gerät im Web UI:
>
> Die EMS-Funktion muss zusätzlich in der Kathrein easyOperate-App aktiviert sein.
Die Solarstrahlungs-Prognose wird als eigenes 24h-Balkendiagramm im Energie-Dashboard angezeigt, direkt unter dem EPEX-Spot-Chart (nur sichtbar mit gesetztem Standort).
**Prognosebasierter Zwangslade-Timeout (optional):** Mit Standort (Breitengrad/Längengrad, Einstellungen-Tab) passt sich der Zwangslade-Timeout an die Solarstrahlungs-Prognose (Open-Meteo, kostenlos) an — bei bald zu erwartender Sonne wird länger gewartet (gedeckelt), bei aussichtsloser Prognose eher vom Netz geladen statt sinnlos den vollen Timeout abzuwarten. Ohne Standort unverändertes Verhalten (fester Timeout).
**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)
+1 -1
View File
@@ -20,7 +20,7 @@ Reihenfolge nach Aufwand/Abhängigkeit, nicht nach Wichtigkeit.
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** — 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.
- ~~**Teil B: Wetterprognose**~~ — erledigt (v1.12.0): Standort (Breitengrad/Längengrad) im Einstellungen-Tab, optional. Open-Meteo-Solarstrahlungsprognose (kostenlos, kein Sonnenstand extra nötig — steckt implizit in der stündlichen Prognose) passt den Zwangslade-Timeout adaptiv an: bald Sonne zu erwarten → länger warten (gedeckelt); aussichtslose Prognose → früher vom Netz laden. Ohne Standort unverändertes Verhalten.
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).
+1 -1
View File
@@ -1,5 +1,5 @@
name: ShineBridge
version: "1.11.2"
version: "1.12.2"
slug: shinebridge
description: Energiemanagement für Growatt/Goodwe-Wechselrichter — PV-Überschussladen, Wallbox-EMS, Tarifvergleich, Zigbee2MQTT-Überschussgeräte
url: https://gitea.bitfire.work/retr0/shinebridge
+9 -4
View File
@@ -76,14 +76,18 @@ class EmsController:
return max(6000, min(self.max_charge_ma, ma))
def update(self, wallbox_reader, pv_surplus_w: float, charging_state: int,
wallbox_power_w: float = 0.0) -> str:
wallbox_power_w: float = 0.0, timeout_override_h: Optional[float] = None) -> str:
"""
Wird vom Poll-Loop aufgerufen.
pv_surplus_w: positiv = PV exportiert ins Netz (verbleibender Überschuss)
wallbox_power_w: aktuell von der Wallbox gezogene Leistung (aus Meter-Daten)
timeout_override_h: falls gesetzt, ersetzt self.pv_timeout_h für diesen Aufruf
(Prognose-basierte Anpassung, siehe weather_forecast.adjusted_timeout_h —
wird vom Poll-Loop berechnet, EmsController kennt selbst keine Standortdaten)
Gibt eine kurze Status-Beschreibung zurück.
"""
now = time.time()
effective_timeout_h = timeout_override_h if timeout_override_h is not None else self.pv_timeout_h
# EMS einmalig aktivieren wenn Auto angesteckt
if charging_state in (STATE_CONNECTED, STATE_CHARGING, STATE_PAUSED):
@@ -132,7 +136,7 @@ class EmsController:
no_pv_h = (now - self._no_pv_since) / 3600.0
if no_pv_h >= self.pv_timeout_h:
if no_pv_h >= effective_timeout_h:
# Zwangsladen bis Zielzeit — Strom bei JEDEM Update erneut setzen (nicht nur beim
# ersten Eintritt), sonst bleibt ein einzelner fehlgeschlagener/verworfener Write
# (Modbus-Hänger, Fahrzeug-Störung) unbemerkt für den Rest der Nacht stehen.
@@ -140,8 +144,9 @@ class EmsController:
mins_left = (target - datetime.now()).total_seconds() / 60
if not self._forced_charging:
self._forced_charging = True
log.info("[EMS] Zwangsladen gestartet — %dmin bis %02d:00, max %.0fA",
int(mins_left), self.target_hour, self.max_charge_ma / 1000)
log.info("[EMS] Zwangsladen gestartet — %dmin bis %02d:00, max %.0fA (Timeout %.1fh%s)",
int(mins_left), self.target_hour, self.max_charge_ma / 1000, effective_timeout_h,
" prognose-angepasst" if timeout_override_h is not None else "")
wallbox_reader.set_current(self.max_charge_ma, self.phases)
return f"Zwangsladen ({no_pv_h:.1f}h kein PV, {int(mins_left)}min bis {self.target_hour:02d}:00)"
+32 -2
View File
@@ -15,6 +15,7 @@ from modbus_client import ModbusReader
from goodwe_client import GoodweReader
from wallbox_client import WallboxReader
from ha_entity_client import HAEntityReader
import weather_forecast
from ems_controller import EmsController
from mqtt_publisher import MqttPublisher
from surplus_devices import SurplusDeviceController
@@ -125,6 +126,8 @@ def _defaults() -> Dict[str, Any]:
"surplus_devices": [],
"z2m_base": "zigbee2mqtt",
"config_backup_retention_days": 14,
"site_lat": None,
"site_lon": None,
}
def _load_json_safe(path: str) -> Optional[Dict]:
@@ -176,6 +179,8 @@ def save_config():
"surplus_devices": State.surplus_devices_cfg,
"z2m_base": State.z2m_base,
"config_backup_retention_days": State.mqtt_cfg.get("config_backup_retention_days", 14),
"site_lat": State.mqtt_cfg.get("site_lat"),
"site_lon": State.mqtt_cfg.get("site_lon"),
}
# Backup der letzten guten Config anlegen
if os.path.exists(CONFIG_PATH):
@@ -366,7 +371,10 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
# charging_state aus 0x0060 (solo-Read); fehlt bei kein Fahrzeug → 0 (Idle)
charging_state = int(values.get("charging_state", 0))
wallbox_power = values.get("total_power", 0.0)
ems_status = ems.update(reader, pv_surplus, charging_state, wallbox_power)
site_lat = State.mqtt_cfg.get("site_lat")
site_lon = State.mqtt_cfg.get("site_lon")
timeout_override = weather_forecast.adjusted_timeout_h(ems.pv_timeout_h, site_lat, site_lon)
ems_status = ems.update(reader, pv_surplus, charging_state, wallbox_power, timeout_override)
values["ems_status_code"] = float(charging_state)
log.info("[%s] EMS: %s | PV-Überschuss: %.0fW", inv_id, ems_status, pv_surplus)
@@ -503,6 +511,19 @@ def _restart_all():
# ── REST API ──────────────────────────────────────────────────
@app.get("/api/weather-forecast")
def api_weather_forecast():
with State.lock:
lat = State.mqtt_cfg.get("site_lat")
lon = State.mqtt_cfg.get("site_lon")
# Beispiel-Basiswert: erstes konfigurierte Kathrein-Timeout, sonst 2h-Default
base = next(
(inv.get("ems_timeout") for inv in State.inverters_cfg if inv.get("ems_timeout")),
2.0,
)
return jsonify(weather_forecast.summary(lat, lon, float(base)))
@app.get("/api/config")
def api_get_config():
with State.lock:
@@ -527,6 +548,15 @@ def api_save_config():
State.mqtt_cfg[k] = float(data[k])
except (ValueError, TypeError):
pass
for k in ("site_lat", "site_lon"):
if k in data:
if data[k] in (None, ""):
State.mqtt_cfg[k] = None
else:
try:
State.mqtt_cfg[k] = float(data[k])
except (ValueError, TypeError):
pass
for k in ("billing_day", "billing_month", "config_backup_retention_days"):
if k in data and data[k] is not None:
try:
@@ -1049,7 +1079,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",
"config_backup_retention_days", "site_lat", "site_lon",
) if k in cfg}
State.inverters_cfg = cfg.get("inverters", [])
State.surplus_devices_cfg = cfg.get("surplus_devices", [])
+126
View File
@@ -0,0 +1,126 @@
"""Solarstrahlungs-Prognose über Open-Meteo (kostenlos, kein API-Key nötig).
Wird genutzt, um den Zwangslade-Timeout im EMS-Controller adaptiv zu machen:
bei noch zu erwartender Sonne wird länger gewartet, bei aussichtsloser Prognose
wird eher vom Netz geladen statt sinnlos den vollen Timeout abzuwarten.
"""
import logging
import time
import urllib.error
import urllib.request
import json
from typing import List, Optional, Tuple
log = logging.getLogger(__name__)
_CACHE_TTL_S = 3 * 3600 # Prognose alle 3h neu holen, nicht bei jedem Poll
_cache: dict = {} # key: (lat, lon) -> {"ts": float, "hourly": [(epoch_s, ghi_w_m2), ...]}
def _fetch(lat: float, lon: float) -> Optional[List[Tuple[int, float]]]:
url = (
"https://api.open-meteo.com/v1/forecast"
f"?latitude={lat}&longitude={lon}"
"&hourly=shortwave_radiation&forecast_days=2&timezone=auto"
)
try:
with urllib.request.urlopen(url, timeout=10) as r:
data = json.loads(r.read())
times = data["hourly"]["time"]
values = data["hourly"]["shortwave_radiation"]
# ISO-Zeitstrings (lokale Zeit dank timezone=auto) -> Unix-Timestamp
import datetime
out = []
for t, v in zip(times, values):
dt = datetime.datetime.fromisoformat(t)
out.append((int(dt.timestamp()), float(v or 0.0)))
return out
except (urllib.error.URLError, urllib.error.HTTPError, KeyError, ValueError) as e:
log.warning("[Weather] Open-Meteo Abfrage fehlgeschlagen: %s", e)
return None
def get_hourly_ghi(lat: float, lon: float) -> Optional[List[Tuple[int, float]]]:
"""Liefert [(unix_ts, ghi_w_m2), ...] für die nächsten ~48h, gecacht."""
key = (round(lat, 3), round(lon, 3))
cached = _cache.get(key)
now = time.time()
if cached and now - cached["ts"] < _CACHE_TTL_S:
return cached["hourly"]
hourly = _fetch(lat, lon)
if hourly is None:
return cached["hourly"] if cached else None # alten Stand weiterverwenden statt nichts
_cache[key] = {"ts": now, "hourly": hourly}
return hourly
def adjusted_timeout_h(base_timeout_h: float, lat: Optional[float], lon: Optional[float]) -> float:
"""Passt den Zwangslade-Timeout an die Prognose an. Ohne Standort (lat/lon
nicht gesetzt) unverändert den Basiswert zurückgeben — Feature ist Opt-in."""
if lat is None or lon is None:
return base_timeout_h
hourly = get_hourly_ghi(lat, lon)
if not hourly:
return base_timeout_h
now = time.time()
# Strahlung in den naechsten 3h (kurzfristig: lohnt es sich, gleich noch zu warten?)
near = [ghi for ts, ghi in hourly if now <= ts < now + 3 * 3600]
# Strahlung im Rest des "Tagesfensters" (grob 12h voraus, deckt auch noch
# kommende Vormittagssonne ab, falls schon frueh am Morgen geprueft wird)
horizon = [ghi for ts, ghi in hourly if now <= ts < now + 12 * 3600]
SUN_THRESHOLD = 80.0 # W/m² — deutlich mehr als Daemmerung, aber kein voller Sonnentag noetig
if near and max(near) >= SUN_THRESHOLD:
# Bald noch nennenswerte Sonne zu erwarten -> mehr Geduld, gedeckelt
return min(base_timeout_h * 1.75, base_timeout_h + 4.0)
if horizon and max(horizon) < SUN_THRESHOLD:
# Auch im Rest des Fensters keine nennenswerte Sonne zu erwarten ->
# nicht sinnlos den vollen Timeout abwarten, aber nie unter 0.5h
return max(base_timeout_h * 0.5, 0.5)
return base_timeout_h
def summary(lat: Optional[float], lon: Optional[float], base_timeout_h: float = 2.0) -> dict:
"""Für die UI: aktueller Prognose-Stand + Beispiel, wie sich ein Timeout
dadurch veraendern wuerde. Nicht fuers eigentliche EMS-Update genutzt
(das ruft adjusted_timeout_h direkt auf) — nur zur Anzeige/Diagnose."""
if lat is None or lon is None:
return {"configured": False}
hourly = get_hourly_ghi(lat, lon)
if not hourly:
return {"configured": True, "error": "Prognose aktuell nicht abrufbar"}
now = time.time()
near = [ghi for ts, ghi in hourly if now <= ts < now + 3 * 3600]
horizon = [ghi for ts, ghi in hourly if now <= ts < now + 12 * 3600]
next_3h_max = round(max(near), 0) if near else 0
horizon_12h_max = round(max(horizon), 0) if horizon else 0
adjusted = adjusted_timeout_h(base_timeout_h, lat, lon)
if adjusted > base_timeout_h:
verdict = "verlängert"
elif adjusted < base_timeout_h:
verdict = "verkürzt"
else:
verdict = "unverändert"
# Naechste 24h fuers Diagramm im Dashboard (unter dem Strompreis-Chart)
horizon_24h = [{"ts": ts, "ghi": round(ghi, 0)} for ts, ghi in hourly if now <= ts < now + 24 * 3600]
return {
"configured": True,
"next_3h_max_ghi": next_3h_max,
"horizon_12h_max_ghi": horizon_12h_max,
"sun_threshold": SUN_THRESHOLD,
"example_base_h": base_timeout_h,
"example_adjusted_h": round(adjusted, 2),
"verdict": verdict,
"hourly": horizon_24h,
}
+99 -3
View File
@@ -233,6 +233,19 @@
<button class="btn btn-primary" onclick="saveMqtt()">Speichern & Neu starten</button>
</div>
<div class="settings-section">
<h3>Standort</h3>
<p style="color:var(--text-dim);font-size:.85rem;margin:0 0 .75rem">Für die prognosebasierte Anpassung des Zwangslade-Timeouts (Solarstrahlungs-Vorhersage via Open-Meteo). Optional — leer lassen deaktiviert die Anpassung, Zwangsladen nutzt dann weiter den festen Timeout.</p>
<div style="display:flex;gap:10px;flex-wrap:wrap">
<div class="field" style="max-width:180px"><label>Breitengrad</label>
<input type="number" id="cfg-site-lat" step="0.0001" placeholder="z.B. 52.5200"></div>
<div class="field" style="max-width:180px"><label>Längengrad</label>
<input type="number" id="cfg-site-lon" step="0.0001" placeholder="z.B. 13.4050"></div>
</div>
<button class="btn btn-primary" onclick="saveLocation()">Speichern</button>
<div id="weather-preview" style="margin-top:.75rem;font-size:.85rem"></div>
</div>
<div class="settings-section">
<h3>Stromtarif</h3>
<div class="field">
@@ -541,6 +554,51 @@ function renderSpotChart(spotData) {
</div>`;
}
function renderWeatherChart(weather) {
if (!weather || !weather.configured || weather.error) return '';
const hourly = weather.hourly || [];
if (hourly.length < 2) return '';
const now = Math.floor(Date.now() / 1000);
const maxGhi = Math.max(...hourly.map(h => h.ghi), 100);
const sunCol = g => g >= weather.sun_threshold ? '#f0c040' : g > 0 ? '#5b6472' : '#30363d';
const W=520, H=108, PL=34, PR=8, PT=14, PB=22;
const cW=W-PL-PR, cH=H-PT-PB, bW=cW/hourly.length;
const bars = hourly.map((h,i) => {
const isNow = now >= h.ts && now < h.ts+3600;
const barH = Math.max(2, (h.ghi/maxGhi)*cH);
const x=(PL+i*bW), y=PT+cH-barH;
const col = sunCol(h.ghi);
const hh = new Date(h.ts*1000).getHours();
const tl = hh%6===0 ? `<text x="${(x+bW/2).toFixed(1)}" y="${H-3}" text-anchor="middle" font-size="8" fill="#8b949e">${String(hh).padStart(2,'0')}h</text>` : '';
return `<rect x="${(x+1).toFixed(1)}" y="${y.toFixed(1)}" width="${(bW-2).toFixed(1)}" height="${barH.toFixed(1)}" fill="${col}" opacity="${isNow?1:.7}" rx="1.5"/>
${isNow?`<rect x="${x.toFixed(1)}" y="${PT}" width="${bW.toFixed(1)}" height="${cH}" fill="none" stroke="rgba(255,255,255,.25)" stroke-width="1.5" rx="1.5"/>`:''}
${tl}`;
}).join('');
const yTicks = [[0,PT+cH],[maxGhi,PT]].map(([g,y]) =>
`<line x1="${PL}" y1="${y.toFixed(1)}" x2="${W-PR}" y2="${y.toFixed(1)}" stroke="#30363d" stroke-width="0.5"/>
<text x="${PL-3}" y="${(y+3).toFixed(1)}" text-anchor="end" font-size="8" fill="#8b949e">${g.toFixed(0)}</text>`
).join('');
const cur = hourly.find(h => now>=h.ts && now<h.ts+3600);
const curLabel = cur
? `<span style="font-size:16px;font-weight:700;color:${sunCol(cur.ghi)}">${cur.ghi.toFixed(0)} W/m²</span><span style="font-size:10px;color:#8b949e;margin-left:6px">Jetzt</span>`
: '';
return `<div style="border-top:1px solid #30363d;padding:14px 14px 4px">
<div style="display:flex;align-items:baseline;gap:6px;margin-bottom:8px">
<span style="font-size:10px;font-weight:700;letter-spacing:.08em;color:#8b949e;text-transform:uppercase">Solarprognose · 24h</span>
${curLabel}
</div>
<svg viewBox="0 0 ${W} ${H}" width="100%" style="display:block" xmlns="http://www.w3.org/2000/svg">
${yTicks}${bars}
</svg>
</div>`;
}
function fEur(v) {
if (v == null) return null;
return v >= 100 ? v.toFixed(0) + ' €' : v.toFixed(2) + ' €';
@@ -550,7 +608,7 @@ function fKwh(v) {
return (v >= 100 ? v.toFixed(0) : v.toFixed(1)) + ' kWh';
}
function renderEnergy(inverters, aggregates, period, spotData) {
function renderEnergy(inverters, aggregates, period, spotData, weatherData) {
const el = document.getElementById("energy-content");
if (!aggregates || !Object.keys(aggregates).length) {
el.innerHTML = '<div class="no-data">Warte auf erste Messung…</div>';
@@ -716,6 +774,7 @@ function renderEnergy(inverters, aggregates, period, spotData) {
sectionCards(yr.label || 'Dieses Jahr', yr);
const spotHtml = (period.spot_chart !== false) ? renderSpotChart(spotData) : '';
const weatherHtml = renderWeatherChart(weatherData);
const bt = period.billing_tracker;
const trackerHtml = bt ? (() => {
@@ -751,7 +810,7 @@ function renderEnergy(inverters, aggregates, period, spotData) {
})() : '';
el.innerHTML = `<div class="energy-wrap">
<div class="energy-svg-wrap">${svg}${spotHtml}</div>
<div class="energy-svg-wrap">${svg}${spotHtml}${weatherHtml}</div>
${cards ? `<div style="margin-top:16px">${cards}</div>` : ''}
${trackerHtml}
</div>`;
@@ -961,7 +1020,8 @@ async function refreshData() {
renderLive(d.inverters || {}, d.aggregates || {}, surplusData);
const period = await fetchJSON(api("api/period-energy")).catch(() => ({}));
const spot = await fetchJSON(api("api/spot-price")).catch(() => ({}));
renderEnergy(d.inverters || {}, d.aggregates || {}, period, (spot.data || []));
const weather = await fetchJSON(api("api/weather-forecast")).catch(() => ({}));
renderEnergy(d.inverters || {}, d.aggregates || {}, period, (spot.data || []), weather);
} catch(e) {
document.getElementById("pill-mqtt").className = "pill err";
}
@@ -1238,6 +1298,42 @@ async function loadSettings() {
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;
document.getElementById("cfg-site-lat").value = cfg.site_lat ?? "";
document.getElementById("cfg-site-lon").value = cfg.site_lon ?? "";
loadWeatherPreview();
}
async function saveLocation() {
const lat = document.getElementById("cfg-site-lat").value;
const lon = document.getElementById("cfg-site-lon").value;
try {
await fetchJSON(api("api/config"), {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({ site_lat: lat === "" ? null : lat, site_lon: lon === "" ? null : lon }),
});
showToast("Gespeichert!", "ok");
setTimeout(loadWeatherPreview, 500);
} catch(e) { showToast("Fehler beim Speichern", "err"); }
}
async function loadWeatherPreview() {
const el = document.getElementById("weather-preview");
let data;
try { data = await fetchJSON(api("api/weather-forecast")); }
catch(e) { el.innerHTML = ""; return; }
if (!data.configured) {
el.innerHTML = `<span style="color:var(--text-dim)">Kein Standort gesetzt — Zwangslade-Timeout bleibt fest.</span>`;
return;
}
if (data.error) {
el.innerHTML = `<span style="color:var(--red)">${esc(data.error)}</span>`;
return;
}
const verdictColor = data.verdict === "verlängert" ? "var(--green)" : data.verdict === "verkürzt" ? "var(--orange, #ffa657)" : "var(--text-dim)";
el.innerHTML = `
<div style="color:var(--text-dim)">Solarstrahlung nächste 3h: <strong style="color:var(--text)">${data.next_3h_max_ghi} W/m²</strong> · nächste 12h: <strong style="color:var(--text)">${data.horizon_12h_max_ghi} W/m²</strong></div>
<div style="margin-top:4px;color:${verdictColor}">Beispiel bei ${data.example_base_h}h Basis-Timeout: <strong>${data.example_adjusted_h}h</strong> (${data.verdict})</div>`;
}
async function savePrices() {