From ec267827655905de7a01ebb49b5581fba4e97ab5 Mon Sep 17 00:00:00 2001 From: retr0 <42kdesigners@gmail.com> Date: Wed, 29 Apr 2026 08:27:55 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20Flexibler=20Stromtarif=20(B=C3=B6rse?= =?UTF-8?q?nstrom)=20in=20Einstellungen=20+=20Periodenabrechnung=20(v1.8.1?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- haos-addon/config.yaml | 2 +- haos-addon/src/main.py | 79 ++++++++++++++++++++++++++++----- haos-addon/src/web/index.html | 83 ++++++++++++++++++++++++++++------- 3 files changed, 135 insertions(+), 29 deletions(-) diff --git a/haos-addon/config.yaml b/haos-addon/config.yaml index c46d7af..602160e 100644 --- a/haos-addon/config.yaml +++ b/haos-addon/config.yaml @@ -1,5 +1,5 @@ name: ShineBridge -version: "1.8.0" +version: "1.8.1" slug: shinebridge description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI url: https://gitea.bitfire.work/retr0/shinebridge diff --git a/haos-addon/src/main.py b/haos-addon/src/main.py index 210ef80..6b5b9d2 100644 --- a/haos-addon/src/main.py +++ b/haos-addon/src/main.py @@ -90,6 +90,10 @@ def _defaults() -> Dict[str, Any]: "price_export": 0.08, "billing_day": 1, "billing_month": 1, + "tariff_type": "fixed", + "spot_country": "de", + "spot_markup": 0.0, + "spot_chart": True, "inverters": [], } @@ -122,6 +126,10 @@ def save_config(): "price_export": State.mqtt_cfg.get("price_export", 0.08), "billing_day": State.mqtt_cfg.get("billing_day", 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, } with open(CONFIG_PATH, "w") as f: @@ -346,12 +354,17 @@ def api_save_config(): State.mqtt_cfg[k] = data[k] if data.get("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: State.mqtt_cfg[k] = float(data[k]) for k in ("billing_day", "billing_month"): if k in data: 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() threading.Thread(target=_restart_all, daemon=True).start() return jsonify({"ok": True}) @@ -365,29 +378,34 @@ def api_period_energy(): price_export = float(State.mqtt_cfg.get("price_export", 0.08)) billing_day = int(State.mqtt_cfg.get("billing_day", 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 = { "price_import": price_import, "price_export": price_export, - "billing_day": billing_day, - "billing_month": billing_month, + "tariff_type": tariff_type, + "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"] + now_ts = time.time() for period_type in ("monthly", "yearly"): key = history.period_key(period_type, billing_day, billing_month) entry = {} - # Lesbare Beschriftung + # Perioden-Startzeitpunkt für Spot-Preisabfrage if period_type == "monthly": - d = datetime.date.fromisoformat(key + "-01") - entry["label"] = f"{MONTHS_DE[d.month]} {d.year}" + pd = datetime.date.fromisoformat(key + "-01") + entry["label"] = f"{MONTHS_DE[pd.month]} {pd.year}" else: - d = datetime.date.fromisoformat(key) - end = datetime.date(d.year + 1, billing_month, billing_day) if billing_month != 1 or billing_day != 1 \ - else datetime.date(d.year + 1, 1, 1) - entry["label"] = f"{d.strftime('%d.%m.%Y')} – {(end - datetime.timedelta(days=1)).strftime('%d.%m.%Y')}" + pd = datetime.date.fromisoformat(key) + end_d = datetime.date(pd.year + 1, billing_month, billing_day) if billing_month != 1 or billing_day != 1 \ + else datetime.date(pd.year + 1, 1, 1) + 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"): cur = agg.get(agg_id) @@ -397,10 +415,24 @@ def api_period_energy(): val = history.get_period_consumption(agg_id, period_type, key, cur) if val is not None: entry[agg_id] = round(val, 2) + + # Kostenberechnung: Festpreis oder Börsenpreis 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: entry["export_revenue"] = round(entry["grid_export_kwh"] * price_export, 2) + result[period_type] = entry return jsonify(result) @@ -545,6 +577,31 @@ def api_import_config(): threading.Thread(target=_restart_all, daemon=True).start() 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_TTL = 900 # 15 Minuten diff --git a/haos-addon/src/web/index.html b/haos-addon/src/web/index.html index 5d22248..2e237b7 100644 --- a/haos-addon/src/web/index.html +++ b/haos-addon/src/web/index.html @@ -208,11 +208,40 @@
-

Strompreise

-
-
+

Stromtarif

+
+ +
+ + +
+
+
+
+
+
+
+
+ + +
@@ -576,8 +605,11 @@ function renderEnergy(inverters, aggregates, period, spotData) { } function sectionCards(title, data, col) { - const imp = periodCard('Netzbezug', data.grid_import_kwh, data.import_cost, C.imp, ''); - const exp = periodCard('Einspeisung', data.grid_export_kwh, data.export_revenue, C.exp, ''); + const impSub = data.spot_avg_ct != null + ? `Ø ${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 ''; return `
${title}
${imp}${exp}
`; @@ -586,7 +618,7 @@ function renderEnergy(inverters, aggregates, period, spotData) { const cards = sectionCards(mon.label || 'Diesen Monat', mon, C.imp) + sectionCards(yr.label || 'Dieses Jahr', yr, C.imp); - const spotHtml = renderSpotChart(spotData); + const spotHtml = (period.spot_chart !== false) ? renderSpotChart(spotData) : ''; el.innerHTML = `
${svg}${spotHtml}
@@ -822,21 +854,38 @@ async function deleteInverter(id) { // ── 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() { - globalConfig = await fetchJSON(api("api/config")); - document.getElementById("cfg-mqtt-broker").value = globalConfig.mqtt_broker || ""; - document.getElementById("cfg-mqtt-port").value = globalConfig.mqtt_port || 1883; - document.getElementById("cfg-mqtt-user").value = globalConfig.mqtt_user || ""; - document.getElementById("cfg-price-import").value = globalConfig.price_import ?? 0.30; - document.getElementById("cfg-price-export").value = globalConfig.price_export ?? 0.08; - document.getElementById("cfg-billing-day").value = globalConfig.billing_day ?? 1; - document.getElementById("cfg-billing-month").value = globalConfig.billing_month ?? 1; + const cfg = await fetchJSON(api("api/config")); + globalConfig = cfg; + document.getElementById("cfg-mqtt-broker").value = cfg.mqtt_broker || ""; + document.getElementById("cfg-mqtt-port").value = cfg.mqtt_port || 1883; + document.getElementById("cfg-mqtt-user").value = cfg.mqtt_user || ""; + document.getElementById("cfg-price-import").value = cfg.price_import ?? 0.30; + document.getElementById("cfg-price-export").value = cfg.price_export ?? 0.08; + 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() { + const isSpot = document.getElementById("tariff-spot").checked; const body = { - price_import: parseFloat(document.getElementById("cfg-price-import").value), - price_export: parseFloat(document.getElementById("cfg-price-export").value), + tariff_type: isSpot ? "spot" : "fixed", + 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_month: parseInt(document.getElementById("cfg-billing-month").value), }; @@ -845,7 +894,7 @@ async function savePrices() { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(body), }); - showToast("Preise gespeichert", "ok"); + showToast("Einstellungen gespeichert", "ok"); } catch(e) { showToast("Fehler beim Speichern", "err"); }