Files
Shinebridge/haos-addon/src/main.py
T
retr0 d1f47177fc Fix: EMS aktiviert sich auch ohne lesbare charging_state (0x0060)
- charging_state Default 0→1 (EV Connected) wenn Register nicht lesbar
- EMS-Status auf INFO hochgestuft inkl. PV-Überschuss

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:26:15 +02:00

502 lines
21 KiB
Python

import json
import logging
import os
import threading
import time
import uuid
from collections import defaultdict, deque
from typing import Any, Dict, List, Optional
from flask import Flask, jsonify, request, send_from_directory
from inverters import INVERTERS
import history
from modbus_client import ModbusReader
from goodwe_client import GoodweReader
from wallbox_client import WallboxReader
from ems_controller import EmsController
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"
HA_OPTIONS_PATH = "/data/options.json"
WEB_DIR = os.path.join(os.path.dirname(__file__), "web")
app = Flask(__name__, static_folder=WEB_DIR)
# ── Aggregation ───────────────────────────────────────────────
# Welche Sensor-IDs fließen in welchen Aggregat-Bucket (Summe, außer AGG_AVG)
AGG_SENSOR_IDS: Dict[str, List[str]] = {
"total_pv_power": ["pv_power", "pv1_power", "pv2_power", "ppv"],
"total_ac_power": ["ac_power", "ac_power_total"],
"total_energy_today": ["energy_today", "e_day"],
"total_energy_total": ["energy_total", "e_total"],
"grid_power": ["total_power", "active_power"],
"grid_import_kwh": ["import_kwh", "e_total_imp"],
"grid_export_kwh": ["export_kwh", "e_total_exp"],
"bat_charge_power": ["bat_charge_power"],
"bat_discharge_power": ["bat_discharge_power"],
"bat_charge_total": ["bat_charge_total", "e_bat_charge_total"],
"bat_discharge_total": ["bat_discharge_total", "e_bat_discharge_total"],
"bat_soc": ["bat_soc", "battery_soc"],
}
AGG_AVG = {"bat_soc"}
AGGREGATE_META: Dict[str, Dict[str, str]] = {
"total_pv_power": {"name": "PV Gesamtleistung", "unit": "W", "device_class": "power", "state_class": "measurement", "icon": "mdi:solar-power"},
"total_ac_power": {"name": "AC Gesamtleistung", "unit": "W", "device_class": "power", "state_class": "measurement", "icon": "mdi:flash"},
"total_energy_today": {"name": "Energie Heute Gesamt", "unit": "kWh", "device_class": "energy", "state_class": "total_increasing", "icon": "mdi:solar-power"},
"total_energy_total": {"name": "Energie Gesamt", "unit": "kWh", "device_class": "energy", "state_class": "total_increasing", "icon": "mdi:solar-power"},
"grid_power": {"name": "Netzleistung", "unit": "W", "device_class": "power", "state_class": "measurement", "icon": "mdi:transmission-tower"},
"grid_import_kwh": {"name": "Netzbezug Gesamt", "unit": "kWh", "device_class": "energy", "state_class": "total_increasing", "icon": "mdi:transmission-tower-import"},
"grid_export_kwh": {"name": "Einspeisung Gesamt", "unit": "kWh", "device_class": "energy", "state_class": "total_increasing", "icon": "mdi:transmission-tower-export"},
"bat_charge_power": {"name": "Batterie Ladeleistung Ges.", "unit": "W", "device_class": "power", "state_class": "measurement", "icon": "mdi:battery-plus"},
"bat_discharge_power": {"name": "Batterie Entladeleistung Ges.","unit": "W", "device_class": "power", "state_class": "measurement", "icon": "mdi:battery-minus"},
"bat_charge_total": {"name": "Batterie Ladung Gesamt", "unit": "kWh", "device_class": "energy", "state_class": "total_increasing", "icon": "mdi:battery-plus"},
"bat_discharge_total": {"name": "Batterie Entladung Gesamt", "unit": "kWh", "device_class": "energy", "state_class": "total_increasing", "icon": "mdi:battery-minus"},
"bat_soc": {"name": "Batterie Ladezustand Ø", "unit": "%", "device_class": "battery", "state_class": "measurement", "icon": "mdi:battery"},
}
# ── State ────────────────────────────────────────────────────
class State:
lock = threading.Lock()
mqtt_cfg: Dict[str, Any] = {}
inverters_cfg: List[Dict[str, Any]] = []
inv_data: Dict[str, Dict[str, Any]] = {}
_publisher: Optional[MqttPublisher] = None
_threads: Dict[str, threading.Thread] = {}
_stop_events: Dict[str, threading.Event] = {}
# ── Config ───────────────────────────────────────────────────
def _defaults() -> Dict[str, Any]:
return {
"mqtt_broker": "core-mosquitto",
"mqtt_port": 1883,
"mqtt_user": "",
"mqtt_pass": "",
"inverters": [],
}
def load_config() -> Dict[str, Any]:
cfg = _defaults()
if os.path.exists(HA_OPTIONS_PATH):
try:
with open(HA_OPTIONS_PATH) as f:
ha = json.load(f)
for k in ("mqtt_broker", "mqtt_port", "mqtt_user", "mqtt_pass"):
if k in ha:
cfg[k] = ha[k]
except Exception as e:
log.warning("HA options Fehler: %s", e)
if os.path.exists(CONFIG_PATH):
try:
with open(CONFIG_PATH) as f:
cfg.update(json.load(f))
except Exception as e:
log.warning("Config-Datei Fehler: %s", e)
return cfg
def save_config():
data = {
"mqtt_broker": State.mqtt_cfg.get("mqtt_broker", ""),
"mqtt_port": State.mqtt_cfg.get("mqtt_port", 1883),
"mqtt_user": State.mqtt_cfg.get("mqtt_user", ""),
"mqtt_pass": State.mqtt_cfg.get("mqtt_pass", ""),
"inverters": State.inverters_cfg,
}
with open(CONFIG_PATH, "w") as f:
json.dump(data, f, indent=2)
# ── Aggregation ───────────────────────────────────────────────
def _compute_aggregates() -> Dict[str, float]:
buckets: Dict[str, List[float]] = defaultdict(list)
with State.lock:
for inv_cfg in State.inverters_cfg:
inv_id = inv_cfg["id"]
d = State.inv_data.get(inv_id, {})
if not d.get("modbus_ok") or not d.get("values"):
continue
values = d["values"]
for agg_id, sensor_ids in AGG_SENSOR_IDS.items():
for sid in sensor_ids:
if sid in values:
buckets[agg_id].append(values[sid])
result: Dict[str, float] = {}
for agg_id, vals in buckets.items():
if vals:
result[agg_id] = round(
sum(vals) / len(vals) if agg_id in AGG_AVG else sum(vals),
3,
)
return result
# ── EMS Hilfsfunktionen ───────────────────────────────────────
def _get_pv_surplus() -> float:
"""PV-Überschuss in Watt aus laufenden Geräten ermitteln.
Goodwe: active_power negativ = Einspeisung (Überschuss).
Growatt: power_to_grid positiv = Einspeisung.
"""
surplus = 0.0
with State.lock:
for inv_cfg in State.inverters_cfg:
d = State.inv_data.get(inv_cfg["id"], {})
if not d.get("modbus_ok") or not d.get("values"):
continue
v = d["values"]
# Goodwe: active_power < 0 bedeutet Einspeisung
if "active_power" in v:
surplus += max(0.0, -v["active_power"])
# Growatt
if "power_to_grid" in v:
surplus += max(0.0, v["power_to_grid"])
return surplus
# ── Poll Loop ─────────────────────────────────────────────────
def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
inv_id = inv_cfg["id"]
model_id = inv_cfg.get("inverter_model", "MIC_1500_TL_X")
inverter = INVERTERS.get(model_id, INVERTERS["MIC_1500_TL_X"])
prefix = inv_cfg.get("mqtt_topic_prefix", f"growatt/{inv_id}")
device_id = f"growatt_{inv_id}"
try:
interval = max(5, int(inv_cfg.get("update_interval", 30)))
port = int(inv_cfg.get("modbus_port", 502))
slave = int(inv_cfg.get("modbus_address", 1))
except (ValueError, TypeError) as e:
log.error("[%s] Ungültige Konfiguration: %s", inv_id, e)
return
host = inv_cfg["modbus_ip"]
ems: Optional[EmsController] = None
if inverter.protocol == "goodwe_udp":
reader = GoodweReader(host=host, family=inverter.goodwe_family)
log.info("[%s] Poll-Loop: %s @ %s (Goodwe UDP/8899) alle %ds",
inv_id, inverter.name, host, interval)
elif inverter.protocol == "kathrein":
reader = WallboxReader(host=host, port=port)
ems = EmsController(
min_pv_power=inv_cfg.get("ems_min_pv", 1400),
pv_timeout_h=inv_cfg.get("ems_timeout", 4.0),
target_hour=inv_cfg.get("ems_target_hour", 6),
phases=inv_cfg.get("ems_phases", 3),
)
log.info("[%s] Poll-Loop: %s @ %s:%s (Kathrein EMS) alle %ds",
inv_id, inverter.name, host, port, interval)
else:
reader = ModbusReader(host=host, port=port, slave=slave)
log.info("[%s] Poll-Loop: %s @ %s:%s alle %ds",
inv_id, inverter.name, host, port, interval)
with State.lock:
if _publisher:
_publisher.register_inverter(inverter, device_id, prefix,
inv_cfg.get("name", inverter.name))
# History aus DB in die In-Memory-Deque laden
hist_data = history.load_recent(inv_id, limit=300)
with State.lock:
d = State.inv_data.setdefault(inv_id, {"poll_count": 0})
hist = d.setdefault("history", {})
for sid, points in hist_data.items():
q = hist.setdefault(sid, deque(maxlen=300))
for pt in points:
q.append(pt)
log.info("[%s] %d Sensoren aus DB geladen", inv_id, len(hist_data))
while not stop.is_set():
t0 = time.time()
values = reader.read(inverter)
# EMS: PV-Überschuss aus anderen Geräten holen und Ladestrom regeln
if ems is not None and values is not None:
pv_surplus = _get_pv_surplus()
# 0x0060 manchmal nicht lesbar → 1 (EV Connected) annehmen,
# damit EMS aktiviert; Wallbox ignoriert Befehle wenn kein Auto da
charging_state = int(values.get("charging_state", 1))
ems_status = ems.update(reader, pv_surplus, charging_state)
values["ems_status_code"] = float(charging_state)
log.info("[%s] EMS: %s | PV-Überschuss: %.0fW", inv_id, ems_status, pv_surplus)
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
hist = d.setdefault("history", {})
now = time.time()
for sid, val in values.items():
q = hist.setdefault(sid, deque(maxlen=300))
q.append((now, val))
history.write_batch(inv_id, now, values)
if _publisher:
_publisher.publish_data(values, prefix)
_publisher.publish_status("online", prefix)
else:
d["modbus_ok"] = False
if _publisher:
_publisher.publish_status("offline", prefix)
# Aggregate nach jedem erfolgreichen Poll neu berechnen und publizieren
if values is not None and _publisher:
agg = _compute_aggregates()
if agg:
_publisher.publish_aggregates(agg)
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", ""),
agg_meta=AGGREGATE_META,
)
_publisher.connect()
time.sleep(2)
for inv_cfg in State.inverters_cfg:
_start_inverter(inv_cfg)
# ── REST API ──────────────────────────────────────────────────
@app.get("/api/config")
def api_get_config():
with State.lock:
cfg = {**State.mqtt_cfg, "inverters": State.inverters_cfg}
cfg.pop("mqtt_pass", None)
cfg["mqtt_connected"] = _publisher.connected if _publisher else False
return jsonify(cfg)
@app.post("/api/config")
def api_save_config():
data = request.get_json(force=True) or {}
with State.lock:
for k in ("mqtt_broker", "mqtt_port", "mqtt_user"):
if k in data:
State.mqtt_cfg[k] = data[k]
if data.get("mqtt_pass"):
State.mqtt_cfg["mqtt_pass"] = data["mqtt_pass"]
save_config()
threading.Thread(target=_restart_all, daemon=True).start()
return jsonify({"ok": True})
@app.get("/api/inverters-config")
def api_get_inverters():
with State.lock:
return jsonify(State.inverters_cfg)
@app.post("/api/inverters-config")
def api_save_inverters():
data = request.get_json(force=True) or []
if not isinstance(data, list):
return jsonify({"error": "invalid"}), 400
for inv in data:
if not isinstance(inv, dict):
return jsonify({"error": "invalid"}), 400
model_id = inv.get("inverter_model")
if model_id not in INVERTERS:
return jsonify({"error": f"unknown model: {model_id}"}), 400
inverter_def = INVERTERS[model_id]
if inverter_def.protocol == "modbus":
port = inv.get("modbus_port", 502)
if not isinstance(port, int) or not (1 <= port <= 65535):
return jsonify({"error": "invalid port"}), 400
addr = inv.get("modbus_address", 1)
if not isinstance(addr, int) or not (1 <= addr <= 247):
return jsonify({"error": "invalid modbus address (1-247)"}), 400
with State.lock:
State.inverters_cfg = data
save_config()
threading.Thread(target=_restart_all, daemon=True).start()
return jsonify({"ok": True})
@app.get("/api/data")
def api_get_data():
with State.lock:
result = {}
for inv_cfg in State.inverters_cfg:
inv_id = inv_cfg["id"]
model_id = inv_cfg.get("inverter_model", "MIC_1500_TL_X")
inverter = INVERTERS.get(model_id, INVERTERS["MIC_1500_TL_X"])
d = State.inv_data.get(inv_id, {})
cutoff = time.time() - 300
raw_hist = d.get("history", {})
history = {
sid: [v for (t, v) in q if t >= cutoff]
for sid, q in raw_hist.items()
}
result[inv_id] = {
"name": inv_cfg.get("name", inverter.name),
"inverter_name": inverter.name,
"values": d.get("values", {}),
"history": history,
"sensors": [
{"id": s.id, "name": s.name, "unit": s.unit,
"icon": s.icon, "device_class": s.device_class}
for s in inverter.sensors
],
"last_update": d.get("last_update"),
"modbus_ok": d.get("modbus_ok", False),
"poll_count": d.get("poll_count", 0),
}
mqtt_ok = _publisher.connected if _publisher else False
aggregates = _compute_aggregates()
return jsonify({"inverters": result, "mqtt_ok": mqtt_ok, "aggregates": aggregates})
@app.get("/api/history")
def api_get_history():
inv_id = request.args.get("inv_id", "")
sensor_id = request.args.get("sensor_id", "")
hours = min(float(request.args.get("hours", "24")), 24 * 7)
if not inv_id or not sensor_id:
return jsonify({"error": "inv_id and sensor_id required"}), 400
to_ts = time.time()
from_ts = to_ts - hours * 3600
rows = history.query(inv_id, sensor_id, from_ts, to_ts)
return jsonify([{"ts": t, "value": v} for t, v in rows])
@app.get("/api/inverter-models")
def api_get_models():
return jsonify({
k: {"id": v.id, "name": v.name, "sensor_count": len(v.sensors)}
for k, v in INVERTERS.items()
})
@app.post("/api/new-id")
def api_new_id():
return jsonify({"id": uuid.uuid4().hex[:8]})
@app.get("/api/export-config")
def api_export_config():
with State.lock:
data = {
"shinebridge_export": True,
"version": 1,
"exported_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"mqtt": {
"broker": State.mqtt_cfg.get("mqtt_broker", ""),
"port": State.mqtt_cfg.get("mqtt_port", 1883),
"user": State.mqtt_cfg.get("mqtt_user", ""),
},
"inverters": State.inverters_cfg,
}
from flask import Response
filename = f"shinebridge-config-{time.strftime('%Y%m%d-%H%M%S')}.json"
return Response(
json.dumps(data, indent=2, ensure_ascii=False),
mimetype="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@app.post("/api/import-config")
def api_import_config():
data = request.get_json(force=True) or {}
if not data.get("shinebridge_export"):
return jsonify({"error": "Keine gültige ShineBridge-Export-Datei"}), 400
inverters = data.get("inverters", [])
if not isinstance(inverters, list):
return jsonify({"error": "inverters ungültig"}), 400
for inv in inverters:
if not inv.get("modbus_ip"):
return jsonify({"error": f"modbus_ip fehlt in Gerät {inv.get('name', '?')}"}), 400
if inv.get("inverter_model") not in INVERTERS:
return jsonify({"error": f"Unbekanntes Modell: {inv.get('inverter_model')}"}), 400
with State.lock:
if mqtt := data.get("mqtt"):
if mqtt.get("broker"):
State.mqtt_cfg["mqtt_broker"] = mqtt["broker"]
if mqtt.get("port"):
State.mqtt_cfg["mqtt_port"] = int(mqtt["port"])
if mqtt.get("user"):
State.mqtt_cfg["mqtt_user"] = mqtt["user"]
State.inverters_cfg = inverters
save_config()
threading.Thread(target=_restart_all, daemon=True).start()
return jsonify({"ok": True, "inverters": len(inverters)})
@app.get("/")
def index():
return send_from_directory(WEB_DIR, "index.html")
@app.get("/<path:filename>")
def static_files(filename):
return send_from_directory(WEB_DIR, filename)
# ── Startup ───────────────────────────────────────────────────
if __name__ == "__main__":
history.init_db()
cfg = load_config()
with State.lock:
State.mqtt_cfg = {k: cfg[k] for k in
("mqtt_broker", "mqtt_port", "mqtt_user", "mqtt_pass")}
State.inverters_cfg = cfg.get("inverters", [])
if not State.inverters_cfg and cfg.get("modbus_ip"):
State.inverters_cfg = [{
"id": uuid.uuid4().hex[:8],
"name": cfg.get("inverter_model", "MIC_1500_TL_X").replace("_", " "),
"modbus_ip": cfg["modbus_ip"],
"modbus_port": cfg.get("modbus_port", 502),
"modbus_address": cfg.get("modbus_address", 1),
"inverter_model": cfg.get("inverter_model", "MIC_1500_TL_X"),
"mqtt_topic_prefix": cfg.get("mqtt_topic_prefix", "growatt/shinelanx"),
"update_interval": cfg.get("update_interval", 30),
}]
save_config()
_restart_all()
port = int(os.environ.get("INGRESS_PORT", "8099"))
log.info("Web UI startet auf Port %d", port)
app.run(host="0.0.0.0", port=port, threaded=True)