v1.8.8: Überschuss-Geräte (Zigbee2MQTT) + Bugfix Eigenversorgungskarte

- Neu: SurplusDeviceController — schaltet Z2M-Geräte bei PV-Überschuss
  ein/aus (Schwellwert + Hysterese pro Gerät, Background-Loop 30s)
- Neu: API GET/POST /api/surplus-devices, Konfig persistent in config.json
- Neu: Settings-Tab "Überschuss-Geräte", Live-Tab zeigt ON/OFF-Status
- Bugfix: Eigenversorgungskarte (Monat/Jahr) bleibt abends sichtbar wenn
  Wechselrichter offline — letzte kWh-Zähler werden als Fallback genutzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
retr0
2026-05-04 10:53:46 +02:00
parent ec6a0e8514
commit 83035fed0e
5 changed files with 246 additions and 9 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
name: ShineBridge
version: "1.8.7"
version: "1.8.8"
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
+72 -4
View File
@@ -16,6 +16,7 @@ from goodwe_client import GoodweReader
from wallbox_client import WallboxReader
from ems_controller import EmsController
from mqtt_publisher import MqttPublisher
from surplus_devices import SurplusDeviceController
logging.basicConfig(
level=logging.INFO,
@@ -73,8 +74,13 @@ class State:
mqtt_cfg: Dict[str, Any] = {}
inverters_cfg: List[Dict[str, Any]] = []
inv_data: Dict[str, Dict[str, Any]] = {}
surplus_devices_cfg: List[Dict[str, Any]] = []
z2m_base: str = "zigbee2mqtt"
_publisher: Optional[MqttPublisher] = None
_surplus_ctrl: Optional[SurplusDeviceController] = None
_surplus_stop: threading.Event = threading.Event()
_surplus_thread: Optional[threading.Thread] = None
_threads: Dict[str, threading.Thread] = {}
_stop_events: Dict[str, threading.Event] = {}
@@ -95,6 +101,8 @@ def _defaults() -> Dict[str, Any]:
"spot_markup": 0.0,
"spot_chart": True,
"inverters": [],
"surplus_devices": [],
"z2m_base": "zigbee2mqtt",
}
def load_config() -> Dict[str, Any]:
@@ -131,19 +139,23 @@ def save_config():
"spot_markup": State.mqtt_cfg.get("spot_markup", 0.0),
"spot_chart": State.mqtt_cfg.get("spot_chart", True),
"inverters": State.inverters_cfg,
"surplus_devices": State.surplus_devices_cfg,
"z2m_base": State.z2m_base,
}
with open(CONFIG_PATH, "w") as f:
json.dump(data, f, indent=2)
# ── Aggregation ───────────────────────────────────────────────
def _compute_aggregates() -> Dict[str, float]:
def _compute_aggregates(allow_stale: bool = False) -> Dict[str, float]:
buckets: Dict[str, List[float]] = defaultdict(list)
with State.lock:
for inv_cfg in State.inverters_cfg:
inv_id = inv_cfg["id"]
d = State.inv_data.get(inv_id, {})
if not d.get("modbus_ok") or not d.get("values"):
if not d.get("values"):
continue
if not allow_stale and not d.get("modbus_ok"):
continue
values = d["values"]
for agg_id, sensor_ids in AGG_SENSOR_IDS.items():
@@ -315,8 +327,27 @@ def _stop_inverter(inv_id: str):
_stop_events.pop(inv_id, None)
_threads.pop(inv_id, None)
def _surplus_loop(stop: threading.Event):
while not stop.wait(30):
ctrl = _surplus_ctrl
if ctrl is None:
continue
surplus = _get_pv_surplus()
ctrl.update(surplus)
def _start_surplus_loop():
global _surplus_stop, _surplus_thread
_surplus_stop.set()
if _surplus_thread and _surplus_thread.is_alive():
_surplus_thread.join(timeout=5)
_surplus_stop = threading.Event()
_surplus_thread = threading.Thread(
target=_surplus_loop, args=(_surplus_stop,), daemon=True, name="surplus-loop"
)
_surplus_thread.start()
def _restart_all():
global _publisher
global _publisher, _surplus_ctrl
for inv_id in list(_threads.keys()):
_stop_inverter(inv_id)
if _publisher:
@@ -332,6 +363,13 @@ def _restart_all():
_publisher.connect()
time.sleep(2)
with State.lock:
devices = State.surplus_devices_cfg
z2m_base = State.z2m_base
_surplus_ctrl = SurplusDeviceController(_publisher, z2m_base)
_surplus_ctrl.set_config(devices, z2m_base)
_start_surplus_loop()
for inv_cfg in State.inverters_cfg:
_start_inverter(inv_cfg)
@@ -373,7 +411,7 @@ def api_save_config():
@app.get("/api/period-energy")
def api_period_energy():
import datetime
agg = _compute_aggregates()
agg = _compute_aggregates(allow_stale=True)
price_import = float(State.mqtt_cfg.get("price_import", 0.30))
price_export = float(State.mqtt_cfg.get("price_export", 0.08))
billing_day = int(State.mqtt_cfg.get("billing_day", 1))
@@ -450,6 +488,34 @@ def api_period_energy():
return jsonify(result)
@app.get("/api/surplus-devices")
def api_get_surplus_devices():
with State.lock:
devices = State.surplus_devices_cfg
z2m_base = State.z2m_base
states = _surplus_ctrl.get_states() if _surplus_ctrl else {}
return jsonify({"devices": devices, "z2m_base": z2m_base, "states": states})
@app.post("/api/surplus-devices")
def api_save_surplus_devices():
data = request.get_json(force=True) or {}
devices = data.get("devices", [])
z2m_base = str(data.get("z2m_base", "zigbee2mqtt")).strip() or "zigbee2mqtt"
if not isinstance(devices, list):
return jsonify({"error": "invalid"}), 400
for dev in devices:
if not isinstance(dev, dict) or not dev.get("z2m_name"):
return jsonify({"error": "z2m_name fehlt"}), 400
if "id" not in dev:
dev["id"] = uuid.uuid4().hex[:8]
with State.lock:
State.surplus_devices_cfg = devices
State.z2m_base = z2m_base
save_config()
if _surplus_ctrl:
_surplus_ctrl.set_config(devices, z2m_base)
return jsonify({"ok": True})
@app.get("/api/inverters-config")
def api_get_inverters():
with State.lock:
@@ -655,6 +721,8 @@ if __name__ == "__main__":
State.mqtt_cfg = {k: cfg[k] for k in
("mqtt_broker", "mqtt_port", "mqtt_user", "mqtt_pass")}
State.inverters_cfg = cfg.get("inverters", [])
State.surplus_devices_cfg = cfg.get("surplus_devices", [])
State.z2m_base = cfg.get("z2m_base", "zigbee2mqtt")
if not State.inverters_cfg and cfg.get("modbus_ip"):
State.inverters_cfg = [{
"id": uuid.uuid4().hex[:8],
+5
View File
@@ -138,3 +138,8 @@ class MqttPublisher:
self._client.publish(f"{AGG_TOPIC}/state", json.dumps(values),
retain=True, qos=0)
self._client.publish(f"{AGG_TOPIC}/status", "online", retain=True, qos=1)
def publish_raw(self, topic: str, payload: str, retain: bool = False, qos: int = 1):
if not self._connected:
return
self._client.publish(topic, payload, retain=retain, qos=qos)
+52
View File
@@ -0,0 +1,52 @@
import json
import logging
from typing import Any, Dict, List
log = logging.getLogger(__name__)
class SurplusDeviceController:
"""Schaltet Zigbee2MQTT-Geräte bei PV-Überschuss ein/aus."""
def __init__(self, publisher, z2m_base: str = "zigbee2mqtt"):
self._pub = publisher
self._base = z2m_base
self._devices: List[Dict[str, Any]] = []
self._states: Dict[str, bool] = {}
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_before = {d["z2m_name"] for d in self._devices if d.get("enabled", True)}
for name in enabled_before - enabled_now:
if self._states.get(name, False):
self._send(name, False)
self._devices = devices
self._base = z2m_base
def update(self, surplus_w: float):
for dev in self._devices:
name = dev["z2m_name"]
if not dev.get("enabled", True):
if self._states.get(name, False):
self._send(name, False)
continue
threshold = float(dev.get("threshold_w", 500))
hysteresis = float(dev.get("hysteresis_w", 150))
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]:
return dict(self._states)
def _send(self, z2m_name: str, on: bool):
topic = f"{self._base}/{z2m_name}/set"
payload = json.dumps({"state": "ON" if on else "OFF"})
try:
self._pub.publish_raw(topic, payload)
self._states[z2m_name] = on
log.info("[SurplusDevices] %s%s", z2m_name, "ON" if on else "OFF")
except Exception as e:
log.error("[SurplusDevices] MQTT Fehler: %s", e)
+116 -4
View File
@@ -101,6 +101,8 @@
font-size: 14px; transition: border-color .15s, color .15s; }
.add-btn:hover { border-color: var(--accent); color: var(--accent); }
.surplus-device-row { background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius); padding:10px 12px; }
.surplus-device-row input[type=text], .surplus-device-row input[type=number] { background:var(--surface); border:1px solid var(--border); border-radius:4px; color:var(--text); padding:5px 8px; font-size:13px; }
/* Settings */
.settings-section { background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 20px; max-width: 500px; }
@@ -255,6 +257,18 @@
<button class="btn btn-primary" onclick="savePrices()">Speichern</button>
</div>
<div class="settings-section">
<h3>Überschuss-Geräte (Zigbee2MQTT)</h3>
<p style="color:var(--text-dim);font-size:.85rem;margin:0 0 .75rem">Geräte automatisch einschalten wenn PV-Überschuss ins Netz eingespeist wird.</p>
<div class="field" style="max-width:280px"><label>Z2M Basis-Topic</label>
<input type="text" id="cfg-z2m-base" placeholder="zigbee2mqtt"></div>
<div id="surplus-device-list" style="margin-bottom:10px;display:flex;flex-direction:column;gap:8px"></div>
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
<button class="btn btn-secondary" onclick="addSurplusDevice()">+ Gerät hinzufügen</button>
<button class="btn btn-primary" onclick="saveSurplusDevices()">Speichern</button>
</div>
</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>
@@ -643,13 +657,16 @@ function renderEnergy(inverters, aggregates, period, spotData) {
async function refreshData() {
try {
const d = await fetchJSON(api("api/data"));
const [d, surplusData] = await Promise.all([
fetchJSON(api("api/data")),
fetchJSON(api("api/surplus-devices")).catch(() => ({})),
]);
liveData = d;
document.getElementById("pill-mqtt").className = `pill ${d.mqtt_ok ? "ok" : "err"}`;
const keys = Object.keys(d.inverters || {});
document.getElementById("subtitle").textContent =
keys.length ? `${keys.length} Gerät${keys.length !== 1 ? "e" : ""}` : "Keine Geräte";
renderLive(d.inverters || {}, d.aggregates || {});
renderLive(d.inverters || {}, d.aggregates || {}, surplusData);
const period = await fetchJSON(api("api/period-energy")).catch(() => ({}));
const spot = await fetchJSON(api("api/spot-price")).catch(() => ({}));
renderEnergy(d.inverters || {}, d.aggregates || {}, period, (spot.data || []));
@@ -680,14 +697,15 @@ function renderAggregates(aggregates) {
</div>`;
}
function renderLive(inverters, aggregates) {
function renderLive(inverters, aggregates, surplusData) {
const el = document.getElementById("live-content");
if (!Object.keys(inverters).length) {
el.innerHTML = '<div class="no-data">Keine Geräte konfiguriert.<br>Bitte im Tab „Geräte" hinzufügen.</div>';
return;
}
const aggHtml = renderAggregates(aggregates);
el.innerHTML = aggHtml + Object.values(inverters).map(inv => {
const surplusHtml = renderSurplusStatus(surplusData);
el.innerHTML = aggHtml + surplusHtml + Object.values(inverters).map(inv => {
const ago = inv.last_update ? Math.round(Date.now()/1000 - inv.last_update) + "s" : "—";
const cards = (inv.sensors || []).map(s => {
const val = inv.values[s.id];
@@ -878,6 +896,7 @@ function updateTariffUI() {
async function loadSettings() {
const cfg = await fetchJSON(api("api/config"));
globalConfig = cfg;
loadSurplusDevices();
document.getElementById("cfg-mqtt-broker").value = cfg.mqtt_broker || "";
document.getElementById("cfg-mqtt-port").value = cfg.mqtt_port || 1883;
document.getElementById("cfg-mqtt-user").value = cfg.mqtt_user || "";
@@ -964,6 +983,99 @@ async function importConfig(input) {
input.value = "";
}
// ── Surplus Devices ───────────────────────────────────────────
let surplusDevices = [];
function renderSurplusDeviceRow(dev) {
const id = dev.id || ('sd_' + Math.random().toString(36).slice(2));
return `<div class="surplus-device-row" data-id="${esc(id)}">
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<input type="text" placeholder="Name" style="flex:1;min-width:110px" value="${esc(dev.name||'')}" data-field="name">
<input type="text" placeholder="Z2M Friendly Name" 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="Hysterese (W)" title="Hysterese" style="width:90px" value="${dev.hysteresis_w||150}" min="0" step="50" data-field="hysteresis_w">
<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
</label>
<button class="btn btn-secondary" style="padding:3px 8px;font-size:12px;flex-shrink:0" onclick="removeSurplusDevice(this)">✕</button>
</div>
</div>`;
}
async function loadSurplusDevices() {
const d = await fetchJSON(api("api/surplus-devices")).catch(() => ({}));
surplusDevices = d.devices || [];
document.getElementById("cfg-z2m-base").value = d.z2m_base || "zigbee2mqtt";
const list = document.getElementById("surplus-device-list");
list.innerHTML = surplusDevices.map(renderSurplusDeviceRow).join('');
}
function addSurplusDevice() {
const list = document.getElementById("surplus-device-list");
const id = 'sd_' + Math.random().toString(36).slice(2);
const div = document.createElement('div');
div.innerHTML = renderSurplusDeviceRow({id, name:'', z2m_name:'', threshold_w:500, hysteresis_w:150, enabled:true});
list.appendChild(div.firstElementChild);
}
function removeSurplusDevice(btn) {
btn.closest('.surplus-device-row').remove();
}
function _collectSurplusDevices() {
return Array.from(document.querySelectorAll('.surplus-device-row')).map(row => ({
id: row.dataset.id,
name: row.querySelector('[data-field=name]').value.trim(),
z2m_name: row.querySelector('[data-field=z2m_name]').value.trim(),
threshold_w: parseFloat(row.querySelector('[data-field=threshold_w]').value) || 0,
hysteresis_w: parseFloat(row.querySelector('[data-field=hysteresis_w]').value) || 0,
enabled: row.querySelector('[data-field=enabled]').checked,
}));
}
async function saveSurplusDevices() {
const devices = _collectSurplusDevices();
const z2m_base = document.getElementById("cfg-z2m-base").value.trim() || "zigbee2mqtt";
for (const d of devices) {
if (!d.z2m_name) { showToast("Z2M Name darf nicht leer sein", "err"); return; }
}
try {
await fetchJSON(api("api/surplus-devices"), {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({devices, z2m_base}),
});
showToast("Überschuss-Geräte gespeichert", "ok");
} catch(e) {
showToast("Fehler beim Speichern", "err");
}
}
function renderSurplusStatus(surplusData) {
if (!surplusData || !surplusData.devices || !surplusData.devices.length) return '';
const states = surplusData.states || {};
const rows = surplusData.devices
.filter(d => d.enabled !== false)
.map(d => {
const on = states[d.z2m_name] === true;
const dot = `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0;background:${on?'var(--green)':'var(--border)'}"></span>`;
return `<div style="display:flex;align-items:center;gap:6px;font-size:12px;padding:4px 0">
${dot}
<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="margin-left:auto;font-weight:600;color:${on?'var(--green)':'var(--text-dim)'}">${on?'EIN':'AUS'}</span>
</div>`;
}).join('');
if (!rows) return '';
return `<div class="inv-section">
<div class="inv-header">
<div class="inv-title">Überschuss-Geräte</div>
<div class="inv-badge ok">Z2M</div>
</div>
<div style="padding:0 2px">${rows}</div>
</div>`;
}
// ── Init ──────────────────────────────────────────────────────
(async () => {