Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33ada90df4 | |||
| 0dbf0266a8 | |||
| 5942c18df6 | |||
| 58a33f966d |
@@ -1,5 +1,5 @@
|
|||||||
name: ShineBridge
|
name: ShineBridge
|
||||||
version: "1.8.9"
|
version: "1.8.12"
|
||||||
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
@@ -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():
|
||||||
|
|||||||
@@ -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,29 @@ 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
|
||||||
currently_on = self._states.get(name, False)
|
currently_on = self._states.get(name, False)
|
||||||
|
|
||||||
if not currently_on and surplus_w >= threshold:
|
if not currently_on and surplus_w >= threshold:
|
||||||
self._send(name, True)
|
self._send(name, True)
|
||||||
|
self._on_since[name] = now
|
||||||
elif currently_on and surplus_w < (threshold - hysteresis):
|
elif currently_on and surplus_w < (threshold - hysteresis):
|
||||||
|
on_since = self._on_since.get(name, 0.0)
|
||||||
|
if now - on_since >= min_on_s:
|
||||||
self._send(name, False)
|
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, bool]:
|
def get_states(self) -> Dict[str, Any]:
|
||||||
return dict(self._states)
|
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 +65,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)
|
||||||
|
|||||||
@@ -996,6 +996,7 @@ function renderSurplusDeviceRow(dev) {
|
|||||||
<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">
|
<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">
|
||||||
<input type="number" placeholder="Ab (W)" title="Schwellwert" style="width:76px" value="${dev.threshold_w||500}" min="0" step="50" data-field="threshold_w">
|
<input type="number" placeholder="Ab (W)" title="Schwellwert" style="width:76px" 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">
|
<input type="number" placeholder="Hysterese (W)" title="Hysterese" style="width:90px" value="${dev.hysteresis_w||150}" min="0" step="50" data-field="hysteresis_w">
|
||||||
|
<input type="number" placeholder="Min. Laufzeit (min)" title="Mindest-Laufzeit in Minuten" style="width:110px" value="${dev.min_on_minutes||0}" min="0" step="5" data-field="min_on_minutes">
|
||||||
<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>
|
||||||
@@ -1042,6 +1043,7 @@ 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,
|
||||||
enabled: row.querySelector('[data-field=enabled]').checked,
|
enabled: row.querySelector('[data-field=enabled]').checked,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -1069,20 +1071,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">ab ${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>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user