Feature: Flexibler Stromtarif (Börsenstrom) in Einstellungen + Periodenabrechnung (v1.8.1)
- Tarifart: Festpreis / Flexibel (Börsenstrom) — Radio-Toggle in Einstellungen - Flexibel: Aufschlag (ct/kWh) + Land (DE/AT) konfigurierbar - /api/period-energy: bei tariff_type=spot → historische aWATTar-Preise, 1h Cache - Kostenrechnung Monat/Jahr: Ø Börsenpreis + Aufschlag statt Festpreis - Periodenkarte zeigt "Ø X.X + Y.Y ct/kWh" bei Börsentarif - Spot-Chart-Toggle in Einstellungen (spot_chart true/false) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
name: ShineBridge
|
name: ShineBridge
|
||||||
version: "1.8.0"
|
version: "1.8.1"
|
||||||
slug: shinebridge
|
slug: shinebridge
|
||||||
description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI
|
description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI
|
||||||
url: https://gitea.bitfire.work/retr0/shinebridge
|
url: https://gitea.bitfire.work/retr0/shinebridge
|
||||||
|
|||||||
+68
-11
@@ -90,6 +90,10 @@ def _defaults() -> Dict[str, Any]:
|
|||||||
"price_export": 0.08,
|
"price_export": 0.08,
|
||||||
"billing_day": 1,
|
"billing_day": 1,
|
||||||
"billing_month": 1,
|
"billing_month": 1,
|
||||||
|
"tariff_type": "fixed",
|
||||||
|
"spot_country": "de",
|
||||||
|
"spot_markup": 0.0,
|
||||||
|
"spot_chart": True,
|
||||||
"inverters": [],
|
"inverters": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +126,10 @@ def save_config():
|
|||||||
"price_export": State.mqtt_cfg.get("price_export", 0.08),
|
"price_export": State.mqtt_cfg.get("price_export", 0.08),
|
||||||
"billing_day": State.mqtt_cfg.get("billing_day", 1),
|
"billing_day": State.mqtt_cfg.get("billing_day", 1),
|
||||||
"billing_month": State.mqtt_cfg.get("billing_month", 1),
|
"billing_month": State.mqtt_cfg.get("billing_month", 1),
|
||||||
|
"tariff_type": State.mqtt_cfg.get("tariff_type", "fixed"),
|
||||||
|
"spot_country": State.mqtt_cfg.get("spot_country", "de"),
|
||||||
|
"spot_markup": State.mqtt_cfg.get("spot_markup", 0.0),
|
||||||
|
"spot_chart": State.mqtt_cfg.get("spot_chart", True),
|
||||||
"inverters": State.inverters_cfg,
|
"inverters": State.inverters_cfg,
|
||||||
}
|
}
|
||||||
with open(CONFIG_PATH, "w") as f:
|
with open(CONFIG_PATH, "w") as f:
|
||||||
@@ -346,12 +354,17 @@ def api_save_config():
|
|||||||
State.mqtt_cfg[k] = data[k]
|
State.mqtt_cfg[k] = data[k]
|
||||||
if data.get("mqtt_pass"):
|
if data.get("mqtt_pass"):
|
||||||
State.mqtt_cfg["mqtt_pass"] = data["mqtt_pass"]
|
State.mqtt_cfg["mqtt_pass"] = data["mqtt_pass"]
|
||||||
for k in ("price_import", "price_export"):
|
for k in ("price_import", "price_export", "spot_markup"):
|
||||||
if k in data:
|
if k in data:
|
||||||
State.mqtt_cfg[k] = float(data[k])
|
State.mqtt_cfg[k] = float(data[k])
|
||||||
for k in ("billing_day", "billing_month"):
|
for k in ("billing_day", "billing_month"):
|
||||||
if k in data:
|
if k in data:
|
||||||
State.mqtt_cfg[k] = int(data[k])
|
State.mqtt_cfg[k] = int(data[k])
|
||||||
|
for k in ("tariff_type", "spot_country"):
|
||||||
|
if k in data:
|
||||||
|
State.mqtt_cfg[k] = str(data[k])
|
||||||
|
if "spot_chart" in data:
|
||||||
|
State.mqtt_cfg["spot_chart"] = bool(data["spot_chart"])
|
||||||
save_config()
|
save_config()
|
||||||
threading.Thread(target=_restart_all, daemon=True).start()
|
threading.Thread(target=_restart_all, daemon=True).start()
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
@@ -365,29 +378,34 @@ def api_period_energy():
|
|||||||
price_export = float(State.mqtt_cfg.get("price_export", 0.08))
|
price_export = float(State.mqtt_cfg.get("price_export", 0.08))
|
||||||
billing_day = int(State.mqtt_cfg.get("billing_day", 1))
|
billing_day = int(State.mqtt_cfg.get("billing_day", 1))
|
||||||
billing_month = int(State.mqtt_cfg.get("billing_month", 1))
|
billing_month = int(State.mqtt_cfg.get("billing_month", 1))
|
||||||
|
tariff_type = str(State.mqtt_cfg.get("tariff_type", "fixed"))
|
||||||
|
spot_country = str(State.mqtt_cfg.get("spot_country", "de"))
|
||||||
|
spot_markup = float(State.mqtt_cfg.get("spot_markup", 0.0)) # ct/kWh
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"price_import": price_import,
|
"price_import": price_import,
|
||||||
"price_export": price_export,
|
"price_export": price_export,
|
||||||
"billing_day": billing_day,
|
"tariff_type": tariff_type,
|
||||||
"billing_month": billing_month,
|
"spot_chart": bool(State.mqtt_cfg.get("spot_chart", True)),
|
||||||
}
|
}
|
||||||
|
|
||||||
MONTHS_DE = ["","Jan","Feb","Mär","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"]
|
MONTHS_DE = ["","Jan","Feb","Mär","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"]
|
||||||
|
now_ts = time.time()
|
||||||
|
|
||||||
for period_type in ("monthly", "yearly"):
|
for period_type in ("monthly", "yearly"):
|
||||||
key = history.period_key(period_type, billing_day, billing_month)
|
key = history.period_key(period_type, billing_day, billing_month)
|
||||||
entry = {}
|
entry = {}
|
||||||
|
|
||||||
# Lesbare Beschriftung
|
# Perioden-Startzeitpunkt für Spot-Preisabfrage
|
||||||
if period_type == "monthly":
|
if period_type == "monthly":
|
||||||
d = datetime.date.fromisoformat(key + "-01")
|
pd = datetime.date.fromisoformat(key + "-01")
|
||||||
entry["label"] = f"{MONTHS_DE[d.month]} {d.year}"
|
entry["label"] = f"{MONTHS_DE[pd.month]} {pd.year}"
|
||||||
else:
|
else:
|
||||||
d = datetime.date.fromisoformat(key)
|
pd = datetime.date.fromisoformat(key)
|
||||||
end = datetime.date(d.year + 1, billing_month, billing_day) if billing_month != 1 or billing_day != 1 \
|
end_d = datetime.date(pd.year + 1, billing_month, billing_day) if billing_month != 1 or billing_day != 1 \
|
||||||
else datetime.date(d.year + 1, 1, 1)
|
else datetime.date(pd.year + 1, 1, 1)
|
||||||
entry["label"] = f"{d.strftime('%d.%m.%Y')} – {(end - datetime.timedelta(days=1)).strftime('%d.%m.%Y')}"
|
entry["label"] = f"{pd.strftime('%d.%m.%Y')} – {(end_d - datetime.timedelta(days=1)).strftime('%d.%m.%Y')}"
|
||||||
|
pd_start_ts = datetime.datetime.combine(pd, datetime.time.min).timestamp()
|
||||||
|
|
||||||
for agg_id in ("grid_import_kwh", "grid_export_kwh"):
|
for agg_id in ("grid_import_kwh", "grid_export_kwh"):
|
||||||
cur = agg.get(agg_id)
|
cur = agg.get(agg_id)
|
||||||
@@ -397,10 +415,24 @@ def api_period_energy():
|
|||||||
val = history.get_period_consumption(agg_id, period_type, key, cur)
|
val = history.get_period_consumption(agg_id, period_type, key, cur)
|
||||||
if val is not None:
|
if val is not None:
|
||||||
entry[agg_id] = round(val, 2)
|
entry[agg_id] = round(val, 2)
|
||||||
|
|
||||||
|
# Kostenberechnung: Festpreis oder Börsenpreis
|
||||||
if "grid_import_kwh" in entry:
|
if "grid_import_kwh" in entry:
|
||||||
entry["import_cost"] = round(entry["grid_import_kwh"] * price_import, 2)
|
if tariff_type == "spot":
|
||||||
|
avg_ct = _get_avg_spot_price(pd_start_ts, now_ts, spot_country)
|
||||||
|
if avg_ct is not None:
|
||||||
|
eff_price = (avg_ct + spot_markup) / 100 # ct/kWh → €/kWh
|
||||||
|
entry["import_cost"] = round(entry["grid_import_kwh"] * eff_price, 2)
|
||||||
|
entry["spot_avg_ct"] = avg_ct
|
||||||
|
entry["spot_markup_ct"] = spot_markup
|
||||||
|
entry["effective_price"]= round(eff_price, 4)
|
||||||
|
else:
|
||||||
|
entry["import_cost"] = round(entry["grid_import_kwh"] * price_import, 2)
|
||||||
|
else:
|
||||||
|
entry["import_cost"] = round(entry["grid_import_kwh"] * price_import, 2)
|
||||||
if "grid_export_kwh" in entry:
|
if "grid_export_kwh" in entry:
|
||||||
entry["export_revenue"] = round(entry["grid_export_kwh"] * price_export, 2)
|
entry["export_revenue"] = round(entry["grid_export_kwh"] * price_export, 2)
|
||||||
|
|
||||||
result[period_type] = entry
|
result[period_type] = entry
|
||||||
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
@@ -545,6 +577,31 @@ def api_import_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 jsonify({"ok": True, "inverters": len(inverters)})
|
||||||
|
|
||||||
|
_hist_spot_cache: Dict[str, Any] = {} # key → {"ts", "avg_ct"}
|
||||||
|
|
||||||
|
def _get_avg_spot_price(start_ts: float, end_ts: float, country: str = "de") -> Optional[float]:
|
||||||
|
"""Durchschnittlicher EPEX-SPOT-Preis (ct/kWh) für einen Zeitraum. None bei Fehler."""
|
||||||
|
import urllib.request as _ur
|
||||||
|
cache_key = f"{country}-{int(start_ts//3600)}-{int(end_ts//3600)}"
|
||||||
|
now = time.time()
|
||||||
|
cached = _hist_spot_cache.get(cache_key)
|
||||||
|
if cached and now - cached["ts"] < 3600:
|
||||||
|
return cached["avg_ct"]
|
||||||
|
try:
|
||||||
|
url = (f"https://api.awattar.{country}/v1/marketdata"
|
||||||
|
f"?start={int(start_ts*1000)}&end={int(end_ts*1000)}")
|
||||||
|
with _ur.urlopen(url, timeout=10) as r:
|
||||||
|
raw = json.loads(r.read())
|
||||||
|
prices = [d["marketprice"] / 10 for d in raw.get("data", [])]
|
||||||
|
if not prices:
|
||||||
|
return None
|
||||||
|
avg = round(sum(prices) / len(prices), 2)
|
||||||
|
_hist_spot_cache[cache_key] = {"ts": now, "avg_ct": avg}
|
||||||
|
return avg
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Historischer Spot-Preis Fehler: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
_spot_cache: Dict[str, Any] = {"ts": 0.0, "data": []}
|
_spot_cache: Dict[str, Any] = {"ts": 0.0, "data": []}
|
||||||
_SPOT_TTL = 900 # 15 Minuten
|
_SPOT_TTL = 900 # 15 Minuten
|
||||||
|
|
||||||
|
|||||||
@@ -208,11 +208,40 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Strompreise</h3>
|
<h3>Stromtarif</h3>
|
||||||
<div class="field"><label>Bezugspreis (€/kWh)</label>
|
<div class="field">
|
||||||
<input type="number" id="cfg-price-import" step="0.001" placeholder="0.300"></div>
|
<label>Tarifart</label>
|
||||||
|
<div style="display:flex;gap:16px;margin-top:4px">
|
||||||
|
<label style="cursor:pointer;display:flex;align-items:center;gap:6px;font-size:13px">
|
||||||
|
<input type="radio" name="tariff-type" value="fixed" id="tariff-fixed" onchange="updateTariffUI()"> Festpreis
|
||||||
|
</label>
|
||||||
|
<label style="cursor:pointer;display:flex;align-items:center;gap:6px;font-size:13px">
|
||||||
|
<input type="radio" name="tariff-type" value="spot" id="tariff-spot" onchange="updateTariffUI()"> Flexibel (Börsenstrom)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="tariff-fixed-fields">
|
||||||
|
<div class="field"><label>Bezugspreis (€/kWh)</label>
|
||||||
|
<input type="number" id="cfg-price-import" step="0.001" placeholder="0.300"></div>
|
||||||
|
</div>
|
||||||
|
<div id="tariff-spot-fields" style="display:none">
|
||||||
|
<div class="field"><label>Aufschlag Netz + Steuern (ct/kWh)</label>
|
||||||
|
<input type="number" id="cfg-spot-markup" step="0.1" placeholder="15.0">
|
||||||
|
<div style="font-size:11px;color:var(--text-dim);margin-top:4px">Netzentgelt + Steuern + Aufschlag Anbieter — wird auf den Börsenpreis addiert</div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Land</label>
|
||||||
|
<select id="cfg-spot-country" style="background:var(--surface);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;padding:6px 10px;outline:none">
|
||||||
|
<option value="de">Deutschland</option>
|
||||||
|
<option value="at">Österreich</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="field"><label>Einspeisevergütung (€/kWh)</label>
|
<div class="field"><label>Einspeisevergütung (€/kWh)</label>
|
||||||
<input type="number" id="cfg-price-export" step="0.001" placeholder="0.080"></div>
|
<input type="number" id="cfg-price-export" step="0.001" placeholder="0.080"></div>
|
||||||
|
<div class="field" style="display:flex;align-items:center;gap:8px">
|
||||||
|
<input type="checkbox" id="cfg-spot-chart" style="width:auto;margin:0">
|
||||||
|
<label for="cfg-spot-chart" style="margin:0;cursor:pointer;font-size:13px">Börsenstrompreis-Chart anzeigen</label>
|
||||||
|
</div>
|
||||||
<div class="field"><label>Abrechnungsjahr beginnt am</label>
|
<div class="field"><label>Abrechnungsjahr beginnt am</label>
|
||||||
<div style="display:flex;gap:8px;align-items:center">
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
<input type="number" id="cfg-billing-day" min="1" max="28" placeholder="1" style="width:72px">
|
<input type="number" id="cfg-billing-day" min="1" max="28" placeholder="1" style="width:72px">
|
||||||
@@ -576,8 +605,11 @@ function renderEnergy(inverters, aggregates, period, spotData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sectionCards(title, data, col) {
|
function sectionCards(title, data, col) {
|
||||||
const imp = periodCard('Netzbezug', data.grid_import_kwh, data.import_cost, C.imp, '');
|
const impSub = data.spot_avg_ct != null
|
||||||
const exp = periodCard('Einspeisung', data.grid_export_kwh, data.export_revenue, C.exp, '');
|
? `Ø ${data.spot_avg_ct.toFixed(1)} + ${(data.spot_markup_ct||0).toFixed(1)} ct/kWh`
|
||||||
|
: '';
|
||||||
|
const imp = periodCard('Netzbezug', data.grid_import_kwh, data.import_cost, C.imp, impSub);
|
||||||
|
const exp = periodCard('Einspeisung', data.grid_export_kwh, data.export_revenue, C.exp, '');
|
||||||
if (!imp && !exp) return '';
|
if (!imp && !exp) return '';
|
||||||
return `<div style="margin-bottom:8px;font-size:10px;font-weight:700;letter-spacing:.08em;color:${C.dim};text-transform:uppercase">${title}</div>
|
return `<div style="margin-bottom:8px;font-size:10px;font-weight:700;letter-spacing:.08em;color:${C.dim};text-transform:uppercase">${title}</div>
|
||||||
<div class="energy-kwh" style="margin-bottom:16px">${imp}${exp}</div>`;
|
<div class="energy-kwh" style="margin-bottom:16px">${imp}${exp}</div>`;
|
||||||
@@ -586,7 +618,7 @@ function renderEnergy(inverters, aggregates, period, spotData) {
|
|||||||
const cards = sectionCards(mon.label || 'Diesen Monat', mon, C.imp) +
|
const cards = sectionCards(mon.label || 'Diesen Monat', mon, C.imp) +
|
||||||
sectionCards(yr.label || 'Dieses Jahr', yr, C.imp);
|
sectionCards(yr.label || 'Dieses Jahr', yr, C.imp);
|
||||||
|
|
||||||
const spotHtml = renderSpotChart(spotData);
|
const spotHtml = (period.spot_chart !== false) ? renderSpotChart(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}</div>
|
||||||
@@ -822,21 +854,38 @@ async function deleteInverter(id) {
|
|||||||
|
|
||||||
// ── MQTT Settings ─────────────────────────────────────────────
|
// ── MQTT Settings ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function updateTariffUI() {
|
||||||
|
const isSpot = document.getElementById("tariff-spot").checked;
|
||||||
|
document.getElementById("tariff-fixed-fields").style.display = isSpot ? "none" : "";
|
||||||
|
document.getElementById("tariff-spot-fields").style.display = isSpot ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
globalConfig = await fetchJSON(api("api/config"));
|
const cfg = await fetchJSON(api("api/config"));
|
||||||
document.getElementById("cfg-mqtt-broker").value = globalConfig.mqtt_broker || "";
|
globalConfig = cfg;
|
||||||
document.getElementById("cfg-mqtt-port").value = globalConfig.mqtt_port || 1883;
|
document.getElementById("cfg-mqtt-broker").value = cfg.mqtt_broker || "";
|
||||||
document.getElementById("cfg-mqtt-user").value = globalConfig.mqtt_user || "";
|
document.getElementById("cfg-mqtt-port").value = cfg.mqtt_port || 1883;
|
||||||
document.getElementById("cfg-price-import").value = globalConfig.price_import ?? 0.30;
|
document.getElementById("cfg-mqtt-user").value = cfg.mqtt_user || "";
|
||||||
document.getElementById("cfg-price-export").value = globalConfig.price_export ?? 0.08;
|
document.getElementById("cfg-price-import").value = cfg.price_import ?? 0.30;
|
||||||
document.getElementById("cfg-billing-day").value = globalConfig.billing_day ?? 1;
|
document.getElementById("cfg-price-export").value = cfg.price_export ?? 0.08;
|
||||||
document.getElementById("cfg-billing-month").value = globalConfig.billing_month ?? 1;
|
document.getElementById("cfg-billing-day").value = cfg.billing_day ?? 1;
|
||||||
|
document.getElementById("cfg-billing-month").value = cfg.billing_month ?? 1;
|
||||||
|
document.getElementById("cfg-spot-markup").value = cfg.spot_markup ?? 0.0;
|
||||||
|
document.getElementById("cfg-spot-country").value = cfg.spot_country ?? "de";
|
||||||
|
document.getElementById("cfg-spot-chart").checked = cfg.spot_chart ?? true;
|
||||||
|
document.getElementById((cfg.tariff_type ?? "fixed") === "spot" ? "tariff-spot" : "tariff-fixed").checked = true;
|
||||||
|
updateTariffUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function savePrices() {
|
async function savePrices() {
|
||||||
|
const isSpot = document.getElementById("tariff-spot").checked;
|
||||||
const body = {
|
const body = {
|
||||||
price_import: parseFloat(document.getElementById("cfg-price-import").value),
|
tariff_type: isSpot ? "spot" : "fixed",
|
||||||
price_export: parseFloat(document.getElementById("cfg-price-export").value),
|
price_import: parseFloat(document.getElementById("cfg-price-import").value || 0.30),
|
||||||
|
price_export: parseFloat(document.getElementById("cfg-price-export").value || 0.08),
|
||||||
|
spot_markup: parseFloat(document.getElementById("cfg-spot-markup").value || 0),
|
||||||
|
spot_country: document.getElementById("cfg-spot-country").value,
|
||||||
|
spot_chart: document.getElementById("cfg-spot-chart").checked,
|
||||||
billing_day: parseInt(document.getElementById("cfg-billing-day").value),
|
billing_day: parseInt(document.getElementById("cfg-billing-day").value),
|
||||||
billing_month: parseInt(document.getElementById("cfg-billing-month").value),
|
billing_month: parseInt(document.getElementById("cfg-billing-month").value),
|
||||||
};
|
};
|
||||||
@@ -845,7 +894,7 @@ async function savePrices() {
|
|||||||
method: "POST", headers: {"Content-Type": "application/json"},
|
method: "POST", headers: {"Content-Type": "application/json"},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
showToast("Preise gespeichert", "ok");
|
showToast("Einstellungen gespeichert", "ok");
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
showToast("Fehler beim Speichern", "err");
|
showToast("Fehler beim Speichern", "err");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user