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:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user