v1.8.16: Finanzen-Tab — Festpreis vs. Spot-Vergleich

Täglicher Tarif-Tracking: kWh + EPEX-Ø-Preis werden ab jetzt täglich
gespeichert. Neuer Finanzen-Tab zeigt Balkendiagramm (Festpreis vs.
Spot hypothetisch), Summen-Karten und Empfehlung ob flexibler Tarif
sich lohnen würde.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
retr0
2026-05-05 13:29:55 +02:00
parent dfb42e6902
commit 2456f356b4
4 changed files with 253 additions and 2 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
name: ShineBridge
version: "1.8.15"
version: "1.8.16"
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
+55
View File
@@ -47,6 +47,16 @@ def init_db():
PRIMARY KEY (agg_id, period_type, period_key)
)
""")
# Tariftage: täglicher Verbrauch + Preisvergleich
c.execute("""
CREATE TABLE IF NOT EXISTS tariff_days (
date TEXT PRIMARY KEY,
kwh REAL NOT NULL,
spot_ct REAL,
fixed_ct REAL NOT NULL,
markup_ct REAL NOT NULL DEFAULT 0
)
""")
c.commit()
cleanup_old()
log.info("History DB initialisiert: %s", DB_PATH)
@@ -137,6 +147,51 @@ def query(inv_id: str, sensor_id: str,
return rows
def save_daily_tariff_snapshot(date_iso: str, prev_kwh: float, cur_kwh: float,
spot_ct: Optional[float], fixed_ct: float, markup_ct: float):
"""Speichert den Tagesverbrauch + Preisdaten für einen abgeschlossenen Tag."""
delta = max(0.0, cur_kwh - prev_kwh)
with _lock:
c = _get_conn()
c.execute("""
INSERT OR IGNORE INTO tariff_days(date, kwh, spot_ct, fixed_ct, markup_ct)
VALUES(?, ?, ?, ?, ?)
""", (date_iso, round(delta, 4), spot_ct, fixed_ct, markup_ct))
c.commit()
def get_tariff_days(from_date: str, to_date: str) -> List[Tuple]:
"""Gibt alle Tariftage im Bereich [from_date, to_date] zurück."""
with _lock:
return _get_conn().execute("""
SELECT date, kwh, spot_ct, fixed_ct, markup_ct
FROM tariff_days
WHERE date >= ? AND date <= ?
ORDER BY date ASC
""", (from_date, to_date)).fetchall()
def get_daily_kwh_start(date_iso: str) -> Optional[float]:
"""Gibt den gespeicherten kWh-Tagesstartwert zurück (aus period_starts)."""
with _lock:
row = _get_conn().execute("""
SELECT value FROM period_starts
WHERE agg_id='tariff' AND period_type='daily' AND period_key=?
""", (date_iso,)).fetchone()
return row[0] if row else None
def save_daily_kwh_start(date_iso: str, kwh: float):
"""Speichert den kWh-Stand als Tagesstartwert (einmalig, INSERT OR IGNORE)."""
with _lock:
c = _get_conn()
c.execute("""
INSERT OR IGNORE INTO period_starts(agg_id, period_type, period_key, value)
VALUES('tariff', 'daily', ?, ?)
""", (date_iso, kwh))
c.commit()
def cleanup_old(days: int = RETENTION_DAYS):
cutoff = time.time() - days * 86400
with _lock:
+83
View File
@@ -77,6 +77,7 @@ class State:
surplus_devices_cfg: List[Dict[str, Any]] = []
z2m_base: str = "zigbee2mqtt"
z2m_devices: List[Dict[str, Any]] = []
last_tariff_snapshot_date: str = ""
_publisher: Optional[MqttPublisher] = None
_surplus_ctrl: Optional[SurplusDeviceController] = None
@@ -193,6 +194,37 @@ def _get_pv_surplus() -> float:
# ── Poll Loop ─────────────────────────────────────────────────
def _run_daily_tariff_snapshot():
import datetime
today = datetime.date.today()
yesterday = today - datetime.timedelta(days=1)
today_iso = today.isoformat()
yesterday_iso = yesterday.isoformat()
agg = _compute_aggregates(allow_stale=True)
cur_kwh = agg.get("grid_import_kwh")
if cur_kwh is None:
return
history.save_daily_kwh_start(today_iso, cur_kwh)
prev_kwh = history.get_daily_kwh_start(yesterday_iso)
if prev_kwh is None:
return
fixed_ct = float(State.mqtt_cfg.get("price_import", 0.30)) * 100
markup_ct = float(State.mqtt_cfg.get("spot_markup", 0.0))
country = str(State.mqtt_cfg.get("spot_country", "de"))
y_start = datetime.datetime.combine(yesterday, datetime.time.min).timestamp()
y_end = datetime.datetime.combine(today, datetime.time.min).timestamp()
spot_ct = _get_avg_spot_price(y_start, y_end, country)
history.save_daily_tariff_snapshot(yesterday_iso, prev_kwh, cur_kwh, spot_ct, fixed_ct, markup_ct)
log.info("Tariftag %s gespeichert: %.3f kWh, spot=%.2f ct, fixed=%.2f ct",
yesterday_iso, max(0, cur_kwh - prev_kwh), spot_ct or 0, fixed_ct)
def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
inv_id = inv_cfg["id"]
model_id = inv_cfg.get("inverter_model", "MIC_1500_TL_X")
@@ -294,6 +326,14 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
if agg:
_publisher.publish_aggregates(agg)
# Täglicher Tarif-Snapshot (einmal pro Tag, beim ersten Poll nach Mitternacht)
if values is not None:
import datetime
today_iso = datetime.date.today().isoformat()
if State.last_tariff_snapshot_date != today_iso:
State.last_tariff_snapshot_date = today_iso
threading.Thread(target=_run_daily_tariff_snapshot, daemon=True).start()
stop.wait(max(0.0, interval - (time.time() - t0)))
reader.close()
@@ -533,6 +573,49 @@ def api_period_energy():
return jsonify(result)
@app.get("/api/finance")
def api_finance():
import datetime
billing_day = int(State.mqtt_cfg.get("billing_day", 1))
billing_month = int(State.mqtt_cfg.get("billing_month", 1))
yr_key = history.period_key("yearly", billing_day, billing_month)
yr_start = datetime.date.fromisoformat(yr_key)
today = datetime.date.today()
rows = history.get_tariff_days(yr_key, today.isoformat())
days = []
fixed_total = 0.0
spot_total = 0.0
spot_available = 0
for date_iso, kwh, spot_ct, fixed_ct, markup_ct in rows:
fixed_day = round(kwh * fixed_ct / 100, 4)
spot_day = round(kwh * ((spot_ct + markup_ct) / 100), 4) if spot_ct is not None else None
fixed_total += fixed_day
if spot_day is not None:
spot_total += spot_day
spot_available += 1
days.append({
"date": date_iso,
"kwh": round(kwh, 3),
"fixed_eur": fixed_day,
"spot_eur": spot_day,
"spot_ct": spot_ct,
"fixed_ct": fixed_ct,
})
result = {
"period_start": yr_key,
"days": days,
"fixed_total_eur": round(fixed_total, 2),
"spot_total_eur": round(spot_total, 2) if spot_available else None,
"spot_days": spot_available,
"total_days": len(days),
"savings_eur": round(fixed_total - spot_total, 2) if spot_available else None,
}
return jsonify(result)
@app.get("/api/z2m-devices")
def api_z2m_devices():
with State.lock:
+114 -1
View File
@@ -175,6 +175,7 @@
<main>
<div class="tabs">
<div class="tab active" onclick="switchTab('energy')">Energie</div>
<div class="tab" onclick="switchTab('finance')">Finanzen</div>
<div class="tab" onclick="switchTab('live')">Live-Daten</div>
<div class="tab" onclick="switchTab('inverters')">Geräte</div>
<div class="tab" onclick="switchTab('settings')">Einstellungen</div>
@@ -185,6 +186,11 @@
<div id="energy-content"><div class="no-data">Warte auf erste Messung…</div></div>
</div>
<!-- Finance Panel -->
<div class="panel" id="panel-finance">
<div id="finance-content"><div class="no-data">Lade…</div></div>
</div>
<!-- Live Panel -->
<div class="panel" id="panel-live">
<div id="live-content"><div class="no-data">Warte auf erste Messung...</div></div>
@@ -430,11 +436,12 @@ function showToast(msg, type) {
}
function switchTab(name) {
["energy","live","inverters","settings"].forEach((t, i) => {
["energy","finance","live","inverters","settings"].forEach((t, i) => {
document.querySelectorAll(".tab")[i].classList.toggle("active", t === name);
document.querySelectorAll(".panel")[i].classList.toggle("active", t === name);
});
if (name === "energy" || name === "live") startRefresh(); else stopRefresh();
if (name === "finance") loadFinance();
}
// ── Energy Dashboard ──────────────────────────────────────────
@@ -705,6 +712,112 @@ function renderEnergy(inverters, aggregates, period, spotData) {
</div>`;
}
// ── Finance Tab ───────────────────────────────────────────────
async function loadFinance() {
const el = document.getElementById("finance-content");
let data;
try { data = await fetchJSON(api("api/finance")); }
catch(e) { el.innerHTML = '<div class="no-data">Fehler beim Laden</div>'; return; }
const { days, fixed_total_eur, spot_total_eur, savings_eur, period_start, spot_days, total_days } = data;
if (!days || days.length === 0) {
el.innerHTML = `<div style="text-align:center;padding:40px;color:var(--text-dim)">
<div style="font-size:32px;margin-bottom:12px">📊</div>
<div style="font-weight:600;margin-bottom:6px">Noch keine Daten</div>
<div style="font-size:13px">Der Tracker sammelt ab morgen täglich Daten.<br>Abrechnungsjahr ab ${period_start}.</div>
</div>`;
return;
}
const C = { fixed: '#58a6ff', spot: '#f0883e', grid: '#30363d', green: '#4caf82', red: '#e05c5c' };
// ── Empfehlung ───────────────────────────────────────────────
let empfehlung = '';
if (spot_total_eur !== null && spot_days >= 7) {
const lohnt = savings_eur > 0;
const col = lohnt ? C.green : C.red;
const icon = lohnt ? '✓' : '✗';
empfehlung = `<div style="display:flex;align-items:center;justify-content:space-between;background:var(--surface);border:1px solid ${col};border-radius:var(--radius);padding:14px 18px;margin-bottom:16px">
<div>
<div style="font-weight:700;font-size:15px;color:${col}">${icon} Flexibler Tarif würde sich ${lohnt ? 'lohnen' : 'nicht lohnen'}</div>
<div style="font-size:12px;color:var(--text-dim);margin-top:3px">Basierend auf ${spot_days} Tagen im Abrechnungsjahr</div>
</div>
<div style="font-size:22px;font-weight:700;color:${col}">${lohnt ? '' : '+'}${fEur(Math.abs(savings_eur))}</div>
</div>`;
}
// ── Summary-Karten ───────────────────────────────────────────
const spotCard = spot_total_eur !== null
? `<div class="kwh-card" style="border-top:3px solid ${C.spot}">
<div class="kv" style="color:${C.spot}">${fEur(spot_total_eur)}</div>
<div class="kl">Spot-Tarif (hypothetisch)<br><span style="opacity:.6">${spot_days} Tage mit Preisdaten</span></div>
</div>`
: `<div class="kwh-card"><div class="kv" style="color:var(--text-dim)"></div><div class="kl">Spot-Tarif<br><span style="opacity:.6">Noch keine Daten</span></div></div>`;
const savCard = savings_eur !== null
? `<div class="kwh-card" style="border-top:3px solid ${savings_eur > 0 ? C.green : C.red}">
<div class="kv" style="color:${savings_eur > 0 ? C.green : C.red}">${savings_eur > 0 ? '' : '+'}${fEur(Math.abs(savings_eur))}</div>
<div class="kl">${savings_eur > 0 ? 'Ersparnis mit Spot' : 'Mehrkosten mit Spot'}<br><span style="opacity:.6">gegenüber Festpreis</span></div>
</div>`
: '';
const cards = `<div class="energy-kwh" style="margin-bottom:20px">
<div class="kwh-card" style="border-top:3px solid ${C.fixed}">
<div class="kv" style="color:${C.fixed}">${fEur(fixed_total_eur)}</div>
<div class="kl">Festpreis-Kosten<br><span style="opacity:.6">${total_days} Tage</span></div>
</div>
${spotCard}${savCard}
</div>`;
// ── SVG-Balkendiagramm ────────────────────────────────────────
const W = 600, H = 180, PL = 44, PR = 12, PT = 10, PB = 28;
const cW = W - PL - PR;
const cH = H - PT - PB;
const n = days.length;
const barW = Math.max(2, Math.floor(cW / n) - 2);
const gap = Math.max(1, Math.floor(cW / n) - barW);
const allVals = days.flatMap(d => [d.fixed_eur, d.spot_eur].filter(v => v != null));
const maxVal = Math.max(...allVals, 0.01);
const yTicks = 4;
let gridLines = '', yLabels = '';
for (let i = 0; i <= yTicks; i++) {
const v = maxVal * i / yTicks;
const y = PT + cH - (cH * i / yTicks);
gridLines += `<line x1="${PL}" y1="${y.toFixed(1)}" x2="${W-PR}" y2="${y.toFixed(1)}" stroke="${C.grid}" stroke-width="0.5"/>`;
yLabels += `<text x="${PL-4}" y="${(y+4).toFixed(1)}" text-anchor="end" font-size="9" fill="#8b949e">${fEur(v)}</text>`;
}
let bars = '', xLabels = '';
days.forEach((d, i) => {
const x0 = PL + i * (barW + gap);
const hFixed = cH * d.fixed_eur / maxVal;
bars += `<rect x="${x0}" y="${(PT + cH - hFixed).toFixed(1)}" width="${barW/2}" height="${hFixed.toFixed(1)}" fill="${C.fixed}" opacity="0.85"/>`;
if (d.spot_eur != null) {
const hSpot = cH * d.spot_eur / maxVal;
bars += `<rect x="${x0 + barW/2}" y="${(PT + cH - hSpot).toFixed(1)}" width="${barW/2}" height="${hSpot.toFixed(1)}" fill="${C.spot}" opacity="0.85"/>`;
}
if (i % Math.max(1, Math.floor(n / 8)) === 0) {
const label = d.date.slice(5);
xLabels += `<text x="${(x0 + barW/2).toFixed(1)}" y="${H - 4}" text-anchor="middle" font-size="9" fill="#8b949e">${label}</text>`;
}
});
const legend = `<text x="${PL}" y="${H+18}" font-size="10" fill="${C.fixed}">■ Festpreis</text>
<text x="${PL+70}" y="${H+18}" font-size="10" fill="${C.spot}">■ Spot (hypothetisch)</text>`;
const chart = `<div style="margin-bottom:20px;overflow-x:auto">
<svg viewBox="0 0 ${W} ${H+24}" style="width:100%;max-width:${W}px;display:block">
${gridLines}${yLabels}${bars}${xLabels}${legend}
</svg>
</div>`;
el.innerHTML = empfehlung + cards + chart;
}
// ── Live Data ─────────────────────────────────────────────────
async function refreshData() {