HAOS Add-on: MVP + NuttX Binary + .gitignore
- haos-addon/: vollständiges HA Add-on (config.yaml, Dockerfile, build.yaml) - Python Backend: pymodbus Modbus TCP → paho-mqtt MQTT Discovery - Unterstützte Modelle: MIC 1500/2000 TL-X, SPH 5000 TL3, MOD 6000 TL3 - Web UI: Wechselrichter-Auswahl, Modbus/MQTT-Konfig, Live-Sensor-Grid (dark theme) - MQTT HA Discovery für alle Sensoren mit device_class, state_class, icon - ShineLAN-X/releases/nuttx-mbusd-shinelanx.bin: NuttX Firmware (ohne DFU, 0x08000000) - .gitignore: Logs, MQTT-JSON, shinelanx-modbus/ ausgeschlossen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from flask import Flask, jsonify, request, send_from_directory
|
||||
|
||||
from inverters import INVERTERS, Inverter
|
||||
from modbus_client import ModbusReader
|
||||
from mqtt_publisher import MqttPublisher
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_PATH = "/data/config.json"
|
||||
WEB_DIR = os.path.join(os.path.dirname(__file__), "web")
|
||||
|
||||
app = Flask(__name__, static_folder=WEB_DIR)
|
||||
|
||||
|
||||
class State:
|
||||
lock = threading.Lock()
|
||||
config: Dict[str, Any] = {}
|
||||
last_values: Dict[str, float] = {}
|
||||
last_update: Optional[float] = None
|
||||
modbus_ok: bool = False
|
||||
mqtt_ok: bool = False
|
||||
poll_count: int = 0
|
||||
error_count: int = 0
|
||||
|
||||
|
||||
def load_config() -> Dict[str, Any]:
|
||||
cfg: Dict[str, Any] = {
|
||||
"modbus_ip": os.environ.get("MODBUS_IP", "10.10.20.190"),
|
||||
"modbus_port": int(os.environ.get("MODBUS_PORT", "502")),
|
||||
"modbus_address": int(os.environ.get("MODBUS_ADDRESS", "1")),
|
||||
"inverter_model": os.environ.get("INVERTER_MODEL", "MIC_1500_TL_X"),
|
||||
"mqtt_broker": os.environ.get("MQTT_BROKER", "core-mosquitto"),
|
||||
"mqtt_port": int(os.environ.get("MQTT_PORT", "1883")),
|
||||
"mqtt_user": os.environ.get("MQTT_USER", ""),
|
||||
"mqtt_pass": os.environ.get("MQTT_PASS", ""),
|
||||
"mqtt_topic_prefix": os.environ.get("MQTT_TOPIC_PREFIX", "growatt/shinelanx"),
|
||||
"update_interval": int(os.environ.get("UPDATE_INTERVAL", "30")),
|
||||
}
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
try:
|
||||
with open(CONFIG_PATH) as f:
|
||||
saved = json.load(f)
|
||||
cfg.update(saved)
|
||||
except Exception as e:
|
||||
log.warning("Config-Datei konnte nicht gelesen werden: %s", e)
|
||||
return cfg
|
||||
|
||||
|
||||
def save_config(cfg: Dict[str, Any]):
|
||||
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
|
||||
|
||||
_reader: Optional[ModbusReader] = None
|
||||
_publisher: Optional[MqttPublisher] = None
|
||||
_poll_thread: Optional[threading.Thread] = None
|
||||
_stop_event = threading.Event()
|
||||
|
||||
|
||||
def _build_reader(cfg: Dict[str, Any]) -> ModbusReader:
|
||||
return ModbusReader(
|
||||
host=cfg["modbus_ip"],
|
||||
port=cfg["modbus_port"],
|
||||
slave=cfg["modbus_address"],
|
||||
)
|
||||
|
||||
|
||||
def _build_publisher(cfg: Dict[str, Any]) -> MqttPublisher:
|
||||
return MqttPublisher(
|
||||
broker=cfg["mqtt_broker"],
|
||||
port=cfg["mqtt_port"],
|
||||
user=cfg["mqtt_user"],
|
||||
password=cfg["mqtt_pass"],
|
||||
topic_prefix=cfg["mqtt_topic_prefix"],
|
||||
)
|
||||
|
||||
|
||||
def _poll_loop(cfg: Dict[str, Any]):
|
||||
global _reader, _publisher
|
||||
|
||||
inverter_id = cfg.get("inverter_model", "MIC_1500_TL_X")
|
||||
inverter: Inverter = INVERTERS.get(inverter_id, INVERTERS["MIC_1500_TL_X"])
|
||||
interval = max(5, int(cfg.get("update_interval", 30)))
|
||||
|
||||
_reader = _build_reader(cfg)
|
||||
_publisher = _build_publisher(cfg)
|
||||
_publisher.connect()
|
||||
time.sleep(2)
|
||||
_publisher.setup_inverter(inverter)
|
||||
|
||||
log.info("Poll-Loop gestartet: %s alle %ds", inverter.name, interval)
|
||||
|
||||
while not _stop_event.is_set():
|
||||
t_start = time.time()
|
||||
values = _reader.read(inverter)
|
||||
with State.lock:
|
||||
if values is not None:
|
||||
State.last_values = values
|
||||
State.last_update = time.time()
|
||||
State.modbus_ok = True
|
||||
State.poll_count += 1
|
||||
_publisher.publish_data(values)
|
||||
_publisher.publish_status("online")
|
||||
else:
|
||||
State.modbus_ok = False
|
||||
State.error_count += 1
|
||||
_publisher.publish_status("offline")
|
||||
State.mqtt_ok = _publisher.connected
|
||||
|
||||
elapsed = time.time() - t_start
|
||||
wait = max(0.0, interval - elapsed)
|
||||
_stop_event.wait(wait)
|
||||
|
||||
_reader.close()
|
||||
_publisher.publish_status("offline")
|
||||
_publisher.disconnect()
|
||||
log.info("Poll-Loop beendet")
|
||||
|
||||
|
||||
def start_poll_thread(cfg: Dict[str, Any]):
|
||||
global _poll_thread
|
||||
_stop_event.clear()
|
||||
_poll_thread = threading.Thread(target=_poll_loop, args=(cfg,), daemon=True, name="poll")
|
||||
_poll_thread.start()
|
||||
|
||||
|
||||
def stop_poll_thread():
|
||||
_stop_event.set()
|
||||
if _poll_thread:
|
||||
_poll_thread.join(timeout=15)
|
||||
|
||||
|
||||
# ── REST API ──────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/config")
|
||||
def api_get_config():
|
||||
cfg = State.config.copy()
|
||||
cfg.pop("mqtt_pass", None) # Passwort nie zurückgeben
|
||||
return jsonify(cfg)
|
||||
|
||||
|
||||
@app.post("/api/config")
|
||||
def api_save_config():
|
||||
data = request.get_json(force=True) or {}
|
||||
with State.lock:
|
||||
# Passwort nur überschreiben wenn mitgesendet und nicht leer
|
||||
if not data.get("mqtt_pass"):
|
||||
data["mqtt_pass"] = State.config.get("mqtt_pass", "")
|
||||
State.config.update(data)
|
||||
save_config(State.config)
|
||||
cfg_snapshot = State.config.copy()
|
||||
|
||||
stop_poll_thread()
|
||||
start_poll_thread(cfg_snapshot)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.get("/api/data")
|
||||
def api_get_data():
|
||||
with State.lock:
|
||||
inverter_id = State.config.get("inverter_model", "MIC_1500_TL_X")
|
||||
inverter = INVERTERS.get(inverter_id, INVERTERS["MIC_1500_TL_X"])
|
||||
sensors_meta = [
|
||||
{
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"unit": s.unit,
|
||||
"icon": s.icon,
|
||||
"device_class": s.device_class,
|
||||
}
|
||||
for s in inverter.sensors
|
||||
]
|
||||
return jsonify({
|
||||
"values": State.last_values,
|
||||
"sensors": sensors_meta,
|
||||
"last_update": State.last_update,
|
||||
"modbus_ok": State.modbus_ok,
|
||||
"mqtt_ok": State.mqtt_ok,
|
||||
"poll_count": State.poll_count,
|
||||
"error_count": State.error_count,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/inverters")
|
||||
def api_get_inverters():
|
||||
return jsonify({
|
||||
k: {"id": v.id, "name": v.name, "sensor_count": len(v.sensors)}
|
||||
for k, v in INVERTERS.items()
|
||||
})
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return send_from_directory(WEB_DIR, "index.html")
|
||||
|
||||
|
||||
@app.get("/<path:filename>")
|
||||
def static_files(filename):
|
||||
return send_from_directory(WEB_DIR, filename)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cfg = load_config()
|
||||
with State.lock:
|
||||
State.config = cfg
|
||||
start_poll_thread(cfg)
|
||||
port = int(os.environ.get("INGRESS_PORT", "8099"))
|
||||
log.info("Web UI startet auf Port %d", port)
|
||||
app.run(host="0.0.0.0", port=port, threaded=True)
|
||||
Reference in New Issue
Block a user