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
+1 -13
View File
@@ -1,5 +1,5 @@
name: Growatt ShineLAN-X name: Growatt ShineLAN-X
version: "1.0.5" version: "1.1.0"
slug: growatt_shinelan_x slug: growatt_shinelan_x
description: Growatt Wechselrichter via ShineLAN-X (NuttX Modbus TCP) - MQTT Discovery + Web UI description: Growatt Wechselrichter via ShineLAN-X (NuttX Modbus TCP) - MQTT Discovery + Web UI
url: https://gitea.bitfire.work/retr0/Growatt-Wechselrichter-HAOS url: https://gitea.bitfire.work/retr0/Growatt-Wechselrichter-HAOS
@@ -14,24 +14,12 @@ ingress: true
ingress_port: 8099 ingress_port: 8099
panel_icon: mdi:solar-power panel_icon: mdi:solar-power
options: options:
modbus_ip: "10.10.20.190"
modbus_port: 502
modbus_address: 1
inverter_model: "MIC_1500_TL_X"
mqtt_broker: "core-mosquitto" mqtt_broker: "core-mosquitto"
mqtt_port: 1883 mqtt_port: 1883
mqtt_user: "" mqtt_user: ""
mqtt_pass: "" mqtt_pass: ""
mqtt_topic_prefix: "growatt/shinelanx"
update_interval: 30
schema: schema:
modbus_ip: str
modbus_port: int
modbus_address: int
inverter_model: str
mqtt_broker: str mqtt_broker: str
mqtt_port: int mqtt_port: int
mqtt_user: str mqtt_user: str
mqtt_pass: password mqtt_pass: password
mqtt_topic_prefix: str
update_interval: int
+177 -135
View File
@@ -3,11 +3,12 @@ import logging
import os import os
import threading import threading
import time 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 flask import Flask, jsonify, request, send_from_directory
from inverters import INVERTERS, Inverter from inverters import INVERTERS
from modbus_client import ModbusReader from modbus_client import ModbusReader
from mqtt_publisher import MqttPublisher 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) app = Flask(__name__, static_folder=WEB_DIR)
# ── State ────────────────────────────────────────────────────
class State: class State:
lock = threading.Lock() lock = threading.Lock()
config: Dict[str, Any] = {} mqtt_cfg: Dict[str, Any] = {}
last_values: Dict[str, float] = {} inverters_cfg: List[Dict[str, Any]] = []
last_update: Optional[float] = None # {inv_id: {values, last_update, modbus_ok, poll_count}}
modbus_ok: bool = False inv_data: Dict[str, Dict[str, Any]] = {}
mqtt_ok: bool = False
poll_count: int = 0
error_count: int = 0
_publisher: Optional[MqttPublisher] = None
_threads: Dict[str, threading.Thread] = {}
_stop_events: Dict[str, threading.Event] = {}
def load_config() -> Dict[str, Any]: # ── Config ───────────────────────────────────────────────────
# Defaults
cfg: Dict[str, Any] = { def _defaults() -> Dict[str, Any]:
"modbus_ip": "10.10.20.190", return {
"modbus_port": 502,
"modbus_address": 1,
"inverter_model": "MIC_1500_TL_X",
"mqtt_broker": "core-mosquitto", "mqtt_broker": "core-mosquitto",
"mqtt_port": 1883, "mqtt_port": 1883,
"mqtt_user": "", "mqtt_user": "",
"mqtt_pass": "", "mqtt_pass": "",
"mqtt_topic_prefix": "growatt/shinelanx", "inverters": [],
"update_interval": 30,
} }
# 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): if os.path.exists(HA_OPTIONS_PATH):
try: try:
with open(HA_OPTIONS_PATH) as f: 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: except Exception as e:
log.warning("HA options konnten nicht gelesen werden: %s", e) log.warning("HA options Fehler: %s", e)
# Web UI overrides (gespeichert via /api/config POST)
if os.path.exists(CONFIG_PATH): if os.path.exists(CONFIG_PATH):
try: try:
with open(CONFIG_PATH) as f: with open(CONFIG_PATH) as f:
cfg.update(json.load(f)) cfg.update(json.load(f))
except Exception as e: except Exception as e:
log.warning("Config-Datei konnte nicht gelesen werden: %s", e) log.warning("Config-Datei Fehler: %s", e)
return cfg return cfg
def save_config():
def save_config(cfg: Dict[str, Any]): data = {
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) "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: 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 def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
_publisher: Optional[MqttPublisher] = None inv_id = inv_cfg["id"]
_poll_thread: Optional[threading.Thread] = None model_id = inv_cfg.get("inverter_model", "MIC_1500_TL_X")
_stop_event = threading.Event() 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)))
reader = ModbusReader(
def _build_reader(cfg: Dict[str, Any]) -> ModbusReader: host=inv_cfg["modbus_ip"],
return ModbusReader( port=int(inv_cfg.get("modbus_port", 502)),
host=cfg["modbus_ip"], slave=int(inv_cfg.get("modbus_address", 1)),
port=cfg["modbus_port"],
slave=cfg["modbus_address"],
) )
with State.lock:
if _publisher:
_publisher.register_inverter(inverter, device_id, prefix)
def _build_publisher(cfg: Dict[str, Any]) -> MqttPublisher: log.info("[%s] Poll-Loop: %s @ %s:%s alle %ds",
return MqttPublisher( inv_id, inverter.name, inv_cfg["modbus_ip"],
broker=cfg["mqtt_broker"], inv_cfg.get("modbus_port", 502), interval)
port=cfg["mqtt_port"],
user=cfg["mqtt_user"], while not stop.is_set():
password=cfg["mqtt_pass"], t0 = time.time()
topic_prefix=cfg["mqtt_topic_prefix"], 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() _publisher.connect()
time.sleep(2) 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 ────────────────────────────────────────────────── # ── REST API ──────────────────────────────────────────────────
@app.get("/api/config") @app.get("/api/config")
def api_get_config(): def api_get_config():
cfg = State.config.copy() with State.lock:
cfg.pop("mqtt_pass", None) # Passwort nie zurückgeben 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) return jsonify(cfg)
@app.post("/api/config") @app.post("/api/config")
def api_save_config(): def api_save_config():
data = request.get_json(force=True) or {} data = request.get_json(force=True) or {}
with State.lock: with State.lock:
# Passwort nur überschreiben wenn mitgesendet und nicht leer for k in ("mqtt_broker", "mqtt_port", "mqtt_user"):
if not data.get("mqtt_pass"): if k in data:
data["mqtt_pass"] = State.config.get("mqtt_pass", "") State.mqtt_cfg[k] = data[k]
State.config.update(data) if data.get("mqtt_pass"):
save_config(State.config) State.mqtt_cfg["mqtt_pass"] = data["mqtt_pass"]
cfg_snapshot = State.config.copy() save_config()
threading.Thread(target=_restart_all, daemon=True).start()
stop_poll_thread()
start_poll_thread(cfg_snapshot)
return jsonify({"ok": True}) 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") @app.get("/api/data")
def api_get_data(): def api_get_data():
with State.lock: with State.lock:
inverter_id = State.config.get("inverter_model", "MIC_1500_TL_X") result = {}
inverter = INVERTERS.get(inverter_id, INVERTERS["MIC_1500_TL_X"]) for inv_cfg in State.inverters_cfg:
sensors_meta = [ inv_id = inv_cfg["id"]
{ model_id = inv_cfg.get("inverter_model", "MIC_1500_TL_X")
"id": s.id, inverter = INVERTERS.get(model_id, INVERTERS["MIC_1500_TL_X"])
"name": s.name, d = State.inv_data.get(inv_id, {})
"unit": s.unit, result[inv_id] = {
"icon": s.icon, "name": inv_cfg.get("name", inverter.name),
"device_class": s.device_class, "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 for s in inverter.sensors
] ],
return jsonify({ "last_update": d.get("last_update"),
"values": State.last_values, "modbus_ok": d.get("modbus_ok", False),
"sensors": sensors_meta, "poll_count": d.get("poll_count", 0),
"last_update": State.last_update, }
"modbus_ok": State.modbus_ok, mqtt_ok = _publisher.connected if _publisher else False
"mqtt_ok": State.mqtt_ok, return jsonify({"inverters": result, "mqtt_ok": mqtt_ok})
"poll_count": State.poll_count,
"error_count": State.error_count,
})
@app.get("/api/inverter-models")
@app.get("/api/inverters") def api_get_models():
def api_get_inverters():
return jsonify({ return jsonify({
k: {"id": v.id, "name": v.name, "sensor_count": len(v.sensors)} k: {"id": v.id, "name": v.name, "sensor_count": len(v.sensors)}
for k, v in INVERTERS.items() for k, v in INVERTERS.items()
}) })
@app.post("/api/new-id")
def api_new_id():
return jsonify({"id": uuid.uuid4().hex[:8]})
@app.get("/") @app.get("/")
def index(): def index():
return send_from_directory(WEB_DIR, "index.html") return send_from_directory(WEB_DIR, "index.html")
@app.get("/<path:filename>") @app.get("/<path:filename>")
def static_files(filename): def static_files(filename):
return send_from_directory(WEB_DIR, filename) return send_from_directory(WEB_DIR, filename)
# ── Startup ───────────────────────────────────────────────────
if __name__ == "__main__": if __name__ == "__main__":
cfg = load_config() cfg = load_config()
with State.lock: with State.lock:
State.config = cfg State.mqtt_cfg = {k: cfg[k] for k in
start_poll_thread(cfg) ("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")) port = int(os.environ.get("INGRESS_PORT", "8099"))
log.info("Web UI startet auf Port %d", port) log.info("Web UI startet auf Port %d", port)
app.run(host="0.0.0.0", port=port, threaded=True) app.run(host="0.0.0.0", port=port, threaded=True)
+29 -29
View File
@@ -1,7 +1,7 @@
import json import json
import logging import logging
import time import time
from typing import Dict, Optional from typing import List, Tuple
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
@@ -9,28 +9,26 @@ from inverters import Inverter
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
DEVICE_ID = "growatt_shinelanx"
class MqttPublisher: class MqttPublisher:
def __init__(self, broker: str, port: int, user: str, password: str, topic_prefix: str): def __init__(self, broker: str, port: int, user: str, password: str):
self.topic_prefix = topic_prefix.rstrip("/") self._broker = broker
self._client = mqtt.Client(client_id=DEVICE_ID, clean_session=True) 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: if user:
self._client.username_pw_set(user, password) self._client.username_pw_set(user, password)
self._client.on_connect = self._on_connect self._client.on_connect = self._on_connect
self._client.on_disconnect = self._on_disconnect 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): def _on_connect(self, client, userdata, flags, rc):
if rc == 0: if rc == 0:
self._connected = True self._connected = True
log.info("MQTT verbunden: %s:%d", self._broker, self._port) log.info("MQTT verbunden: %s:%d", self._broker, self._port)
if self._inverter: for inv, dev_id, prefix in self._registered:
self._publish_discovery(self._inverter) self._publish_discovery(inv, dev_id, prefix)
else: else:
log.error("MQTT Verbindungsfehler rc=%d", rc) log.error("MQTT Verbindungsfehler rc=%d", rc)
@@ -53,23 +51,28 @@ class MqttPublisher:
def connected(self) -> bool: def connected(self) -> bool:
return self._connected return self._connected
def setup_inverter(self, inverter: Inverter): def register_inverter(self, inverter: Inverter, device_id: str, topic_prefix: str):
self._inverter = inverter 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: 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 = { device_payload = {
"identifiers": [DEVICE_ID], "identifiers": [device_id],
"name": "Growatt ShineLAN-X", "name": f"Growatt {inverter.name}",
"manufacturer": inverter.manufacturer, "manufacturer": inverter.manufacturer,
"model": inverter.name, "model": inverter.name,
} }
for sensor in inverter.sensors: for sensor in inverter.sensors:
config = { config = {
"name": sensor.name, "name": sensor.name,
"unique_id": f"{DEVICE_ID}_{sensor.id}", "unique_id": f"{device_id}_{sensor.id}",
"state_topic": f"{self.topic_prefix}/state", "state_topic": f"{topic_prefix}/state",
"value_template": f"{{{{ value_json.{sensor.id} }}}}", "value_template": f"{{{{ value_json.{sensor.id} }}}}",
"unit_of_measurement": sensor.unit, "unit_of_measurement": sensor.unit,
"state_class": sensor.state_class, "state_class": sensor.state_class,
@@ -78,17 +81,14 @@ class MqttPublisher:
} }
if sensor.device_class: if sensor.device_class:
config["device_class"] = 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) 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: if not self._connected:
log.warning("MQTT nicht verbunden, Daten verworfen")
return return
payload = json.dumps(values) self._client.publish(f"{topic_prefix}/state", json.dumps(values), retain=True, qos=0)
self._client.publish(f"{self.topic_prefix}/state", payload, retain=True, qos=0)
def publish_status(self, status: str): def publish_status(self, status: str, topic_prefix: str):
self._client.publish(f"{self.topic_prefix}/status", status, retain=True, qos=1) self._client.publish(f"{topic_prefix}/status", status, retain=True, qos=1)
+326 -426
View File
@@ -6,260 +6,139 @@
<title>Growatt ShineLAN-X</title> <title>Growatt ShineLAN-X</title>
<style> <style>
:root { :root {
--bg: #0d1117; --bg: #0d1117; --surface: #161b22; --surface2: #21262d;
--surface: #161b22; --border: #30363d; --text: #e6edf3; --text-dim: #8b949e;
--surface2: #21262d; --accent: #f0c040; --green: #3fb950; --red: #f85149;
--border: #30363d; --blue: #58a6ff; --orange: #ffa657; --purple: #bc8cff;
--text: #e6edf3;
--text-dim: #8b949e;
--accent: #f0c040;
--green: #3fb950;
--red: #f85149;
--blue: #58a6ff;
--orange: #ffa657;
--purple: #bc8cff;
--radius: 10px; --radius: 10px;
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { body { background: var(--bg); color: var(--text);
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px; font-size: 14px; min-height: 100vh; }
min-height: 100vh;
}
/* ── Header ── */ header { display: flex; align-items: center; gap: 12px;
header { padding: 16px 20px; background: var(--surface);
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: var(--surface);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
position: sticky; position: sticky; top: 0; z-index: 100; }
top: 0;
z-index: 100;
}
header svg { flex-shrink: 0; }
header h1 { font-size: 16px; font-weight: 600; } header h1 { font-size: 16px; font-weight: 600; }
header .subtitle { font-size: 12px; color: var(--text-dim); } header .subtitle { font-size: 12px; color: var(--text-dim); }
.status-pill { .status-pill { margin-left: auto; display: flex; gap: 8px; align-items: center; }
margin-left: auto; .pill { display: flex; align-items: center; gap: 5px; padding: 4px 10px;
display: flex; border-radius: 20px; font-size: 12px; font-weight: 600;
gap: 8px; background: var(--surface2); border: 1px solid var(--border); }
align-items: center;
}
.pill {
display: flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
background: var(--surface2);
border: 1px solid var(--border);
}
.pill.ok { color: var(--green); border-color: var(--green); } .pill.ok { color: var(--green); border-color: var(--green); }
.pill.err { color: var(--red); border-color: var(--red); } .pill.err { color: var(--red); border-color: var(--red); }
.dot { .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor;
width: 7px; height: 7px; animation: pulse 2s infinite; }
border-radius: 50%; @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
background: currentColor;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ── Layout ── */
main { padding: 20px; max-width: 1100px; margin: 0 auto; } main { padding: 20px; max-width: 1100px; margin: 0 auto; }
.tabs { .tabs { display: flex; gap: 4px; margin-bottom: 20px;
display: flex; border-bottom: 1px solid var(--border); }
gap: 4px; .tab { padding: 10px 16px; cursor: pointer; color: var(--text-dim);
margin-bottom: 20px; font-weight: 500; border-bottom: 2px solid transparent;
border-bottom: 1px solid var(--border); margin-bottom: -1px; transition: color .15s, border-color .15s; }
}
.tab {
padding: 10px 16px;
cursor: pointer;
color: var(--text-dim);
font-weight: 500;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color .15s, border-color .15s;
}
.tab.active { color: var(--accent); border-color: var(--accent); } .tab.active { color: var(--accent); border-color: var(--accent); }
.tab:hover { color: var(--text); } .tab:hover { color: var(--text); }
.panel { display: none; } .panel { display: none; }
.panel.active { display: block; } .panel.active { display: block; }
/* ── Sensor Grid ── */ /* Sensor Grid */
.sensor-grid { .inv-section { margin-bottom: 28px; }
display: grid; .inv-header { display: flex; align-items: center; gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); margin-bottom: 12px; }
gap: 12px; .inv-title { font-size: 15px; font-weight: 600; }
} .inv-badge { font-size: 11px; padding: 2px 8px; border-radius: 10px;
.sensor-card { background: var(--surface2); border: 1px solid var(--border);
background: var(--surface); color: var(--text-dim); }
border: 1px solid var(--border); .inv-badge.ok { color: var(--green); border-color: var(--green); }
border-radius: var(--radius); .inv-badge.err { color: var(--red); border-color: var(--red); }
padding: 14px 16px; .sensor-grid { display: grid;
transition: border-color .15s; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 10px; }
} .sensor-card { background: var(--surface); border: 1px solid var(--border);
.sensor-card:hover { border-color: var(--text-dim); } border-radius: var(--radius); padding: 14px 16px; }
.sensor-icon { .sensor-icon { font-size: 18px; margin-bottom: 6px; }
font-size: 20px; .sensor-name { font-size: 11px; color: var(--text-dim);
margin-bottom: 8px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; }
} .sensor-value { font-size: 20px; font-weight: 700;
.sensor-name { font-variant-numeric: tabular-nums; }
font-size: 11px; .sensor-unit { font-size: 11px; color: var(--text-dim); margin-left: 2px; }
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: .05em;
margin-bottom: 6px;
}
.sensor-value {
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--text);
}
.sensor-unit {
font-size: 12px;
color: var(--text-dim);
margin-left: 3px;
}
/* Color coding by device class */
.dc-power .sensor-value { color: var(--accent); } .dc-power .sensor-value { color: var(--accent); }
.dc-voltage .sensor-value { color: var(--blue); } .dc-voltage .sensor-value { color: var(--blue); }
.dc-current .sensor-value { color: var(--orange); } .dc-current .sensor-value { color: var(--orange); }
.dc-energy .sensor-value { color: var(--green); } .dc-energy .sensor-value { color: var(--green); }
.dc-temperature .sensor-value { color: var(--red); } .dc-temperature .sensor-value { color: var(--red); }
.dc-battery .sensor-value { color: var(--purple); } .dc-battery .sensor-value { color: var(--purple); }
.dc-frequency .sensor-value { color: var(--text); } .no-data { text-align: center; padding: 40px 20px; color: var(--text-dim); font-size: 13px; }
/* ── Config Form ── */ /* Inverter management */
.config-grid { .inv-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
display: grid; .inv-card { background: var(--surface); border: 1px solid var(--border);
grid-template-columns: 1fr 1fr; border-radius: var(--radius); padding: 18px; }
gap: 20px; .inv-card-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 12px; }
} .inv-card-icon { font-size: 28px; }
@media (max-width: 640px) { .config-grid { grid-template-columns: 1fr; } } .inv-card-info { flex: 1; }
.config-section { .inv-card-name { font-weight: 600; font-size: 15px; margin-bottom: 2px; }
background: var(--surface); .inv-card-model { font-size: 12px; color: var(--text-dim); }
border: 1px solid var(--border); .inv-card-actions { display: flex; gap: 6px; margin-top: 14px; }
border-radius: var(--radius); .btn { padding: 7px 14px; border-radius: 6px; border: 1px solid var(--border);
padding: 20px; background: var(--surface2); color: var(--text); cursor: pointer;
} font-size: 12px; font-weight: 500; transition: border-color .15s; }
.config-section h3 { .btn:hover { border-color: var(--text-dim); }
font-size: 13px; .btn-danger { color: var(--red); border-color: var(--red); }
font-weight: 600; .btn-danger:hover { background: rgba(248,81,73,.1); }
color: var(--text-dim); .btn-primary { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 700; }
text-transform: uppercase; .btn-primary:hover { opacity: .85; }
letter-spacing: .06em; .inv-card-meta { font-size: 12px; color: var(--text-dim); line-height: 1.7; }
margin-bottom: 16px; .add-btn { display: flex; align-items: center; justify-content: center; gap: 8px;
padding-bottom: 10px; padding: 20px; background: var(--surface); border: 2px dashed var(--border);
border-bottom: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; color: var(--text-dim);
} font-size: 14px; transition: border-color .15s, color .15s; }
.add-btn:hover { border-color: var(--accent); color: var(--accent); }
/* Settings */
.settings-section { background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 20px; max-width: 500px; }
.settings-section h3 { font-size: 13px; font-weight: 600; color: var(--text-dim);
text-transform: uppercase; letter-spacing: .06em;
margin-bottom: 16px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
.field { margin-bottom: 14px; } .field { margin-bottom: 14px; }
.field label { .field label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 5px; }
display: block; .field input, .field select { width: 100%; padding: 8px 10px;
font-size: 12px; background: var(--surface2); border: 1px solid var(--border);
color: var(--text-dim); border-radius: 6px; color: var(--text); font-size: 13px; outline: none;
margin-bottom: 5px; transition: border-color .15s; }
} .field input:focus, .field select:focus { border-color: var(--accent); }
.field input, .field select {
width: 100%;
padding: 8px 10px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
outline: none;
transition: border-color .15s;
}
.field input:focus, .field select:focus {
border-color: var(--accent);
}
.field select option { background: var(--surface2); } .field select option { background: var(--surface2); }
.inverter-select-grid {
display: grid; /* Modal */
grid-template-columns: 1fr 1fr; .modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.7);
gap: 8px; z-index: 200; display: flex; align-items: center; justify-content: center;
grid-column: 1 / -1; opacity: 0; pointer-events: none; transition: opacity .2s; }
} .modal-backdrop.open { opacity: 1; pointer-events: all; }
.inverter-card { .modal { background: var(--surface); border: 1px solid var(--border);
padding: 12px; border-radius: var(--radius); padding: 24px; width: 100%; max-width: 440px;
background: var(--surface2); max-height: 90vh; overflow-y: auto; }
border: 2px solid var(--border); .modal h2 { font-size: 16px; margin-bottom: 20px; }
border-radius: 8px; .modal-actions { display: flex; gap: 8px; margin-top: 20px; justify-content: flex-end; }
cursor: pointer;
transition: border-color .15s, background .15s; /* Info row */
text-align: center; .info-row { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
} .info-chip { padding: 5px 12px; background: var(--surface);
.inverter-card:hover { border-color: var(--text-dim); } border: 1px solid var(--border); border-radius: 20px;
.inverter-card.selected { border-color: var(--accent); background: rgba(240,192,64,0.08); } font-size: 12px; color: var(--text-dim); }
.inverter-card .inv-name { font-weight: 600; font-size: 13px; margin-bottom: 3px; } .info-chip span { color: var(--text); font-weight: 600; }
.inverter-card .inv-sensors { font-size: 11px; color: var(--text-dim); }
.save-btn { /* Toast */
margin-top: 20px; .toast { position: fixed; bottom: 24px; right: 24px; padding: 12px 20px;
padding: 10px 24px; background: var(--surface2); border: 1px solid var(--border);
background: var(--accent); border-radius: 8px; font-size: 13px; z-index: 999;
color: #000; transform: translateY(80px); opacity: 0;
font-weight: 700; transition: transform .3s, opacity .3s; pointer-events: none; }
font-size: 14px;
border: none;
border-radius: 6px;
cursor: pointer;
transition: opacity .15s;
}
.save-btn:hover { opacity: 0.85; }
.save-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.toast {
position: fixed;
bottom: 24px;
right: 24px;
padding: 12px 20px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 13px;
z-index: 999;
transform: translateY(80px);
opacity: 0;
transition: transform .3s, opacity .3s;
pointer-events: none;
}
.toast.show { transform: translateY(0); opacity: 1; } .toast.show { transform: translateY(0); opacity: 1; }
.toast.ok { border-color: var(--green); color: var(--green); } .toast.ok { border-color: var(--green); color: var(--green); }
.toast.err { border-color: var(--red); color: var(--red); } .toast.err { border-color: var(--red); color: var(--red); }
/* ── Info row ── */
.info-row {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.info-chip {
padding: 6px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 20px;
font-size: 12px;
color: var(--text-dim);
}
.info-chip span { color: var(--text); font-weight: 600; }
.no-data {
text-align: center;
padding: 60px 20px;
color: var(--text-dim);
}
.no-data p { margin-top: 8px; font-size: 13px; }
</style> </style>
</head> </head>
<body> <body>
@@ -274,7 +153,6 @@
<div class="subtitle" id="subtitle">Lade...</div> <div class="subtitle" id="subtitle">Lade...</div>
</div> </div>
<div class="status-pill"> <div class="status-pill">
<div class="pill" id="pill-modbus"><div class="dot"></div>Modbus</div>
<div class="pill" id="pill-mqtt"><div class="dot"></div>MQTT</div> <div class="pill" id="pill-mqtt"><div class="dot"></div>MQTT</div>
</div> </div>
</header> </header>
@@ -282,106 +160,81 @@
<main> <main>
<div class="tabs"> <div class="tabs">
<div class="tab active" onclick="switchTab('live')">Live-Daten</div> <div class="tab active" onclick="switchTab('live')">Live-Daten</div>
<div class="tab" onclick="switchTab('config')">Konfiguration</div> <div class="tab" onclick="switchTab('inverters')">Wechselrichter</div>
<div class="tab" onclick="switchTab('settings')">Einstellungen</div>
</div> </div>
<!-- Live Panel --> <!-- Live Panel -->
<div class="panel active" id="panel-live"> <div class="panel active" id="panel-live">
<div class="info-row" id="info-row"></div> <div id="live-content"><div class="no-data">Warte auf erste Messung...</div></div>
<div class="sensor-grid" id="sensor-grid">
<div class="no-data">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#8b949e" stroke-width="1.5">
<path d="M12 22V12M12 12L8 16M12 12L16 16"/>
<path d="M20 16.5A4.5 4.5 0 0 0 12 8a6 6 0 1 0-8 5.66"/>
</svg>
<p>Warte auf erste Messung...</p>
</div>
</div>
</div> </div>
<!-- Config Panel --> <!-- Inverters Panel -->
<div class="panel" id="panel-config"> <div class="panel" id="panel-inverters">
<form id="config-form" onsubmit="saveConfig(event)"> <div class="inv-list" id="inv-list"></div>
<div class="config-section" style="margin-bottom:20px">
<h3>Wechselrichter-Modell</h3>
<div class="inverter-select-grid" id="inverter-grid"></div>
<input type="hidden" id="cfg-inverter" name="inverter_model">
</div> </div>
<div class="config-grid"> <!-- Settings Panel -->
<div class="config-section"> <div class="panel" id="panel-settings">
<h3>Modbus TCP</h3> <div class="settings-section">
<div class="field"> <h3>MQTT Broker</h3>
<label>IP-Adresse des ShineLAN-X</label> <div class="field"><label>Broker</label>
<input type="text" id="cfg-modbus-ip" placeholder="10.10.20.190" pattern="\d+\.\d+\.\d+\.\d+"> <input type="text" id="cfg-mqtt-broker" placeholder="core-mosquitto"></div>
<div class="field"><label>Port</label>
<input type="number" id="cfg-mqtt-port" placeholder="1883"></div>
<div class="field"><label>Benutzername</label>
<input type="text" id="cfg-mqtt-user" autocomplete="off"></div>
<div class="field"><label>Passwort</label>
<input type="password" id="cfg-mqtt-pass" placeholder="leer = unverändert"></div>
<button class="btn btn-primary" onclick="saveMqtt()">Speichern & Neu starten</button>
</div> </div>
<div class="field">
<label>Port</label>
<input type="number" id="cfg-modbus-port" placeholder="502" min="1" max="65535">
</div>
<div class="field">
<label>Modbus Slave-Adresse</label>
<input type="number" id="cfg-modbus-addr" placeholder="1" min="1" max="247">
</div>
<div class="field">
<label>Abfrageintervall (Sekunden)</label>
<input type="number" id="cfg-interval" placeholder="30" min="5" max="3600">
</div>
</div>
<div class="config-section">
<h3>MQTT</h3>
<div class="field">
<label>Broker</label>
<input type="text" id="cfg-mqtt-broker" placeholder="core-mosquitto oder IP">
</div>
<div class="field">
<label>Port</label>
<input type="number" id="cfg-mqtt-port" placeholder="1883" min="1" max="65535">
</div>
<div class="field">
<label>Benutzername</label>
<input type="text" id="cfg-mqtt-user" placeholder="optional" autocomplete="off">
</div>
<div class="field">
<label>Passwort</label>
<input type="password" id="cfg-mqtt-pass" placeholder="leer lassen = unverändert" autocomplete="new-password">
</div>
<div class="field">
<label>Topic-Präfix</label>
<input type="text" id="cfg-mqtt-prefix" placeholder="growatt/shinelanx">
</div>
</div>
</div>
<button type="submit" class="save-btn" id="save-btn">Speichern & Neu starten</button>
</form>
</div> </div>
</main> </main>
<!-- Inverter Edit Modal -->
<div class="modal-backdrop" id="modal-backdrop" onclick="closeModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<h2 id="modal-title">Wechselrichter hinzufügen</h2>
<input type="hidden" id="modal-id">
<div class="field"><label>Name</label>
<input type="text" id="modal-name" placeholder="z.B. Dach Süd"></div>
<div class="field"><label>Modell</label>
<select id="modal-model"></select></div>
<div class="field"><label>IP-Adresse</label>
<input type="text" id="modal-ip" placeholder="10.10.20.190"></div>
<div class="field"><label>Modbus Port</label>
<input type="number" id="modal-port" value="502"></div>
<div class="field"><label>Modbus Slave-Adresse</label>
<input type="number" id="modal-addr" value="1" min="1" max="247"></div>
<div class="field"><label>MQTT Topic-Präfix</label>
<input type="text" id="modal-prefix" placeholder="growatt/wechselrichter1"></div>
<div class="field"><label>Abfrageintervall (Sekunden)</label>
<input type="number" id="modal-interval" value="30" min="5"></div>
<div class="modal-actions">
<button class="btn" onclick="closeModal()">Abbrechen</button>
<button class="btn btn-primary" onclick="saveInverter()">Speichern</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script> <script>
const ICON_MAP = { const ICON_MAP = {
"mdi:solar-panel": "☀️", "mdi:solar-panel":"☀️","mdi:flash":"⚡","mdi:sine-wave":"〜",
"mdi:flash": "", "mdi:solar-power":"🔆","mdi:thermometer":"🌡️","mdi:battery":"🔋",
"mdi:sine-wave": "", "mdi:battery-minus":"🪫","mdi:battery-plus":"",
"mdi:solar-power": "🔋", "mdi:transmission-tower-export":"📤","mdi:transmission-tower-import":"📥",
"mdi:thermometer": "🌡️",
"mdi:battery": "🔋",
"mdi:battery-minus": "🪫",
"mdi:battery-plus": "⚡",
"mdi:transmission-tower-export": "📤",
"mdi:transmission-tower-import": "📥",
}; };
let currentConfig = {};
let inverterList = {};
let refreshTimer = null;
// Basis-URL relativ zur aktuellen Seite (funktioniert hinter HA Ingress-Proxy)
const BASE = new URL("./", window.location.href).pathname; const BASE = new URL("./", window.location.href).pathname;
function apiUrl(path) { return BASE + path; } const api = p => BASE + p;
let globalConfig = {};
let invertersList = [];
let modelsList = {};
let liveData = {};
let refreshTimer = null;
async function fetchJSON(url, opts) { async function fetchJSON(url, opts) {
const r = await fetch(url, opts); const r = await fetch(url, opts);
@@ -397,176 +250,223 @@ function showToast(msg, type) {
el._t = setTimeout(() => el.className = "toast", 3000); el._t = setTimeout(() => el.className = "toast", 3000);
} }
// ── Tab switching ──
function switchTab(name) { function switchTab(name) {
document.querySelectorAll(".tab").forEach((t, i) => { ["live","inverters","settings"].forEach((t, i) => {
t.classList.toggle("active", ["live", "config"][i] === name); document.querySelectorAll(".tab")[i].classList.toggle("active", t === name);
document.querySelectorAll(".panel")[i].classList.toggle("active", t === name);
}); });
document.querySelectorAll(".panel").forEach((p, i) => { if (name === "live") startRefresh(); else stopRefresh();
p.classList.toggle("active", ["panel-live", "panel-config"][i] === `panel-${name}`);
});
if (name === "live") startRefresh();
else stopRefresh();
} }
// ── Live data ── // ── Live Data ─────────────────────────────────────────────────
async function refreshData() { async function refreshData() {
try { try {
const d = await fetchJSON(apiUrl("api/data")); const d = await fetchJSON(api("api/data"));
updateStatus(d.modbus_ok, d.mqtt_ok); liveData = d;
updateSubtitle(d); document.getElementById("pill-mqtt").className = `pill ${d.mqtt_ok ? "ok" : "err"}`;
updateInfoRow(d); const keys = Object.keys(d.inverters || {});
updateGrid(d); document.getElementById("subtitle").textContent =
} catch (e) { keys.length ? `${keys.length} Wechselrichter` : "Keine Wechselrichter";
updateStatus(false, false); renderLive(d.inverters || {});
} catch(e) {
document.getElementById("pill-mqtt").className = "pill err";
} }
} }
function updateStatus(modbus, mqtt) { function renderLive(inverters) {
const pm = document.getElementById("pill-modbus"); const el = document.getElementById("live-content");
const pq = document.getElementById("pill-mqtt"); if (!Object.keys(inverters).length) {
pm.className = `pill ${modbus ? "ok" : "err"}`; el.innerHTML = '<div class="no-data">Keine Wechselrichter konfiguriert.<br>Bitte im Tab „Wechselrichter" hinzufügen.</div>';
pq.className = `pill ${mqtt ? "ok" : "err"}`; return;
} }
el.innerHTML = Object.values(inverters).map(inv => {
function updateSubtitle(d) { const ago = inv.last_update ? Math.round(Date.now()/1000 - inv.last_update) + "s" : "—";
const inv = currentConfig.inverter_model || ""; const cards = (inv.sensors || []).map(s => {
const name = (inverterList[inv] || {}).name || inv; const val = inv.values[s.id];
document.getElementById("subtitle").textContent = name; const display = val !== undefined ? fmtVal(val) : "—";
}
function updateInfoRow(d) {
if (!d.last_update) return;
const ago = Math.round(Date.now() / 1000 - d.last_update);
const ip = currentConfig.modbus_ip || "";
document.getElementById("info-row").innerHTML = `
<div class="info-chip">Letzte Messung <span>${ago}s</span> vor</div>
<div class="info-chip">Messungen <span>${d.poll_count}</span></div>
<div class="info-chip">ShineLAN-X <span>${ip}:${currentConfig.modbus_port || 502}</span></div>
`;
}
function updateGrid(d) {
const grid = document.getElementById("sensor-grid");
if (!d.last_update || !d.sensors || d.sensors.length === 0) return;
grid.innerHTML = d.sensors.map(s => {
const val = d.values[s.id];
const display = val !== undefined ? formatVal(val) : "—";
const dcClass = s.device_class ? `dc-${s.device_class}` : ""; const dcClass = s.device_class ? `dc-${s.device_class}` : "";
const icon = ICON_MAP[s.icon] || "📊";
return `<div class="sensor-card ${dcClass}"> return `<div class="sensor-card ${dcClass}">
<div class="sensor-icon">${icon}</div> <div class="sensor-icon">${ICON_MAP[s.icon]||"📊"}</div>
<div class="sensor-name">${s.name}</div> <div class="sensor-name">${s.name}</div>
<div class="sensor-value">${display}<span class="sensor-unit">${s.unit}</span></div> <div class="sensor-value">${display}<span class="sensor-unit">${s.unit}</span></div>
</div>`; </div>`;
}).join(""); }).join("");
return `<div class="inv-section">
<div class="inv-header">
<div class="inv-title">${inv.name}</div>
<div class="inv-badge ${inv.modbus_ok ? "ok" : "err"}">${inv.modbus_ok ? "online" : "offline"}</div>
<div class="info-chip" style="margin-left:auto;font-size:11px">⏱ ${ago} vor · ${inv.poll_count} Messungen</div>
</div>
<div class="sensor-grid">${cards || '<div class="no-data">Warte...</div>'}</div>
</div>`;
}).join("");
} }
function formatVal(v) { function fmtVal(v) {
if (v >= 1000) return (v / 1000).toFixed(2).replace(".", ",") + "k"; if (v >= 1000) return (v/1000).toFixed(2).replace(".",",") + "k";
if (v % 1 === 0) return v.toString(); if (v % 1 === 0) return v.toString();
return v.toFixed(v < 10 ? 2 : 1).replace(".", ","); return v.toFixed(v < 10 ? 2 : 1).replace(".",",");
} }
function startRefresh() { function startRefresh() { stopRefresh(); refreshData(); refreshTimer = setInterval(refreshData, 5000); }
stopRefresh(); function stopRefresh() { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } }
refreshData();
refreshTimer = setInterval(refreshData, 5000); // ── Inverter Management ───────────────────────────────────────
async function loadInverters() {
invertersList = await fetchJSON(api("api/inverters-config"));
renderInverterList();
} }
function stopRefresh() { async function loadModels() {
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } modelsList = await fetchJSON(api("api/inverter-models"));
const sel = document.getElementById("modal-model");
sel.innerHTML = Object.values(modelsList).map(m =>
`<option value="${m.id}">${m.name} (${m.sensor_count} Sensoren)</option>`
).join("");
} }
// ── Config ── function renderInverterList() {
async function loadConfig() { const el = document.getElementById("inv-list");
const cards = invertersList.map(inv => {
const model = modelsList[inv.inverter_model] || {};
return `<div class="inv-card">
<div class="inv-card-header">
<div class="inv-card-icon">☀️</div>
<div class="inv-card-info">
<div class="inv-card-name">${inv.name || "Wechselrichter"}</div>
<div class="inv-card-model">${model.name || inv.inverter_model}</div>
</div>
</div>
<div class="inv-card-meta">
📡 ${inv.modbus_ip}:${inv.modbus_port || 502} · Slave ${inv.modbus_address || 1}<br>
📨 ${inv.mqtt_topic_prefix}<br>
⏱ alle ${inv.update_interval || 30}s
</div>
<div class="inv-card-actions">
<button class="btn" onclick="editInverter('${inv.id}')">Bearbeiten</button>
<button class="btn btn-danger" onclick="deleteInverter('${inv.id}')">Löschen</button>
</div>
</div>`;
}).join("");
el.innerHTML = cards + `<div class="add-btn" onclick="openModal()"> Wechselrichter hinzufügen</div>`;
}
async function openModal(invId) {
await loadModels();
const modal = document.getElementById("modal-backdrop");
if (invId) {
const inv = invertersList.find(i => i.id === invId) || {};
document.getElementById("modal-title").textContent = "Wechselrichter bearbeiten";
document.getElementById("modal-id").value = inv.id || "";
document.getElementById("modal-name").value = inv.name || "";
document.getElementById("modal-model").value = inv.inverter_model || "MIC_1500_TL_X";
document.getElementById("modal-ip").value = inv.modbus_ip || "";
document.getElementById("modal-port").value = inv.modbus_port || 502;
document.getElementById("modal-addr").value = inv.modbus_address || 1;
document.getElementById("modal-prefix").value = inv.mqtt_topic_prefix || "";
document.getElementById("modal-interval").value = inv.update_interval || 30;
} else {
document.getElementById("modal-title").textContent = "Wechselrichter hinzufügen";
document.getElementById("modal-id").value = "";
document.getElementById("modal-name").value = "";
document.getElementById("modal-ip").value = "";
document.getElementById("modal-port").value = "502";
document.getElementById("modal-addr").value = "1";
document.getElementById("modal-prefix").value = "growatt/wechselrichter" + (invertersList.length + 1);
document.getElementById("modal-interval").value = "30";
}
modal.classList.add("open");
}
function editInverter(id) { openModal(id); }
function closeModal(e) {
if (!e || e.target === document.getElementById("modal-backdrop"))
document.getElementById("modal-backdrop").classList.remove("open");
}
async function saveInverter() {
let id = document.getElementById("modal-id").value;
if (!id) {
const r = await fetchJSON(api("api/new-id"), {method: "POST"});
id = r.id;
}
const inv = {
id,
name: document.getElementById("modal-name").value.trim() || "Wechselrichter",
inverter_model: document.getElementById("modal-model").value,
modbus_ip: document.getElementById("modal-ip").value.trim(),
modbus_port: parseInt(document.getElementById("modal-port").value),
modbus_address: parseInt(document.getElementById("modal-addr").value),
mqtt_topic_prefix: document.getElementById("modal-prefix").value.trim(),
update_interval: parseInt(document.getElementById("modal-interval").value),
};
const idx = invertersList.findIndex(i => i.id === id);
if (idx >= 0) invertersList[idx] = inv; else invertersList.push(inv);
try { try {
currentConfig = await fetchJSON(apiUrl("api/config")); await fetchJSON(api("api/inverters-config"), {
fillForm(currentConfig); method: "POST", headers: {"Content-Type": "application/json"},
} catch (e) { body: JSON.stringify(invertersList),
showToast("Konfiguration konnte nicht geladen werden", "err"); });
closeModal();
renderInverterList();
showToast("Gespeichert!", "ok");
} catch(e) {
showToast("Fehler beim Speichern", "err");
} }
} }
async function loadInverters() { async function deleteInverter(id) {
if (!confirm("Wechselrichter wirklich löschen?")) return;
invertersList = invertersList.filter(i => i.id !== id);
try { try {
inverterList = await fetchJSON(apiUrl("api/inverters")); await fetchJSON(api("api/inverters-config"), {
buildInverterGrid(inverterList, currentConfig.inverter_model); method: "POST", headers: {"Content-Type": "application/json"},
} catch (e) {} body: JSON.stringify(invertersList),
});
renderInverterList();
showToast("Gelöscht", "ok");
} catch(e) {
showToast("Fehler", "err");
}
} }
function fillForm(cfg) { // ── MQTT Settings ─────────────────────────────────────────────
document.getElementById("cfg-modbus-ip").value = cfg.modbus_ip || "";
document.getElementById("cfg-modbus-port").value = cfg.modbus_port || 502; async function loadSettings() {
document.getElementById("cfg-modbus-addr").value = cfg.modbus_address || 1; globalConfig = await fetchJSON(api("api/config"));
document.getElementById("cfg-interval").value = cfg.update_interval || 30; document.getElementById("cfg-mqtt-broker").value = globalConfig.mqtt_broker || "";
document.getElementById("cfg-mqtt-broker").value = cfg.mqtt_broker || ""; document.getElementById("cfg-mqtt-port").value = globalConfig.mqtt_port || 1883;
document.getElementById("cfg-mqtt-port").value = cfg.mqtt_port || 1883; document.getElementById("cfg-mqtt-user").value = globalConfig.mqtt_user || "";
document.getElementById("cfg-mqtt-user").value = cfg.mqtt_user || "";
document.getElementById("cfg-mqtt-prefix").value = cfg.mqtt_topic_prefix || "growatt/shinelanx";
document.getElementById("cfg-inverter").value = cfg.inverter_model || "MIC_1500_TL_X";
} }
function buildInverterGrid(list, selected) { async function saveMqtt() {
const grid = document.getElementById("inverter-grid");
grid.innerHTML = Object.values(list).map(inv => `
<div class="inverter-card ${inv.id === selected ? "selected" : ""}"
onclick="selectInverter('${inv.id}', this)">
<div class="inv-name">${inv.name}</div>
<div class="inv-sensors">${inv.sensor_count} Sensoren</div>
</div>
`).join("");
}
function selectInverter(id, el) {
document.querySelectorAll(".inverter-card").forEach(c => c.classList.remove("selected"));
el.classList.add("selected");
document.getElementById("cfg-inverter").value = id;
}
async function saveConfig(e) {
e.preventDefault();
const btn = document.getElementById("save-btn");
btn.disabled = true;
const body = { const body = {
modbus_ip: document.getElementById("cfg-modbus-ip").value.trim(),
modbus_port: parseInt(document.getElementById("cfg-modbus-port").value),
modbus_address: parseInt(document.getElementById("cfg-modbus-addr").value),
update_interval: parseInt(document.getElementById("cfg-interval").value),
mqtt_broker: document.getElementById("cfg-mqtt-broker").value.trim(), mqtt_broker: document.getElementById("cfg-mqtt-broker").value.trim(),
mqtt_port: parseInt(document.getElementById("cfg-mqtt-port").value), mqtt_port: parseInt(document.getElementById("cfg-mqtt-port").value),
mqtt_user: document.getElementById("cfg-mqtt-user").value, mqtt_user: document.getElementById("cfg-mqtt-user").value,
mqtt_pass: document.getElementById("cfg-mqtt-pass").value, mqtt_pass: document.getElementById("cfg-mqtt-pass").value,
mqtt_topic_prefix: document.getElementById("cfg-mqtt-prefix").value.trim(),
inverter_model: document.getElementById("cfg-inverter").value,
}; };
try { try {
await fetchJSON(apiUrl("api/config"), { await fetchJSON(api("api/config"), {
method: "POST", method: "POST", headers: {"Content-Type": "application/json"},
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
currentConfig = { ...currentConfig, ...body };
showToast("Gespeichert! Neustart...", "ok"); showToast("Gespeichert! Neustart...", "ok");
setTimeout(loadConfig, 2000); } catch(e) {
} catch (e) {
showToast("Fehler beim Speichern", "err"); showToast("Fehler beim Speichern", "err");
} finally {
btn.disabled = false;
} }
} }
// ── Init ── // ── Init ──────────────────────────────────────────────────────
(async () => { (async () => {
await loadConfig(); await Promise.all([loadSettings(), loadInverters(), loadModels()]);
await loadInverters();
startRefresh(); startRefresh();
})(); })();
// Cleanup on unload
window.addEventListener("beforeunload", stopRefresh); window.addEventListener("beforeunload", stopRefresh);
</script> </script>
</body> </body>