5 Commits

Author SHA1 Message Date
retr0 5ab8ee75fb v1.8.13: Labels + Invertiert-Modus für Überschuss-Geräte
- Beschriftung über jedem Eingabefeld (Name, Z2M Name, Schwellwert, etc.)
- Checkbox "Invertiert": EIN bei Netzbezug, AUS bei Überschuss
- Live-Tab: ↑/↓ Pfeil zeigt normale/invertierte Logik

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 15:12:15 +02:00
retr0 33ada90df4 v1.8.12: Mindest-Laufzeit pro Überschuss-Gerät
- min_on_minutes pro Gerät: Gerät bleibt mindestens X Minuten an
  auch wenn Überschuss unter Ausschaltschwelle fällt (Boiler-Fix)
- Live-Tab zeigt verbleibende Mindestlaufzeit während Gerät läuft
- get_states() liefert on_minutes für Laufzeit-Tracking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 12:02:37 +02:00
retr0 0dbf0266a8 fix: _get_pv_surplus() via grid_power-Aggregat (SDM-630 fix, v1.8.11)
SDM-630 liefert grid_power (negativ=Einspeisung), wurde von active_power-
Logik nicht erfasst. Jetzt einheitlich über grid_power-Aggregat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 11:42:08 +02:00
retr0 5942c18df6 fix: config.yaml version auf 1.8.10 2026-05-04 11:35:25 +02:00
retr0 58a33f966d debug: surplus_w in API + Live-Tab anzeigen 2026-05-04 11:31:06 +02:00
4 changed files with 81 additions and 40 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
name: ShineBridge name: ShineBridge
version: "1.8.9" version: "1.8.13"
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
+5 -20
View File
@@ -180,25 +180,9 @@ def _compute_aggregates(allow_stale: bool = False) -> Dict[str, float]:
# ── EMS Hilfsfunktionen ─────────────────────────────────────── # ── EMS Hilfsfunktionen ───────────────────────────────────────
def _get_pv_surplus() -> float: def _get_pv_surplus() -> float:
"""PV-Überschuss in Watt aus laufenden Geräten ermitteln. """PV-Überschuss in Watt. Nutzt grid_power-Aggregat (negativ = Einspeisung)."""
Goodwe: active_power negativ = Einspeisung (Überschuss). agg = _compute_aggregates(allow_stale=True)
Growatt: power_to_grid positiv = Einspeisung. return max(0.0, -agg.get("grid_power", 0.0))
"""
surplus = 0.0
with State.lock:
for inv_cfg in State.inverters_cfg:
d = State.inv_data.get(inv_cfg["id"], {})
if not d.get("modbus_ok") or not d.get("values"):
continue
v = d["values"]
# Goodwe ET: active_power > 0 = Einspeisung, < 0 = Netzbezug
# house_consumption = ppv + pbattery1 - active_power (Bibliotheks-Formel)
if "active_power" in v:
surplus += max(0.0, v["active_power"])
# Growatt
if "power_to_grid" in v:
surplus += max(0.0, v["power_to_grid"])
return surplus
# ── Poll Loop ───────────────────────────────────────────────── # ── Poll Loop ─────────────────────────────────────────────────
@@ -522,7 +506,8 @@ def api_get_surplus_devices():
devices = State.surplus_devices_cfg devices = State.surplus_devices_cfg
z2m_base = State.z2m_base z2m_base = State.z2m_base
states = _surplus_ctrl.get_states() if _surplus_ctrl else {} states = _surplus_ctrl.get_states() if _surplus_ctrl else {}
return jsonify({"devices": devices, "z2m_base": z2m_base, "states": states}) surplus_w = _get_pv_surplus()
return jsonify({"devices": devices, "z2m_base": z2m_base, "states": states, "surplus_w": surplus_w})
@app.post("/api/surplus-devices") @app.post("/api/surplus-devices")
def api_save_surplus_devices(): def api_save_surplus_devices():
+32 -7
View File
@@ -1,6 +1,7 @@
import json import json
import logging import logging
from typing import Any, Dict, List import time
from typing import Any, Dict, List, Optional
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -13,6 +14,7 @@ class SurplusDeviceController:
self._base = z2m_base self._base = z2m_base
self._devices: List[Dict[str, Any]] = [] self._devices: List[Dict[str, Any]] = []
self._states: Dict[str, bool] = {} self._states: Dict[str, bool] = {}
self._on_since: Dict[str, float] = {} # z2m_name → Einschaltzeitpunkt
def set_config(self, devices: List[Dict[str, Any]], z2m_base: str = "zigbee2mqtt"): def set_config(self, devices: List[Dict[str, Any]], z2m_base: str = "zigbee2mqtt"):
enabled_now = {d["z2m_name"] for d in devices if d.get("enabled", True)} enabled_now = {d["z2m_name"] for d in devices if d.get("enabled", True)}
@@ -24,6 +26,7 @@ class SurplusDeviceController:
self._base = z2m_base self._base = z2m_base
def update(self, surplus_w: float): def update(self, surplus_w: float):
now = time.time()
for dev in self._devices: for dev in self._devices:
name = dev["z2m_name"] name = dev["z2m_name"]
if not dev.get("enabled", True): if not dev.get("enabled", True):
@@ -32,14 +35,34 @@ class SurplusDeviceController:
continue continue
threshold = float(dev.get("threshold_w", 500)) threshold = float(dev.get("threshold_w", 500))
hysteresis = float(dev.get("hysteresis_w", 150)) hysteresis = float(dev.get("hysteresis_w", 150))
min_on_s = float(dev.get("min_on_minutes", 0)) * 60
inverted = bool(dev.get("inverted", False))
currently_on = self._states.get(name, False) currently_on = self._states.get(name, False)
if not currently_on and surplus_w >= threshold:
self._send(name, True)
elif currently_on and surplus_w < (threshold - hysteresis):
self._send(name, False)
def get_states(self) -> Dict[str, bool]: # Invertiert: EIN bei Netzbezug (kein Überschuss), AUS bei Überschuss
return dict(self._states) should_turn_on = not currently_on and (surplus_w >= threshold if not inverted else surplus_w < (threshold - hysteresis))
should_turn_off = currently_on and (surplus_w < (threshold - hysteresis) if not inverted else surplus_w >= threshold)
if should_turn_on:
self._send(name, True)
self._on_since[name] = now
elif should_turn_off:
on_since = self._on_since.get(name, 0.0)
if now - on_since >= min_on_s:
self._send(name, False)
else:
remaining = int((min_on_s - (now - on_since)) / 60)
log.info("[SurplusDevices] %s bleibt an — Mindestlaufzeit: noch %dmin", name, remaining)
def get_states(self) -> Dict[str, Any]:
now = time.time()
result = {}
for name, on in self._states.items():
entry: Dict[str, Any] = {"on": on}
if on and name in self._on_since:
entry["on_minutes"] = int((now - self._on_since[name]) / 60)
result[name] = entry
return result
def _send(self, z2m_name: str, on: bool): def _send(self, z2m_name: str, on: bool):
topic = f"{self._base}/{z2m_name}/set" topic = f"{self._base}/{z2m_name}/set"
@@ -47,6 +70,8 @@ class SurplusDeviceController:
try: try:
self._pub.publish_raw(topic, payload) self._pub.publish_raw(topic, payload)
self._states[z2m_name] = on self._states[z2m_name] = on
if not on:
self._on_since.pop(z2m_name, None)
log.info("[SurplusDevices] %s%s", z2m_name, "ON" if on else "OFF") log.info("[SurplusDevices] %s%s", z2m_name, "ON" if on else "OFF")
except Exception as e: except Exception as e:
log.error("[SurplusDevices] MQTT Fehler: %s", e) log.error("[SurplusDevices] MQTT Fehler: %s", e)
+39 -8
View File
@@ -988,18 +988,31 @@ async function importConfig(input) {
let surplusDevices = []; let surplusDevices = [];
function field(label, input) {
return `<div style="display:flex;flex-direction:column;gap:3px">
<span style="font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap">${label}</span>
${input}
</div>`;
}
function renderSurplusDeviceRow(dev) { function renderSurplusDeviceRow(dev) {
const id = dev.id || ('sd_' + Math.random().toString(36).slice(2)); const id = dev.id || ('sd_' + Math.random().toString(36).slice(2));
return `<div class="surplus-device-row" data-id="${esc(id)}"> return `<div class="surplus-device-row" data-id="${esc(id)}">
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"> <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
<input type="text" placeholder="Name" style="flex:1;min-width:110px" value="${esc(dev.name||'')}" data-field="name"> ${field('Name', `<input type="text" placeholder="Heizstab Keller" style="width:130px" value="${esc(dev.name||'')}" data-field="name">`)}
<input type="text" placeholder="Z2M Friendly Name" list="z2m-device-list" style="flex:1;min-width:140px" value="${esc(dev.z2m_name||'')}" data-field="z2m_name"> ${field('Z2M Friendly Name', `<input type="text" placeholder="heizstab-keller" list="z2m-device-list" style="width:160px" value="${esc(dev.z2m_name||'')}" data-field="z2m_name">`)}
<input type="number" placeholder="Ab (W)" title="Schwellwert" style="width:76px" value="${dev.threshold_w||500}" min="0" step="50" data-field="threshold_w"> ${field('Schwellwert (W)', `<input type="number" style="width:90px" value="${dev.threshold_w||500}" min="0" step="50" data-field="threshold_w">`)}
<input type="number" placeholder="Hysterese (W)" title="Hysterese" style="width:90px" value="${dev.hysteresis_w||150}" min="0" step="50" data-field="hysteresis_w"> ${field('Hysterese (W)', `<input type="number" style="width:90px" value="${dev.hysteresis_w||150}" min="0" step="50" data-field="hysteresis_w">`)}
${field('Min. Laufzeit (min)', `<input type="number" style="width:90px" value="${dev.min_on_minutes||0}" min="0" step="5" data-field="min_on_minutes">`)}
<div style="display:flex;flex-direction:column;gap:6px;padding-bottom:2px">
<label style="cursor:pointer;display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap"> <label style="cursor:pointer;display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap">
<input type="checkbox" ${dev.enabled!==false?'checked':''} data-field="enabled" style="width:auto;margin:0"> Aktiv <input type="checkbox" ${dev.enabled!==false?'checked':''} data-field="enabled" style="width:auto;margin:0"> Aktiv
</label> </label>
<button class="btn btn-secondary" style="padding:3px 8px;font-size:12px;flex-shrink:0" onclick="removeSurplusDevice(this)">✕</button> <label style="cursor:pointer;display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap" title="AUS bei Überschuss, EIN bei Netzbezug">
<input type="checkbox" ${dev.inverted?'checked':''} data-field="inverted" style="width:auto;margin:0"> Invertiert
</label>
</div>
<button class="btn btn-secondary" style="padding:3px 8px;font-size:12px;flex-shrink:0;align-self:flex-end" onclick="removeSurplusDevice(this)">✕</button>
</div> </div>
</div>`; </div>`;
} }
@@ -1042,6 +1055,8 @@ function _collectSurplusDevices() {
z2m_name: row.querySelector('[data-field=z2m_name]').value.trim(), z2m_name: row.querySelector('[data-field=z2m_name]').value.trim(),
threshold_w: parseFloat(row.querySelector('[data-field=threshold_w]').value) || 0, threshold_w: parseFloat(row.querySelector('[data-field=threshold_w]').value) || 0,
hysteresis_w: parseFloat(row.querySelector('[data-field=hysteresis_w]').value) || 0, hysteresis_w: parseFloat(row.querySelector('[data-field=hysteresis_w]').value) || 0,
min_on_minutes: parseFloat(row.querySelector('[data-field=min_on_minutes]').value) || 0,
inverted: row.querySelector('[data-field=inverted]').checked,
enabled: row.querySelector('[data-field=enabled]').checked, enabled: row.querySelector('[data-field=enabled]').checked,
})); }));
} }
@@ -1069,20 +1084,36 @@ function renderSurplusStatus(surplusData) {
const rows = surplusData.devices const rows = surplusData.devices
.filter(d => d.enabled !== false) .filter(d => d.enabled !== false)
.map(d => { .map(d => {
const on = states[d.z2m_name] === true; const st = states[d.z2m_name] || {};
const on = st.on === true;
const onMin = st.on_minutes ?? null;
const minOn = d.min_on_minutes || 0;
const dot = `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0;background:${on?'var(--green)':'var(--border)'}"></span>`; const dot = `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0;background:${on?'var(--green)':'var(--border)'}"></span>`;
let info = '';
if (on && minOn > 0 && onMin !== null) {
const remaining = Math.max(0, minOn - onMin);
info = remaining > 0
? `<span style="font-size:11px;color:var(--text-dim)">(${remaining}min Mindestlaufzeit)</span>`
: `<span style="font-size:11px;color:var(--text-dim)">(${onMin}min)</span>`;
}
return `<div style="display:flex;align-items:center;gap:6px;font-size:12px;padding:4px 0"> return `<div style="display:flex;align-items:center;gap:6px;font-size:12px;padding:4px 0">
${dot} ${dot}
<span>${esc(d.name||d.z2m_name)}</span> <span>${esc(d.name||d.z2m_name)}</span>
<span style="color:var(--text-dim);font-size:11px">ab ${d.threshold_w}W</span> <span style="color:var(--text-dim);font-size:11px">${d.inverted ? '↓' : '↑'} ${d.threshold_w}W</span>
${info}
<span style="margin-left:auto;font-weight:600;color:${on?'var(--green)':'var(--text-dim)'}">${on?'EIN':'AUS'}</span> <span style="margin-left:auto;font-weight:600;color:${on?'var(--green)':'var(--text-dim)'}">${on?'EIN':'AUS'}</span>
</div>`; </div>`;
}).join(''); }).join('');
if (!rows) return ''; if (!rows) return '';
const surplusW = surplusData.surplus_w ?? null;
const surplusInfo = surplusW !== null
? `<span style="font-size:11px;color:var(--text-dim);margin-left:auto">${surplusW >= 0 ? '+' : ''}${Math.round(surplusW)} W Überschuss</span>`
: '';
return `<div class="inv-section"> return `<div class="inv-section">
<div class="inv-header"> <div class="inv-header">
<div class="inv-title">Überschuss-Geräte</div> <div class="inv-title">Überschuss-Geräte</div>
<div class="inv-badge ok">Z2M</div> <div class="inv-badge ok">Z2M</div>
${surplusInfo}
</div> </div>
<div style="padding:0 2px">${rows}</div> <div style="padding:0 2px">${rows}</div>
</div>`; </div>`;