diff --git a/haos-addon/config.yaml b/haos-addon/config.yaml index 8bac542..3abe35c 100644 --- a/haos-addon/config.yaml +++ b/haos-addon/config.yaml @@ -1,5 +1,5 @@ name: Growatt ShineLAN-X -version: "1.0.5" +version: "1.1.0" slug: growatt_shinelan_x description: Growatt Wechselrichter via ShineLAN-X (NuttX Modbus TCP) - MQTT Discovery + Web UI url: https://gitea.bitfire.work/retr0/Growatt-Wechselrichter-HAOS @@ -14,24 +14,12 @@ ingress: true ingress_port: 8099 panel_icon: mdi:solar-power options: - modbus_ip: "10.10.20.190" - modbus_port: 502 - modbus_address: 1 - inverter_model: "MIC_1500_TL_X" mqtt_broker: "core-mosquitto" mqtt_port: 1883 mqtt_user: "" mqtt_pass: "" - mqtt_topic_prefix: "growatt/shinelanx" - update_interval: 30 schema: - modbus_ip: str - modbus_port: int - modbus_address: int - inverter_model: str mqtt_broker: str mqtt_port: int mqtt_user: str mqtt_pass: password - mqtt_topic_prefix: str - update_interval: int diff --git a/haos-addon/src/main.py b/haos-addon/src/main.py index 538b0b6..f4d9fd3 100644 --- a/haos-addon/src/main.py +++ b/haos-addon/src/main.py @@ -3,11 +3,12 @@ import logging import os import threading import time -from typing import Any, Dict, Optional +import uuid +from typing import Any, Dict, List, Optional from flask import Flask, jsonify, request, send_from_directory -from inverters import INVERTERS, Inverter +from inverters import INVERTERS from modbus_client import ModbusReader from mqtt_publisher import MqttPublisher @@ -23,208 +24,249 @@ WEB_DIR = os.path.join(os.path.dirname(__file__), "web") app = Flask(__name__, static_folder=WEB_DIR) +# ── State ──────────────────────────────────────────────────── class State: lock = threading.Lock() - config: Dict[str, Any] = {} - last_values: Dict[str, float] = {} - last_update: Optional[float] = None - modbus_ok: bool = False - mqtt_ok: bool = False - poll_count: int = 0 - error_count: int = 0 + mqtt_cfg: Dict[str, Any] = {} + inverters_cfg: List[Dict[str, Any]] = [] + # {inv_id: {values, last_update, modbus_ok, poll_count}} + inv_data: Dict[str, Dict[str, Any]] = {} +_publisher: Optional[MqttPublisher] = None +_threads: Dict[str, threading.Thread] = {} +_stop_events: Dict[str, threading.Event] = {} -def load_config() -> Dict[str, Any]: - # Defaults - cfg: Dict[str, Any] = { - "modbus_ip": "10.10.20.190", - "modbus_port": 502, - "modbus_address": 1, - "inverter_model": "MIC_1500_TL_X", +# ── Config ─────────────────────────────────────────────────── + +def _defaults() -> Dict[str, Any]: + return { "mqtt_broker": "core-mosquitto", "mqtt_port": 1883, "mqtt_user": "", "mqtt_pass": "", - "mqtt_topic_prefix": "growatt/shinelanx", - "update_interval": 30, + "inverters": [], } - # HA add-on options (set via UI/config.yaml options) + +def load_config() -> Dict[str, Any]: + cfg = _defaults() if os.path.exists(HA_OPTIONS_PATH): try: with open(HA_OPTIONS_PATH) as f: - cfg.update(json.load(f)) + ha = json.load(f) + for k in ("mqtt_broker", "mqtt_port", "mqtt_user", "mqtt_pass"): + if k in ha: + cfg[k] = ha[k] except Exception as e: - log.warning("HA options konnten nicht gelesen werden: %s", e) - # Web UI overrides (gespeichert via /api/config POST) + log.warning("HA options Fehler: %s", e) if os.path.exists(CONFIG_PATH): try: with open(CONFIG_PATH) as f: cfg.update(json.load(f)) except Exception as e: - log.warning("Config-Datei konnte nicht gelesen werden: %s", e) + log.warning("Config-Datei Fehler: %s", e) return cfg - -def save_config(cfg: Dict[str, Any]): - os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) +def save_config(): + data = { + "mqtt_broker": State.mqtt_cfg.get("mqtt_broker", ""), + "mqtt_port": State.mqtt_cfg.get("mqtt_port", 1883), + "mqtt_user": State.mqtt_cfg.get("mqtt_user", ""), + "mqtt_pass": State.mqtt_cfg.get("mqtt_pass", ""), + "inverters": State.inverters_cfg, + } with open(CONFIG_PATH, "w") as f: - json.dump(cfg, f, indent=2) + json.dump(data, f, indent=2) +# ── Poll Loop ───────────────────────────────────────────────── -_reader: Optional[ModbusReader] = None -_publisher: Optional[MqttPublisher] = None -_poll_thread: Optional[threading.Thread] = None -_stop_event = threading.Event() +def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event): + inv_id = inv_cfg["id"] + model_id = inv_cfg.get("inverter_model", "MIC_1500_TL_X") + inverter = INVERTERS.get(model_id, INVERTERS["MIC_1500_TL_X"]) + prefix = inv_cfg.get("mqtt_topic_prefix", f"growatt/{inv_id}") + device_id = f"growatt_{inv_id}" + interval = max(5, int(inv_cfg.get("update_interval", 30))) - -def _build_reader(cfg: Dict[str, Any]) -> ModbusReader: - return ModbusReader( - host=cfg["modbus_ip"], - port=cfg["modbus_port"], - slave=cfg["modbus_address"], + reader = ModbusReader( + host=inv_cfg["modbus_ip"], + port=int(inv_cfg.get("modbus_port", 502)), + slave=int(inv_cfg.get("modbus_address", 1)), ) + with State.lock: + if _publisher: + _publisher.register_inverter(inverter, device_id, prefix) -def _build_publisher(cfg: Dict[str, Any]) -> MqttPublisher: - return MqttPublisher( - broker=cfg["mqtt_broker"], - port=cfg["mqtt_port"], - user=cfg["mqtt_user"], - password=cfg["mqtt_pass"], - topic_prefix=cfg["mqtt_topic_prefix"], + log.info("[%s] Poll-Loop: %s @ %s:%s alle %ds", + inv_id, inverter.name, inv_cfg["modbus_ip"], + inv_cfg.get("modbus_port", 502), interval) + + while not stop.is_set(): + t0 = time.time() + values = reader.read(inverter) + with State.lock: + d = State.inv_data.setdefault(inv_id, {"poll_count": 0}) + if values is not None: + d["values"] = values + d["last_update"] = time.time() + d["modbus_ok"] = True + d["poll_count"] = d.get("poll_count", 0) + 1 + if _publisher: + _publisher.publish_data(values, prefix) + _publisher.publish_status("online", prefix) + else: + d["modbus_ok"] = False + if _publisher: + _publisher.publish_status("offline", prefix) + + stop.wait(max(0.0, interval - (time.time() - t0))) + + reader.close() + if _publisher: + _publisher.publish_status("offline", prefix) + log.info("[%s] Poll-Loop beendet", inv_id) + +def _start_inverter(inv_cfg: Dict[str, Any]): + inv_id = inv_cfg["id"] + _stop_inverter(inv_id) + ev = threading.Event() + _stop_events[inv_id] = ev + t = threading.Thread(target=_poll_loop, args=(inv_cfg, ev), daemon=True, name=f"poll-{inv_id}") + _threads[inv_id] = t + t.start() + +def _stop_inverter(inv_id: str): + if inv_id in _stop_events: + _stop_events[inv_id].set() + if inv_id in _threads: + _threads[inv_id].join(timeout=15) + _stop_events.pop(inv_id, None) + _threads.pop(inv_id, None) + +def _restart_all(): + global _publisher + for inv_id in list(_threads.keys()): + _stop_inverter(inv_id) + if _publisher: + _publisher.disconnect() + + _publisher = MqttPublisher( + broker=State.mqtt_cfg.get("mqtt_broker", "core-mosquitto"), + port=int(State.mqtt_cfg.get("mqtt_port", 1883)), + user=State.mqtt_cfg.get("mqtt_user", ""), + password=State.mqtt_cfg.get("mqtt_pass", ""), ) - - -def _poll_loop(cfg: Dict[str, Any]): - global _reader, _publisher - - inverter_id = cfg.get("inverter_model", "MIC_1500_TL_X") - inverter: Inverter = INVERTERS.get(inverter_id, INVERTERS["MIC_1500_TL_X"]) - interval = max(5, int(cfg.get("update_interval", 30))) - - _reader = _build_reader(cfg) - _publisher = _build_publisher(cfg) _publisher.connect() time.sleep(2) - _publisher.setup_inverter(inverter) - - log.info("Poll-Loop gestartet: %s alle %ds", inverter.name, interval) - - while not _stop_event.is_set(): - t_start = time.time() - values = _reader.read(inverter) - with State.lock: - if values is not None: - State.last_values = values - State.last_update = time.time() - State.modbus_ok = True - State.poll_count += 1 - _publisher.publish_data(values) - _publisher.publish_status("online") - else: - State.modbus_ok = False - State.error_count += 1 - _publisher.publish_status("offline") - State.mqtt_ok = _publisher.connected - - elapsed = time.time() - t_start - wait = max(0.0, interval - elapsed) - _stop_event.wait(wait) - - _reader.close() - _publisher.publish_status("offline") - _publisher.disconnect() - log.info("Poll-Loop beendet") - - -def start_poll_thread(cfg: Dict[str, Any]): - global _poll_thread - _stop_event.clear() - _poll_thread = threading.Thread(target=_poll_loop, args=(cfg,), daemon=True, name="poll") - _poll_thread.start() - - -def stop_poll_thread(): - _stop_event.set() - if _poll_thread: - _poll_thread.join(timeout=15) + for inv_cfg in State.inverters_cfg: + _start_inverter(inv_cfg) # ── REST API ────────────────────────────────────────────────── @app.get("/api/config") def api_get_config(): - cfg = State.config.copy() - cfg.pop("mqtt_pass", None) # Passwort nie zurückgeben + with State.lock: + cfg = {**State.mqtt_cfg, "inverters": State.inverters_cfg} + cfg.pop("mqtt_pass", None) + cfg["mqtt_connected"] = _publisher.connected if _publisher else False return jsonify(cfg) - @app.post("/api/config") def api_save_config(): data = request.get_json(force=True) or {} with State.lock: - # Passwort nur überschreiben wenn mitgesendet und nicht leer - if not data.get("mqtt_pass"): - data["mqtt_pass"] = State.config.get("mqtt_pass", "") - State.config.update(data) - save_config(State.config) - cfg_snapshot = State.config.copy() - - stop_poll_thread() - start_poll_thread(cfg_snapshot) + for k in ("mqtt_broker", "mqtt_port", "mqtt_user"): + if k in data: + State.mqtt_cfg[k] = data[k] + if data.get("mqtt_pass"): + State.mqtt_cfg["mqtt_pass"] = data["mqtt_pass"] + save_config() + threading.Thread(target=_restart_all, daemon=True).start() return jsonify({"ok": True}) +@app.get("/api/inverters-config") +def api_get_inverters(): + with State.lock: + return jsonify(State.inverters_cfg) + +@app.post("/api/inverters-config") +def api_save_inverters(): + data = request.get_json(force=True) or [] + with State.lock: + State.inverters_cfg = data + save_config() + threading.Thread(target=_restart_all, daemon=True).start() + return jsonify({"ok": True}) @app.get("/api/data") def api_get_data(): with State.lock: - inverter_id = State.config.get("inverter_model", "MIC_1500_TL_X") - inverter = INVERTERS.get(inverter_id, INVERTERS["MIC_1500_TL_X"]) - sensors_meta = [ - { - "id": s.id, - "name": s.name, - "unit": s.unit, - "icon": s.icon, - "device_class": s.device_class, + result = {} + for inv_cfg in State.inverters_cfg: + inv_id = inv_cfg["id"] + model_id = inv_cfg.get("inverter_model", "MIC_1500_TL_X") + inverter = INVERTERS.get(model_id, INVERTERS["MIC_1500_TL_X"]) + d = State.inv_data.get(inv_id, {}) + result[inv_id] = { + "name": inv_cfg.get("name", inverter.name), + "inverter_name": inverter.name, + "values": d.get("values", {}), + "sensors": [ + {"id": s.id, "name": s.name, "unit": s.unit, + "icon": s.icon, "device_class": s.device_class} + for s in inverter.sensors + ], + "last_update": d.get("last_update"), + "modbus_ok": d.get("modbus_ok", False), + "poll_count": d.get("poll_count", 0), } - for s in inverter.sensors - ] - return jsonify({ - "values": State.last_values, - "sensors": sensors_meta, - "last_update": State.last_update, - "modbus_ok": State.modbus_ok, - "mqtt_ok": State.mqtt_ok, - "poll_count": State.poll_count, - "error_count": State.error_count, - }) + mqtt_ok = _publisher.connected if _publisher else False + return jsonify({"inverters": result, "mqtt_ok": mqtt_ok}) - -@app.get("/api/inverters") -def api_get_inverters(): +@app.get("/api/inverter-models") +def api_get_models(): return jsonify({ k: {"id": v.id, "name": v.name, "sensor_count": len(v.sensors)} for k, v in INVERTERS.items() }) +@app.post("/api/new-id") +def api_new_id(): + return jsonify({"id": uuid.uuid4().hex[:8]}) @app.get("/") def index(): return send_from_directory(WEB_DIR, "index.html") - @app.get("/") def static_files(filename): return send_from_directory(WEB_DIR, filename) +# ── Startup ─────────────────────────────────────────────────── if __name__ == "__main__": cfg = load_config() with State.lock: - State.config = cfg - start_poll_thread(cfg) + State.mqtt_cfg = {k: cfg[k] for k in + ("mqtt_broker", "mqtt_port", "mqtt_user", "mqtt_pass")} + State.inverters_cfg = cfg.get("inverters", []) + # Migration: single-inverter config → list + if not State.inverters_cfg and cfg.get("modbus_ip"): + State.inverters_cfg = [{ + "id": uuid.uuid4().hex[:8], + "name": cfg.get("inverter_model", "MIC_1500_TL_X").replace("_", " "), + "modbus_ip": cfg["modbus_ip"], + "modbus_port": cfg.get("modbus_port", 502), + "modbus_address": cfg.get("modbus_address", 1), + "inverter_model": cfg.get("inverter_model", "MIC_1500_TL_X"), + "mqtt_topic_prefix": cfg.get("mqtt_topic_prefix", "growatt/shinelanx"), + "update_interval": cfg.get("update_interval", 30), + }] + save_config() + + _restart_all() port = int(os.environ.get("INGRESS_PORT", "8099")) log.info("Web UI startet auf Port %d", port) app.run(host="0.0.0.0", port=port, threaded=True) diff --git a/haos-addon/src/mqtt_publisher.py b/haos-addon/src/mqtt_publisher.py index 23839fc..9a2ee0d 100644 --- a/haos-addon/src/mqtt_publisher.py +++ b/haos-addon/src/mqtt_publisher.py @@ -1,7 +1,7 @@ import json import logging import time -from typing import Dict, Optional +from typing import List, Tuple import paho.mqtt.client as mqtt @@ -9,28 +9,26 @@ from inverters import Inverter log = logging.getLogger(__name__) -DEVICE_ID = "growatt_shinelanx" - class MqttPublisher: - def __init__(self, broker: str, port: int, user: str, password: str, topic_prefix: str): - self.topic_prefix = topic_prefix.rstrip("/") - self._client = mqtt.Client(client_id=DEVICE_ID, clean_session=True) + def __init__(self, broker: str, port: int, user: str, password: str): + self._broker = broker + self._port = port + self._connected = False + self._registered: List[Tuple[Inverter, str, str]] = [] # (inverter, device_id, topic_prefix) + + self._client = mqtt.Client(client_id="growatt_shinelanx_hub", clean_session=True) if user: self._client.username_pw_set(user, password) self._client.on_connect = self._on_connect self._client.on_disconnect = self._on_disconnect - self._broker = broker - self._port = port - self._connected = False - self._inverter: Optional[Inverter] = None def _on_connect(self, client, userdata, flags, rc): if rc == 0: self._connected = True log.info("MQTT verbunden: %s:%d", self._broker, self._port) - if self._inverter: - self._publish_discovery(self._inverter) + for inv, dev_id, prefix in self._registered: + self._publish_discovery(inv, dev_id, prefix) else: log.error("MQTT Verbindungsfehler rc=%d", rc) @@ -53,23 +51,28 @@ class MqttPublisher: def connected(self) -> bool: return self._connected - def setup_inverter(self, inverter: Inverter): - self._inverter = inverter + def register_inverter(self, inverter: Inverter, device_id: str, topic_prefix: str): + entry = (inverter, device_id, topic_prefix) + self._registered = [r for r in self._registered if r[1] != device_id] + self._registered.append(entry) if self._connected: - self._publish_discovery(inverter) + self._publish_discovery(inverter, device_id, topic_prefix) - def _publish_discovery(self, inverter: Inverter): + def unregister_inverter(self, device_id: str): + self._registered = [r for r in self._registered if r[1] != device_id] + + def _publish_discovery(self, inverter: Inverter, device_id: str, topic_prefix: str): device_payload = { - "identifiers": [DEVICE_ID], - "name": "Growatt ShineLAN-X", + "identifiers": [device_id], + "name": f"Growatt {inverter.name}", "manufacturer": inverter.manufacturer, "model": inverter.name, } for sensor in inverter.sensors: config = { "name": sensor.name, - "unique_id": f"{DEVICE_ID}_{sensor.id}", - "state_topic": f"{self.topic_prefix}/state", + "unique_id": f"{device_id}_{sensor.id}", + "state_topic": f"{topic_prefix}/state", "value_template": f"{{{{ value_json.{sensor.id} }}}}", "unit_of_measurement": sensor.unit, "state_class": sensor.state_class, @@ -78,17 +81,14 @@ class MqttPublisher: } if sensor.device_class: config["device_class"] = sensor.device_class - - topic = f"homeassistant/sensor/{DEVICE_ID}/{sensor.id}/config" + topic = f"homeassistant/sensor/{device_id}/{sensor.id}/config" self._client.publish(topic, json.dumps(config), retain=True, qos=1) - log.info("MQTT Discovery für %d Sensoren veröffentlicht", len(inverter.sensors)) + log.info("MQTT Discovery: %d Sensoren für %s", len(inverter.sensors), device_id) - def publish_data(self, values: Dict[str, float]): + def publish_data(self, values: dict, topic_prefix: str): if not self._connected: - log.warning("MQTT nicht verbunden, Daten verworfen") return - payload = json.dumps(values) - self._client.publish(f"{self.topic_prefix}/state", payload, retain=True, qos=0) + self._client.publish(f"{topic_prefix}/state", json.dumps(values), retain=True, qos=0) - def publish_status(self, status: str): - self._client.publish(f"{self.topic_prefix}/status", status, retain=True, qos=1) + def publish_status(self, status: str, topic_prefix: str): + self._client.publish(f"{topic_prefix}/status", status, retain=True, qos=1) diff --git a/haos-addon/src/web/index.html b/haos-addon/src/web/index.html index 87656e3..99db124 100644 --- a/haos-addon/src/web/index.html +++ b/haos-addon/src/web/index.html @@ -6,260 +6,139 @@ Growatt ShineLAN-X @@ -274,7 +153,6 @@
Lade...
-
Modbus
MQTT
@@ -282,106 +160,81 @@
Live-Daten
-
Konfiguration
+
Wechselrichter
+
Einstellungen
-
-
-
- - - - -

Warte auf erste Messung...

-
+
Warte auf erste Messung...
+
+ + +
+
+
+ + +
+
+

MQTT Broker

+
+
+
+
+
+
+
+
+
- - -
-
-
-

Wechselrichter-Modell

-
- -
- -
-
-

Modbus TCP

-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-

MQTT

-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
+ + +