From 83035fed0eafaaf7968a7fa2c9c9fd9a358fef8a Mon Sep 17 00:00:00 2001 From: retr0 <42kdesigners@gmail.com> Date: Mon, 4 May 2026 10:53:46 +0200 Subject: [PATCH] =?UTF-8?q?v1.8.8:=20=C3=9Cberschuss-Ger=C3=A4te=20(Zigbee?= =?UTF-8?q?2MQTT)=20+=20Bugfix=20Eigenversorgungskarte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- haos-addon/config.yaml | 2 +- haos-addon/src/main.py | 76 ++++++++++++++++++- haos-addon/src/mqtt_publisher.py | 5 ++ haos-addon/src/surplus_devices.py | 52 +++++++++++++ haos-addon/src/web/index.html | 120 +++++++++++++++++++++++++++++- 5 files changed, 246 insertions(+), 9 deletions(-) create mode 100644 haos-addon/src/surplus_devices.py diff --git a/haos-addon/config.yaml b/haos-addon/config.yaml index e2d8259..984996c 100644 --- a/haos-addon/config.yaml +++ b/haos-addon/config.yaml @@ -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 diff --git a/haos-addon/src/main.py b/haos-addon/src/main.py index 3fe0bbf..d06273e 100644 --- a/haos-addon/src/main.py +++ b/haos-addon/src/main.py @@ -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], diff --git a/haos-addon/src/mqtt_publisher.py b/haos-addon/src/mqtt_publisher.py index 5e26736..2d49b01 100644 --- a/haos-addon/src/mqtt_publisher.py +++ b/haos-addon/src/mqtt_publisher.py @@ -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) diff --git a/haos-addon/src/surplus_devices.py b/haos-addon/src/surplus_devices.py new file mode 100644 index 0000000..62d0c1b --- /dev/null +++ b/haos-addon/src/surplus_devices.py @@ -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) diff --git a/haos-addon/src/web/index.html b/haos-addon/src/web/index.html index 2642abc..8b91dc9 100644 --- a/haos-addon/src/web/index.html +++ b/haos-addon/src/web/index.html @@ -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 @@ +
+

Überschuss-Geräte (Zigbee2MQTT)

+

Geräte automatisch einschalten wenn PV-Überschuss ins Netz eingespeist wird.

+
+
+
+
+ + +
+
+

Konfiguration sichern

Alle Geräte und MQTT-Einstellungen als JSON exportieren und bei einer Neuinstallation wieder einlesen.

@@ -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) {
`; } -function renderLive(inverters, aggregates) { +function renderLive(inverters, aggregates, surplusData) { const el = document.getElementById("live-content"); if (!Object.keys(inverters).length) { el.innerHTML = '
Keine Geräte konfiguriert.
Bitte im Tab „Geräte" hinzufügen.
'; 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 `
+
+ + + + + + +
+
`; +} + +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 = ``; + return `
+ ${dot} + ${esc(d.name||d.z2m_name)} + ab ${d.threshold_w}W + ${on?'EIN':'AUS'} +
`; + }).join(''); + if (!rows) return ''; + return `
+
+
Überschuss-Geräte
+
Z2M
+
+
${rows}
+
`; +} + // ── Init ────────────────────────────────────────────────────── (async () => {