Feature: Abrechnungsperiode + Strompreise im Energie-Dashboard (v1.7.0)

- history.py: Tabelle period_starts — speichert kWh-Zählerstand zu Monats-/Jahresbeginn
- main.py: price_import/price_export in Config; /api/period-energy Endpoint
- Web UI: Preisfelder in Einstellungen (€/kWh Bezug + Vergütung)
- Energie-Dashboard: Cards zeigen Monat/Jahr kWh + Kosten statt All-Time-Total

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
retr0
2026-04-28 22:37:09 +02:00
parent 5972ef2c35
commit e9ca2fcc7d
4 changed files with 138 additions and 17 deletions
+1 -1
View File
@@ -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
+41 -1
View File
@@ -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:
+35
View File
@@ -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:
+61 -15
View File
@@ -207,6 +207,15 @@
<button class="btn btn-primary" onclick="saveMqtt()">Speichern & Neu starten</button>
</div>
<div class="settings-section">
<h3>Strompreise</h3>
<div class="field"><label>Bezugspreis (€/kWh)</label>
<input type="number" id="cfg-price-import" step="0.001" placeholder="0.300"></div>
<div class="field"><label>Einspeisevergütung (€/kWh)</label>
<input type="number" id="cfg-price-export" step="0.001" placeholder="0.080"></div>
<button class="btn btn-primary" onclick="savePrices()">Preise speichern</button>
</div>
<div class="settings-section">
<h3>Konfiguration sichern</h3>
<p style="color:var(--text-dim);font-size:.85rem;margin:0 0 .75rem">Alle Geräte und MQTT-Einstellungen als JSON exportieren und bei einer Neuinstallation wieder einlesen.</p>
@@ -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 = '<div class="no-data">Warte auf erste Messung…</div>';
@@ -484,26 +493,44 @@ function renderEnergy(inverters, aggregates) {
${hasEV ? node(260, 323, 'ev', 'WALLBOX', evW, C.ev, evOn, '') : ''}
</svg>`;
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 `<div class="kwh-card" style="border-top:3px solid ${col}">
<div class="kv" style="color:${col}">${d}<span style="font-size:10px;font-weight:400;color:${C.dim}"> kWh</span></div>
<div class="kl">${label}</div>
<div class="kv" style="color:${col}">${fKwh(kwh)}</div>
${cost != null ? `<div style="font-size:11px;font-weight:600;color:${col};opacity:.8;margin:2px 0">${fEur(cost)}</div>` : ''}
<div class="kl">${label}${sub?'<br><span style="opacity:.6">'+sub+'</span>':''}</div>
</div>`;
}
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 `<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>`;
}
const cards = sectionCards('Diesen Monat', mon, C.imp) +
sectionCards('Dieses Jahr', yr, C.imp);
el.innerHTML = `<div class="energy-wrap">
<div class="energy-svg-wrap">${svg}</div>
${cards ? `<div class="energy-kwh" style="margin-top:14px">${cards}</div>` : ''}
${cards ? `<div style="margin-top:16px">${cards}</div>` : ''}
</div>`;
}
@@ -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() {