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 @@ +
Geräte automatisch einschalten wenn PV-Überschuss ins Netz eingespeist wird.
+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) {