Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd5f8cb7af | ||
|
|
8be3a7eb47 | ||
|
|
1ce98df596 |
@@ -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.
|
||||||
|
|
||||||
|
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).
|
**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.
|
**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.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: ShineBridge
|
name: ShineBridge
|
||||||
version: "1.12.0"
|
version: "1.12.3"
|
||||||
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
|
||||||
|
|||||||
@@ -511,6 +511,19 @@ def _restart_all():
|
|||||||
|
|
||||||
# ── REST API ──────────────────────────────────────────────────
|
# ── 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")
|
@app.get("/api/config")
|
||||||
def api_get_config():
|
def api_get_config():
|
||||||
with State.lock:
|
with State.lock:
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ log = logging.getLogger(__name__)
|
|||||||
_CACHE_TTL_S = 3 * 3600 # Prognose alle 3h neu holen, nicht bei jedem Poll
|
_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), ...]}
|
_cache: dict = {} # key: (lat, lon) -> {"ts": float, "hourly": [(epoch_s, ghi_w_m2), ...]}
|
||||||
|
|
||||||
|
SUN_THRESHOLD = 80.0 # W/m² — deutlich mehr als Daemmerung, aber kein voller Sonnentag noetig
|
||||||
|
|
||||||
|
|
||||||
def _fetch(lat: float, lon: float) -> Optional[List[Tuple[int, float]]]:
|
def _fetch(lat: float, lon: float) -> Optional[List[Tuple[int, float]]]:
|
||||||
url = (
|
url = (
|
||||||
@@ -74,8 +76,6 @@ def adjusted_timeout_h(base_timeout_h: float, lat: Optional[float], lon: Optiona
|
|||||||
# kommende Vormittagssonne ab, falls schon frueh am Morgen geprueft wird)
|
# kommende Vormittagssonne ab, falls schon frueh am Morgen geprueft wird)
|
||||||
horizon = [ghi for ts, ghi in hourly if now <= ts < now + 12 * 3600]
|
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:
|
if near and max(near) >= SUN_THRESHOLD:
|
||||||
# Bald noch nennenswerte Sonne zu erwarten -> mehr Geduld, gedeckelt
|
# Bald noch nennenswerte Sonne zu erwarten -> mehr Geduld, gedeckelt
|
||||||
return min(base_timeout_h * 1.75, base_timeout_h + 4.0)
|
return min(base_timeout_h * 1.75, base_timeout_h + 4.0)
|
||||||
@@ -84,3 +84,43 @@ def adjusted_timeout_h(base_timeout_h: float, lat: Optional[float], lon: Optiona
|
|||||||
# nicht sinnlos den vollen Timeout abwarten, aber nie unter 0.5h
|
# nicht sinnlos den vollen Timeout abwarten, aber nie unter 0.5h
|
||||||
return max(base_timeout_h * 0.5, 0.5)
|
return max(base_timeout_h * 0.5, 0.5)
|
||||||
return base_timeout_h
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -243,6 +243,7 @@
|
|||||||
<input type="number" id="cfg-site-lon" step="0.0001" placeholder="z.B. 13.4050"></div>
|
<input type="number" id="cfg-site-lon" step="0.0001" placeholder="z.B. 13.4050"></div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" onclick="saveLocation()">Speichern</button>
|
<button class="btn btn-primary" onclick="saveLocation()">Speichern</button>
|
||||||
|
<div id="weather-preview" style="margin-top:.75rem;font-size:.85rem"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
@@ -553,6 +554,51 @@ function renderSpotChart(spotData) {
|
|||||||
</div>`;
|
</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) {
|
function fEur(v) {
|
||||||
if (v == null) return null;
|
if (v == null) return null;
|
||||||
return v >= 100 ? v.toFixed(0) + ' €' : v.toFixed(2) + ' €';
|
return v >= 100 ? v.toFixed(0) + ' €' : v.toFixed(2) + ' €';
|
||||||
@@ -562,7 +608,7 @@ function fKwh(v) {
|
|||||||
return (v >= 100 ? v.toFixed(0) : v.toFixed(1)) + ' kWh';
|
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");
|
const el = document.getElementById("energy-content");
|
||||||
if (!aggregates || !Object.keys(aggregates).length) {
|
if (!aggregates || !Object.keys(aggregates).length) {
|
||||||
el.innerHTML = '<div class="no-data">Warte auf erste Messung…</div>';
|
el.innerHTML = '<div class="no-data">Warte auf erste Messung…</div>';
|
||||||
@@ -728,6 +774,7 @@ function renderEnergy(inverters, aggregates, period, spotData) {
|
|||||||
sectionCards(yr.label || 'Dieses Jahr', yr);
|
sectionCards(yr.label || 'Dieses Jahr', yr);
|
||||||
|
|
||||||
const spotHtml = (period.spot_chart !== false) ? renderSpotChart(spotData) : '';
|
const spotHtml = (period.spot_chart !== false) ? renderSpotChart(spotData) : '';
|
||||||
|
const weatherHtml = renderWeatherChart(weatherData);
|
||||||
|
|
||||||
const bt = period.billing_tracker;
|
const bt = period.billing_tracker;
|
||||||
const trackerHtml = bt ? (() => {
|
const trackerHtml = bt ? (() => {
|
||||||
@@ -763,7 +810,7 @@ function renderEnergy(inverters, aggregates, period, spotData) {
|
|||||||
})() : '';
|
})() : '';
|
||||||
|
|
||||||
el.innerHTML = `<div class="energy-wrap">
|
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>` : ''}
|
${cards ? `<div style="margin-top:16px">${cards}</div>` : ''}
|
||||||
${trackerHtml}
|
${trackerHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -973,7 +1020,8 @@ async function refreshData() {
|
|||||||
renderLive(d.inverters || {}, d.aggregates || {}, surplusData);
|
renderLive(d.inverters || {}, d.aggregates || {}, surplusData);
|
||||||
const period = await fetchJSON(api("api/period-energy")).catch(() => ({}));
|
const period = await fetchJSON(api("api/period-energy")).catch(() => ({}));
|
||||||
const spot = await fetchJSON(api("api/spot-price")).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) {
|
} catch(e) {
|
||||||
document.getElementById("pill-mqtt").className = "pill err";
|
document.getElementById("pill-mqtt").className = "pill err";
|
||||||
}
|
}
|
||||||
@@ -1252,6 +1300,7 @@ async function loadSettings() {
|
|||||||
document.getElementById("cfg-backup-retention").value = cfg.config_backup_retention_days ?? 14;
|
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-lat").value = cfg.site_lat ?? "";
|
||||||
document.getElementById("cfg-site-lon").value = cfg.site_lon ?? "";
|
document.getElementById("cfg-site-lon").value = cfg.site_lon ?? "";
|
||||||
|
loadWeatherPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveLocation() {
|
async function saveLocation() {
|
||||||
@@ -1263,9 +1312,30 @@ async function saveLocation() {
|
|||||||
body: JSON.stringify({ site_lat: lat === "" ? null : lat, site_lon: lon === "" ? null : lon }),
|
body: JSON.stringify({ site_lat: lat === "" ? null : lat, site_lon: lon === "" ? null : lon }),
|
||||||
});
|
});
|
||||||
showToast("Gespeichert!", "ok");
|
showToast("Gespeichert!", "ok");
|
||||||
|
setTimeout(loadWeatherPreview, 500);
|
||||||
} catch(e) { showToast("Fehler beim Speichern", "err"); }
|
} 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() {
|
async function savePrices() {
|
||||||
const isSpot = document.getElementById("tariff-spot").checked;
|
const isSpot = document.getElementById("tariff-spot").checked;
|
||||||
const body = {
|
const body = {
|
||||||
|
|||||||
Reference in New Issue
Block a user