HAOS Add-on v1.1.0: Multi-Wechselrichter Support

- Unbegrenzt viele Wechselrichter über Web UI verwaltbar (Add/Edit/Delete)
- Pro Wechselrichter: eigener Poll-Thread, MQTT-Topic-Präfix, HA Device
- Shared MQTT-Publisher: eine Verbindung für alle Wechselrichter
- Migration: bestehende Single-Inverter-Config wird automatisch übernommen
- Live-Daten: pro Wechselrichter mit Online/Offline-Badge
- config.yaml: nur noch MQTT global, Wechselrichter über /data/config.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
retr0
2026-04-26 11:17:06 +02:00
parent 4fd54025ad
commit 35a3c01e36
4 changed files with 549 additions and 619 deletions
+177 -135
View File
@@ -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("/<path:filename>")
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)