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:
@@ -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
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user