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