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:
+177
-135
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user