diff --git a/haos-addon/config.yaml b/haos-addon/config.yaml index 7cdc580..175a694 100644 --- a/haos-addon/config.yaml +++ b/haos-addon/config.yaml @@ -1,5 +1,5 @@ name: ShineBridge -version: "1.8.15" +version: "1.8.16" 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/history.py b/haos-addon/src/history.py index 080c648..d4d1b34 100644 --- a/haos-addon/src/history.py +++ b/haos-addon/src/history.py @@ -47,6 +47,16 @@ def init_db(): PRIMARY KEY (agg_id, period_type, period_key) ) """) + # Tariftage: täglicher Verbrauch + Preisvergleich + c.execute(""" + CREATE TABLE IF NOT EXISTS tariff_days ( + date TEXT PRIMARY KEY, + kwh REAL NOT NULL, + spot_ct REAL, + fixed_ct REAL NOT NULL, + markup_ct REAL NOT NULL DEFAULT 0 + ) + """) c.commit() cleanup_old() log.info("History DB initialisiert: %s", DB_PATH) @@ -137,6 +147,51 @@ def query(inv_id: str, sensor_id: str, return rows +def save_daily_tariff_snapshot(date_iso: str, prev_kwh: float, cur_kwh: float, + spot_ct: Optional[float], fixed_ct: float, markup_ct: float): + """Speichert den Tagesverbrauch + Preisdaten für einen abgeschlossenen Tag.""" + delta = max(0.0, cur_kwh - prev_kwh) + with _lock: + c = _get_conn() + c.execute(""" + INSERT OR IGNORE INTO tariff_days(date, kwh, spot_ct, fixed_ct, markup_ct) + VALUES(?, ?, ?, ?, ?) + """, (date_iso, round(delta, 4), spot_ct, fixed_ct, markup_ct)) + c.commit() + + +def get_tariff_days(from_date: str, to_date: str) -> List[Tuple]: + """Gibt alle Tariftage im Bereich [from_date, to_date] zurück.""" + with _lock: + return _get_conn().execute(""" + SELECT date, kwh, spot_ct, fixed_ct, markup_ct + FROM tariff_days + WHERE date >= ? AND date <= ? + ORDER BY date ASC + """, (from_date, to_date)).fetchall() + + +def get_daily_kwh_start(date_iso: str) -> Optional[float]: + """Gibt den gespeicherten kWh-Tagesstartwert zurück (aus period_starts).""" + with _lock: + row = _get_conn().execute(""" + SELECT value FROM period_starts + WHERE agg_id='tariff' AND period_type='daily' AND period_key=? + """, (date_iso,)).fetchone() + return row[0] if row else None + + +def save_daily_kwh_start(date_iso: str, kwh: float): + """Speichert den kWh-Stand als Tagesstartwert (einmalig, INSERT OR IGNORE).""" + with _lock: + c = _get_conn() + c.execute(""" + INSERT OR IGNORE INTO period_starts(agg_id, period_type, period_key, value) + VALUES('tariff', 'daily', ?, ?) + """, (date_iso, kwh)) + c.commit() + + def cleanup_old(days: int = RETENTION_DAYS): cutoff = time.time() - days * 86400 with _lock: diff --git a/haos-addon/src/main.py b/haos-addon/src/main.py index a1efeab..3ceb67a 100644 --- a/haos-addon/src/main.py +++ b/haos-addon/src/main.py @@ -77,6 +77,7 @@ class State: surplus_devices_cfg: List[Dict[str, Any]] = [] z2m_base: str = "zigbee2mqtt" z2m_devices: List[Dict[str, Any]] = [] + last_tariff_snapshot_date: str = "" _publisher: Optional[MqttPublisher] = None _surplus_ctrl: Optional[SurplusDeviceController] = None @@ -193,6 +194,37 @@ def _get_pv_surplus() -> float: # ── Poll Loop ───────────────────────────────────────────────── +def _run_daily_tariff_snapshot(): + import datetime + today = datetime.date.today() + yesterday = today - datetime.timedelta(days=1) + today_iso = today.isoformat() + yesterday_iso = yesterday.isoformat() + + agg = _compute_aggregates(allow_stale=True) + cur_kwh = agg.get("grid_import_kwh") + if cur_kwh is None: + return + + history.save_daily_kwh_start(today_iso, cur_kwh) + + prev_kwh = history.get_daily_kwh_start(yesterday_iso) + if prev_kwh is None: + return + + fixed_ct = float(State.mqtt_cfg.get("price_import", 0.30)) * 100 + markup_ct = float(State.mqtt_cfg.get("spot_markup", 0.0)) + country = str(State.mqtt_cfg.get("spot_country", "de")) + + y_start = datetime.datetime.combine(yesterday, datetime.time.min).timestamp() + y_end = datetime.datetime.combine(today, datetime.time.min).timestamp() + spot_ct = _get_avg_spot_price(y_start, y_end, country) + + history.save_daily_tariff_snapshot(yesterday_iso, prev_kwh, cur_kwh, spot_ct, fixed_ct, markup_ct) + log.info("Tariftag %s gespeichert: %.3f kWh, spot=%.2f ct, fixed=%.2f ct", + yesterday_iso, max(0, cur_kwh - prev_kwh), spot_ct or 0, fixed_ct) + + def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event): inv_id = inv_cfg["id"] model_id = inv_cfg.get("inverter_model", "MIC_1500_TL_X") @@ -294,6 +326,14 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event): if agg: _publisher.publish_aggregates(agg) + # Täglicher Tarif-Snapshot (einmal pro Tag, beim ersten Poll nach Mitternacht) + if values is not None: + import datetime + today_iso = datetime.date.today().isoformat() + if State.last_tariff_snapshot_date != today_iso: + State.last_tariff_snapshot_date = today_iso + threading.Thread(target=_run_daily_tariff_snapshot, daemon=True).start() + stop.wait(max(0.0, interval - (time.time() - t0))) reader.close() @@ -533,6 +573,49 @@ def api_period_energy(): return jsonify(result) +@app.get("/api/finance") +def api_finance(): + import datetime + billing_day = int(State.mqtt_cfg.get("billing_day", 1)) + billing_month = int(State.mqtt_cfg.get("billing_month", 1)) + yr_key = history.period_key("yearly", billing_day, billing_month) + yr_start = datetime.date.fromisoformat(yr_key) + today = datetime.date.today() + + rows = history.get_tariff_days(yr_key, today.isoformat()) + + days = [] + fixed_total = 0.0 + spot_total = 0.0 + spot_available = 0 + for date_iso, kwh, spot_ct, fixed_ct, markup_ct in rows: + fixed_day = round(kwh * fixed_ct / 100, 4) + spot_day = round(kwh * ((spot_ct + markup_ct) / 100), 4) if spot_ct is not None else None + fixed_total += fixed_day + if spot_day is not None: + spot_total += spot_day + spot_available += 1 + days.append({ + "date": date_iso, + "kwh": round(kwh, 3), + "fixed_eur": fixed_day, + "spot_eur": spot_day, + "spot_ct": spot_ct, + "fixed_ct": fixed_ct, + }) + + result = { + "period_start": yr_key, + "days": days, + "fixed_total_eur": round(fixed_total, 2), + "spot_total_eur": round(spot_total, 2) if spot_available else None, + "spot_days": spot_available, + "total_days": len(days), + "savings_eur": round(fixed_total - spot_total, 2) if spot_available else None, + } + return jsonify(result) + + @app.get("/api/z2m-devices") def api_z2m_devices(): with State.lock: diff --git a/haos-addon/src/web/index.html b/haos-addon/src/web/index.html index 790a57e..59f054a 100644 --- a/haos-addon/src/web/index.html +++ b/haos-addon/src/web/index.html @@ -175,6 +175,7 @@
Energie
+
Finanzen
Live-Daten
Geräte
Einstellungen
@@ -185,6 +186,11 @@
Warte auf erste Messung…
+ +
+
Lade…
+
+
Warte auf erste Messung...
@@ -430,11 +436,12 @@ function showToast(msg, type) { } function switchTab(name) { - ["energy","live","inverters","settings"].forEach((t, i) => { + ["energy","finance","live","inverters","settings"].forEach((t, i) => { document.querySelectorAll(".tab")[i].classList.toggle("active", t === name); document.querySelectorAll(".panel")[i].classList.toggle("active", t === name); }); if (name === "energy" || name === "live") startRefresh(); else stopRefresh(); + if (name === "finance") loadFinance(); } // ── Energy Dashboard ────────────────────────────────────────── @@ -705,6 +712,112 @@ function renderEnergy(inverters, aggregates, period, spotData) {
`; } +// ── Finance Tab ─────────────────────────────────────────────── + +async function loadFinance() { + const el = document.getElementById("finance-content"); + let data; + try { data = await fetchJSON(api("api/finance")); } + catch(e) { el.innerHTML = '
Fehler beim Laden
'; return; } + + const { days, fixed_total_eur, spot_total_eur, savings_eur, period_start, spot_days, total_days } = data; + + if (!days || days.length === 0) { + el.innerHTML = `
+
📊
+
Noch keine Daten
+
Der Tracker sammelt ab morgen täglich Daten.
Abrechnungsjahr ab ${period_start}.
+
`; + return; + } + + const C = { fixed: '#58a6ff', spot: '#f0883e', grid: '#30363d', green: '#4caf82', red: '#e05c5c' }; + + // ── Empfehlung ─────────────────────────────────────────────── + let empfehlung = ''; + if (spot_total_eur !== null && spot_days >= 7) { + const lohnt = savings_eur > 0; + const col = lohnt ? C.green : C.red; + const icon = lohnt ? '✓' : '✗'; + empfehlung = `
+
+
${icon} Flexibler Tarif würde sich ${lohnt ? 'lohnen' : 'nicht lohnen'}
+
Basierend auf ${spot_days} Tagen im Abrechnungsjahr
+
+
${lohnt ? '−' : '+'}${fEur(Math.abs(savings_eur))}
+
`; + } + + // ── Summary-Karten ─────────────────────────────────────────── + const spotCard = spot_total_eur !== null + ? `
+
${fEur(spot_total_eur)}
+
Spot-Tarif (hypothetisch)
${spot_days} Tage mit Preisdaten
+
` + : `
Spot-Tarif
Noch keine Daten
`; + + const savCard = savings_eur !== null + ? `
+
${savings_eur > 0 ? '−' : '+'}${fEur(Math.abs(savings_eur))}
+
${savings_eur > 0 ? 'Ersparnis mit Spot' : 'Mehrkosten mit Spot'}
gegenüber Festpreis
+
` + : ''; + + const cards = `
+
+
${fEur(fixed_total_eur)}
+
Festpreis-Kosten
${total_days} Tage
+
+ ${spotCard}${savCard} +
`; + + // ── SVG-Balkendiagramm ──────────────────────────────────────── + const W = 600, H = 180, PL = 44, PR = 12, PT = 10, PB = 28; + const cW = W - PL - PR; + const cH = H - PT - PB; + const n = days.length; + const barW = Math.max(2, Math.floor(cW / n) - 2); + const gap = Math.max(1, Math.floor(cW / n) - barW); + + const allVals = days.flatMap(d => [d.fixed_eur, d.spot_eur].filter(v => v != null)); + const maxVal = Math.max(...allVals, 0.01); + + const yTicks = 4; + let gridLines = '', yLabels = ''; + for (let i = 0; i <= yTicks; i++) { + const v = maxVal * i / yTicks; + const y = PT + cH - (cH * i / yTicks); + gridLines += ``; + yLabels += `${fEur(v)}`; + } + + let bars = '', xLabels = ''; + days.forEach((d, i) => { + const x0 = PL + i * (barW + gap); + const hFixed = cH * d.fixed_eur / maxVal; + bars += ``; + if (d.spot_eur != null) { + const hSpot = cH * d.spot_eur / maxVal; + bars += ``; + } + if (i % Math.max(1, Math.floor(n / 8)) === 0) { + const label = d.date.slice(5); + xLabels += `${label}`; + } + }); + + const legend = `■ Festpreis + ■ Spot (hypothetisch)`; + + const chart = `
+ + ${gridLines}${yLabels}${bars}${xLabels}${legend} + +
`; + + el.innerHTML = empfehlung + cards + chart; +} + // ── Live Data ───────────────────────────────────────────────── async function refreshData() {