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:
@@ -1,5 +1,5 @@
|
|||||||
name: ShineBridge
|
name: ShineBridge
|
||||||
version: "1.6.6"
|
version: "1.7.0"
|
||||||
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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import logging
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Dict, List, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -37,11 +37,51 @@ def init_db():
|
|||||||
CREATE INDEX IF NOT EXISTS idx_inv_sensor_ts
|
CREATE INDEX IF NOT EXISTS idx_inv_sensor_ts
|
||||||
ON measurements(inv_id, sensor_id, 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()
|
c.commit()
|
||||||
cleanup_old()
|
cleanup_old()
|
||||||
log.info("History DB initialisiert: %s", DB_PATH)
|
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]):
|
def write_batch(inv_id: str, ts: float, values: Dict[str, float]):
|
||||||
rows = [(inv_id, sid, ts, val) for sid, val in values.items()]
|
rows = [(inv_id, sid, ts, val) for sid, val in values.items()]
|
||||||
with _lock:
|
with _lock:
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ def _defaults() -> Dict[str, Any]:
|
|||||||
"mqtt_port": 1883,
|
"mqtt_port": 1883,
|
||||||
"mqtt_user": "",
|
"mqtt_user": "",
|
||||||
"mqtt_pass": "",
|
"mqtt_pass": "",
|
||||||
|
"price_import": 0.30,
|
||||||
|
"price_export": 0.08,
|
||||||
"inverters": [],
|
"inverters": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +116,8 @@ def save_config():
|
|||||||
"mqtt_port": State.mqtt_cfg.get("mqtt_port", 1883),
|
"mqtt_port": State.mqtt_cfg.get("mqtt_port", 1883),
|
||||||
"mqtt_user": State.mqtt_cfg.get("mqtt_user", ""),
|
"mqtt_user": State.mqtt_cfg.get("mqtt_user", ""),
|
||||||
"mqtt_pass": State.mqtt_cfg.get("mqtt_pass", ""),
|
"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,
|
"inverters": State.inverters_cfg,
|
||||||
}
|
}
|
||||||
with open(CONFIG_PATH, "w") as f:
|
with open(CONFIG_PATH, "w") as f:
|
||||||
@@ -338,10 +342,41 @@ 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"):
|
||||||
|
if k in data:
|
||||||
|
State.mqtt_cfg[k] = float(data[k])
|
||||||
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})
|
||||||
|
|
||||||
|
|
||||||
|
@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")
|
@app.get("/api/inverters-config")
|
||||||
def api_get_inverters():
|
def api_get_inverters():
|
||||||
with State.lock:
|
with State.lock:
|
||||||
|
|||||||
@@ -207,6 +207,15 @@
|
|||||||
<button class="btn btn-primary" onclick="saveMqtt()">Speichern & Neu starten</button>
|
<button class="btn btn-primary" onclick="saveMqtt()">Speichern & Neu starten</button>
|
||||||
</div>
|
</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">
|
<div class="settings-section">
|
||||||
<h3>Konfiguration sichern</h3>
|
<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>
|
<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 ──────────────────────────────────────────
|
// ── Energy Dashboard ──────────────────────────────────────────
|
||||||
|
|
||||||
function renderEnergy(inverters, aggregates) {
|
function renderEnergy(inverters, aggregates, period) {
|
||||||
const el = document.getElementById("energy-content");
|
const el = document.getElementById("energy-content");
|
||||||
if (!aggregates || !Object.keys(aggregates).length) {
|
if (!aggregates || !Object.keys(aggregates).length) {
|
||||||
el.innerHTML = '<div class="no-data">Warte auf erste Messung…</div>';
|
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, '') : ''}
|
${hasEV ? node(260, 323, 'ev', 'WALLBOX', evW, C.ev, evOn, '') : ''}
|
||||||
</svg>`;
|
</svg>`;
|
||||||
|
|
||||||
function kwhCard(label, val, col) {
|
period = period || {};
|
||||||
if (val == null) return '';
|
const mon = period.monthly || {};
|
||||||
const d = val >= 100 ? val.toFixed(0) : val.toFixed(val >= 10 ? 1 : 2);
|
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}">
|
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="kv" style="color:${col}">${fKwh(kwh)}</div>
|
||||||
<div class="kl">${label}</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>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cards = [
|
function sectionCards(title, data, col) {
|
||||||
kwhCard('PV Heute', aggregates.total_energy_today, C.pv),
|
const imp = periodCard('Netzbezug', data.grid_import_kwh, data.import_cost, C.imp, '');
|
||||||
kwhCard('Netzbezug', aggregates.grid_import_kwh, C.imp),
|
const exp = periodCard('Einspeisung', data.grid_export_kwh, data.export_revenue, C.exp, '');
|
||||||
kwhCard('Einspeisung', aggregates.grid_export_kwh, C.exp),
|
if (!imp && !exp) return '';
|
||||||
kwhCard('Bat. Laden', aggregates.bat_charge_total, C.bat),
|
return `<div style="margin-bottom:8px;font-size:10px;font-weight:700;letter-spacing:.08em;color:${C.dim};text-transform:uppercase">${title}</div>
|
||||||
kwhCard('Bat. Entladen', aggregates.bat_discharge_total, C.bat),
|
<div class="energy-kwh" style="margin-bottom:16px">${imp}${exp}</div>`;
|
||||||
].filter(Boolean).join('');
|
}
|
||||||
|
|
||||||
|
const cards = sectionCards('Diesen Monat', mon, C.imp) +
|
||||||
|
sectionCards('Dieses Jahr', yr, C.imp);
|
||||||
|
|
||||||
el.innerHTML = `<div class="energy-wrap">
|
el.innerHTML = `<div class="energy-wrap">
|
||||||
<div class="energy-svg-wrap">${svg}</div>
|
<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>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,7 +545,8 @@ async function refreshData() {
|
|||||||
document.getElementById("subtitle").textContent =
|
document.getElementById("subtitle").textContent =
|
||||||
keys.length ? `${keys.length} Gerät${keys.length !== 1 ? "e" : ""}` : "Keine Geräte";
|
keys.length ? `${keys.length} Gerät${keys.length !== 1 ? "e" : ""}` : "Keine Geräte";
|
||||||
renderLive(d.inverters || {}, d.aggregates || {});
|
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) {
|
} catch(e) {
|
||||||
document.getElementById("pill-mqtt").className = "pill err";
|
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-broker").value = globalConfig.mqtt_broker || "";
|
||||||
document.getElementById("cfg-mqtt-port").value = globalConfig.mqtt_port || 1883;
|
document.getElementById("cfg-mqtt-port").value = globalConfig.mqtt_port || 1883;
|
||||||
document.getElementById("cfg-mqtt-user").value = globalConfig.mqtt_user || "";
|
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() {
|
async function saveMqtt() {
|
||||||
|
|||||||
Reference in New Issue
Block a user