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:
@@ -1,5 +1,5 @@
|
|||||||
name: ShineBridge
|
name: ShineBridge
|
||||||
version: "1.8.15"
|
version: "1.8.16"
|
||||||
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
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ def init_db():
|
|||||||
PRIMARY KEY (agg_id, period_type, period_key)
|
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()
|
c.commit()
|
||||||
cleanup_old()
|
cleanup_old()
|
||||||
log.info("History DB initialisiert: %s", DB_PATH)
|
log.info("History DB initialisiert: %s", DB_PATH)
|
||||||
@@ -137,6 +147,51 @@ def query(inv_id: str, sensor_id: str,
|
|||||||
return rows
|
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):
|
def cleanup_old(days: int = RETENTION_DAYS):
|
||||||
cutoff = time.time() - days * 86400
|
cutoff = time.time() - days * 86400
|
||||||
with _lock:
|
with _lock:
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ class State:
|
|||||||
surplus_devices_cfg: List[Dict[str, Any]] = []
|
surplus_devices_cfg: List[Dict[str, Any]] = []
|
||||||
z2m_base: str = "zigbee2mqtt"
|
z2m_base: str = "zigbee2mqtt"
|
||||||
z2m_devices: List[Dict[str, Any]] = []
|
z2m_devices: List[Dict[str, Any]] = []
|
||||||
|
last_tariff_snapshot_date: str = ""
|
||||||
|
|
||||||
_publisher: Optional[MqttPublisher] = None
|
_publisher: Optional[MqttPublisher] = None
|
||||||
_surplus_ctrl: Optional[SurplusDeviceController] = None
|
_surplus_ctrl: Optional[SurplusDeviceController] = None
|
||||||
@@ -193,6 +194,37 @@ def _get_pv_surplus() -> float:
|
|||||||
|
|
||||||
# ── Poll Loop ─────────────────────────────────────────────────
|
# ── 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):
|
def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
|
||||||
inv_id = inv_cfg["id"]
|
inv_id = inv_cfg["id"]
|
||||||
model_id = inv_cfg.get("inverter_model", "MIC_1500_TL_X")
|
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:
|
if agg:
|
||||||
_publisher.publish_aggregates(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)))
|
stop.wait(max(0.0, interval - (time.time() - t0)))
|
||||||
|
|
||||||
reader.close()
|
reader.close()
|
||||||
@@ -533,6 +573,49 @@ def api_period_energy():
|
|||||||
|
|
||||||
return jsonify(result)
|
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")
|
@app.get("/api/z2m-devices")
|
||||||
def api_z2m_devices():
|
def api_z2m_devices():
|
||||||
with State.lock:
|
with State.lock:
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
<main>
|
<main>
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<div class="tab active" onclick="switchTab('energy')">Energie</div>
|
<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('live')">Live-Daten</div>
|
||||||
<div class="tab" onclick="switchTab('inverters')">Geräte</div>
|
<div class="tab" onclick="switchTab('inverters')">Geräte</div>
|
||||||
<div class="tab" onclick="switchTab('settings')">Einstellungen</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 id="energy-content"><div class="no-data">Warte auf erste Messung…</div></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 -->
|
<!-- Live Panel -->
|
||||||
<div class="panel" id="panel-live">
|
<div class="panel" id="panel-live">
|
||||||
<div id="live-content"><div class="no-data">Warte auf erste Messung...</div></div>
|
<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) {
|
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(".tab")[i].classList.toggle("active", t === name);
|
||||||
document.querySelectorAll(".panel")[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 === "energy" || name === "live") startRefresh(); else stopRefresh();
|
||||||
|
if (name === "finance") loadFinance();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Energy Dashboard ──────────────────────────────────────────
|
// ── Energy Dashboard ──────────────────────────────────────────
|
||||||
@@ -705,6 +712,112 @@ function renderEnergy(inverters, aggregates, period, spotData) {
|
|||||||
</div>`;
|
</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 ─────────────────────────────────────────────────
|
// ── Live Data ─────────────────────────────────────────────────
|
||||||
|
|
||||||
async function refreshData() {
|
async function refreshData() {
|
||||||
|
|||||||
Reference in New Issue
Block a user