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:
retr0
2026-04-24 22:30:45 +02:00
parent 4d2da56baf
commit 0d6b860664
12 changed files with 1327 additions and 47 deletions
+221
View File
@@ -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)