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
+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() {