diff --git a/haos-addon/config.yaml b/haos-addon/config.yaml index 102f82b..565b5da 100644 --- a/haos-addon/config.yaml +++ b/haos-addon/config.yaml @@ -1,5 +1,5 @@ name: ShineBridge -version: "1.6.6" +version: "1.7.0" 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 3ee2ba6..18272c8 100644 --- a/haos-addon/src/history.py +++ b/haos-addon/src/history.py @@ -2,7 +2,7 @@ import logging import sqlite3 import threading import time -from typing import Dict, List, Tuple +from typing import Dict, List, Optional, Tuple log = logging.getLogger(__name__) @@ -37,11 +37,51 @@ def init_db(): CREATE INDEX IF NOT EXISTS idx_inv_sensor_ts ON measurements(inv_id, sensor_id, ts) """) + # Periodenstarts: kWh-Zählerstand zu Beginn jeder Abrechungsperiode + c.execute(""" + CREATE TABLE IF NOT EXISTS period_starts ( + agg_id TEXT NOT NULL, + period_type TEXT NOT NULL, + period_key TEXT NOT NULL, + value REAL NOT NULL, + PRIMARY KEY (agg_id, period_type, period_key) + ) + """) c.commit() cleanup_old() log.info("History DB initialisiert: %s", DB_PATH) +def period_key(period_type: str) -> str: + import datetime + now = datetime.date.today() + return now.strftime("%Y-%m") if period_type == "monthly" else now.strftime("%Y") + + +def save_period_start_if_new(agg_id: str, period_type: str, key: str, current_value: float): + """Speichert den Startwert nur wenn diese Periode noch nicht existiert.""" + with _lock: + c = _get_conn() + c.execute(""" + INSERT OR IGNORE INTO period_starts(agg_id, period_type, period_key, value) + VALUES (?, ?, ?, ?) + """, (agg_id, period_type, key, current_value)) + c.commit() + + +def get_period_consumption(agg_id: str, period_type: str, key: str, + current_value: float) -> Optional[float]: + """Verbrauch seit Periodenbeginn; None wenn noch kein Startwert gespeichert.""" + with _lock: + row = _get_conn().execute(""" + SELECT value FROM period_starts + WHERE agg_id=? AND period_type=? AND period_key=? + """, (agg_id, period_type, key)).fetchone() + if row is None: + return None + return max(0.0, current_value - row[0]) + + def write_batch(inv_id: str, ts: float, values: Dict[str, float]): rows = [(inv_id, sid, ts, val) for sid, val in values.items()] with _lock: diff --git a/haos-addon/src/main.py b/haos-addon/src/main.py index 22c4b86..3a60831 100644 --- a/haos-addon/src/main.py +++ b/haos-addon/src/main.py @@ -86,6 +86,8 @@ def _defaults() -> Dict[str, Any]: "mqtt_port": 1883, "mqtt_user": "", "mqtt_pass": "", + "price_import": 0.30, + "price_export": 0.08, "inverters": [], } @@ -114,6 +116,8 @@ def save_config(): "mqtt_port": State.mqtt_cfg.get("mqtt_port", 1883), "mqtt_user": State.mqtt_cfg.get("mqtt_user", ""), "mqtt_pass": State.mqtt_cfg.get("mqtt_pass", ""), + "price_import": State.mqtt_cfg.get("price_import", 0.30), + "price_export": State.mqtt_cfg.get("price_export", 0.08), "inverters": State.inverters_cfg, } with open(CONFIG_PATH, "w") as f: @@ -338,10 +342,41 @@ 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"): + if k in data: + State.mqtt_cfg[k] = float(data[k]) save_config() threading.Thread(target=_restart_all, daemon=True).start() return jsonify({"ok": True}) + +@app.get("/api/period-energy") +def api_period_energy(): + agg = _compute_aggregates() + price_import = float(State.mqtt_cfg.get("price_import", 0.30)) + price_export = float(State.mqtt_cfg.get("price_export", 0.08)) + + result = {"price_import": price_import, "price_export": price_export} + + for period_type in ("monthly", "yearly"): + key = history.period_key(period_type) + entry = {} + for agg_id in ("grid_import_kwh", "grid_export_kwh", "total_energy_today"): + cur = agg.get(agg_id) + if cur is None: + continue + history.save_period_start_if_new(agg_id, period_type, key, cur) + val = history.get_period_consumption(agg_id, period_type, key, cur) + if val is not None: + entry[agg_id] = round(val, 2) + if "grid_import_kwh" in entry: + 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) + @app.get("/api/inverters-config") def api_get_inverters(): with State.lock: diff --git a/haos-addon/src/web/index.html b/haos-addon/src/web/index.html index ec81778..89cea89 100644 --- a/haos-addon/src/web/index.html +++ b/haos-addon/src/web/index.html @@ -207,6 +207,15 @@ +
+

Strompreise

+
+
+
+
+ +
+

Konfiguration sichern

Alle Geräte und MQTT-Einstellungen als JSON exportieren und bei einer Neuinstallation wieder einlesen.

@@ -354,7 +363,7 @@ function switchTab(name) { // ── Energy Dashboard ────────────────────────────────────────── -function renderEnergy(inverters, aggregates) { +function renderEnergy(inverters, aggregates, period) { const el = document.getElementById("energy-content"); if (!aggregates || !Object.keys(aggregates).length) { el.innerHTML = '
Warte auf erste Messung…
'; @@ -484,26 +493,44 @@ function renderEnergy(inverters, aggregates) { ${hasEV ? node(260, 323, 'ev', 'WALLBOX', evW, C.ev, evOn, '') : ''} `; - function kwhCard(label, val, col) { - if (val == null) return ''; - const d = val >= 100 ? val.toFixed(0) : val.toFixed(val >= 10 ? 1 : 2); + period = period || {}; + const mon = period.monthly || {}; + const yr = period.yearly || {}; + const pi = period.price_import || 0.30; + const pe = period.price_export || 0.08; + + function fEur(v) { + if (v == null) return null; + return v >= 100 ? v.toFixed(0) + ' €' : v.toFixed(2) + ' €'; + } + function fKwh(v) { + if (v == null) return null; + return (v >= 100 ? v.toFixed(0) : v.toFixed(1)) + ' kWh'; + } + + function periodCard(label, kwh, cost, col, sub) { + if (kwh == null) return ''; return `
-
${d} kWh
-
${label}
+
${fKwh(kwh)}
+ ${cost != null ? `
${fEur(cost)}
` : ''} +
${label}${sub?'
'+sub+'':''}
`; } - const cards = [ - kwhCard('PV Heute', aggregates.total_energy_today, C.pv), - kwhCard('Netzbezug', aggregates.grid_import_kwh, C.imp), - kwhCard('Einspeisung', aggregates.grid_export_kwh, C.exp), - kwhCard('Bat. Laden', aggregates.bat_charge_total, C.bat), - kwhCard('Bat. Entladen', aggregates.bat_discharge_total, C.bat), - ].filter(Boolean).join(''); + 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, ''); + if (!imp && !exp) return ''; + return `
${title}
+
${imp}${exp}
`; + } + + const cards = sectionCards('Diesen Monat', mon, C.imp) + + sectionCards('Dieses Jahr', yr, C.imp); el.innerHTML = `
${svg}
- ${cards ? `
${cards}
` : ''} + ${cards ? `
${cards}
` : ''}
`; } @@ -518,7 +545,8 @@ async function refreshData() { document.getElementById("subtitle").textContent = keys.length ? `${keys.length} Gerät${keys.length !== 1 ? "e" : ""}` : "Keine Geräte"; renderLive(d.inverters || {}, d.aggregates || {}); - renderEnergy(d.inverters || {}, d.aggregates || {}); + const period = await fetchJSON(api("api/period-energy")).catch(() => ({})); + renderEnergy(d.inverters || {}, d.aggregates || {}, period); } catch(e) { document.getElementById("pill-mqtt").className = "pill err"; } @@ -738,6 +766,24 @@ async function loadSettings() { 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; +} + +async function savePrices() { + const body = { + price_import: parseFloat(document.getElementById("cfg-price-import").value), + price_export: parseFloat(document.getElementById("cfg-price-export").value), + }; + try { + await fetchJSON(api("api/config"), { + method: "POST", headers: {"Content-Type": "application/json"}, + body: JSON.stringify(body), + }); + showToast("Preise gespeichert", "ok"); + } catch(e) { + showToast("Fehler beim Speichern", "err"); + } } async function saveMqtt() {