Feature: Aggregat-Gerät + Energie-Dashboard Sensoren (v1.2.0)

- main.py: AGG_SENSOR_IDS/AGGREGATE_META — Mapping sensor_id → Aggregat-Bucket
  _compute_aggregates() summiert alle online Geräte nach jedem Poll
  /api/data liefert jetzt auch "aggregates" Schlüssel
- mqtt_publisher.py: publish_aggregates() + _publish_aggregate_discovery()
  Eigenes HA-Gerät "ShineBridge Gesamt" (device_id: shinebridge_aggregate)
  MQTT Topic: shinebridge/aggregate/state
- index.html: renderAggregates() — "Gesamt"-Sektion oben im Live-Tab

Aggregierte Sensoren (alle kompatibel mit HA Energie-Dashboard):
  PV: total_pv_power, total_ac_power, total_energy_today, total_energy_total
  Netz (SDM-630): grid_power, grid_import_kwh, grid_export_kwh
  Batterie (SPH): bat_charge/discharge_power/total, bat_soc (Ø)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
retr0
2026-04-26 21:40:01 +02:00
parent 33c6a15644
commit 456bfb34d6
4 changed files with 167 additions and 22 deletions
+72 -11
View File
@@ -4,7 +4,7 @@ import os
import threading
import time
import uuid
from collections import deque
from collections import defaultdict, deque
from typing import Any, Dict, List, Optional
from flask import Flask, jsonify, request, send_from_directory
@@ -25,13 +25,46 @@ 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"],
"total_ac_power": ["ac_power", "ac_power_total"],
"total_energy_today": ["energy_today"],
"total_energy_total": ["energy_total"],
"grid_power": ["total_power"],
"grid_import_kwh": ["import_kwh"],
"grid_export_kwh": ["export_kwh"],
"bat_charge_power": ["bat_charge_power"],
"bat_discharge_power": ["bat_discharge_power"],
"bat_charge_total": ["bat_charge_total"],
"bat_discharge_total": ["bat_discharge_total"],
"bat_soc": ["bat_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_id: {values, last_update, modbus_ok, poll_count}}
inv_data: Dict[str, Dict[str, Any]] = {}
_publisher: Optional[MqttPublisher] = None
@@ -79,6 +112,31 @@ def save_config():
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
# ── Poll Loop ─────────────────────────────────────────────────
def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
@@ -95,11 +153,7 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
log.error("[%s] Ungültige Konfiguration: %s", inv_id, e)
return
reader = ModbusReader(
host=inv_cfg["modbus_ip"],
port=port,
slave=slave,
)
reader = ModbusReader(host=inv_cfg["modbus_ip"], port=port, slave=slave)
with State.lock:
if _publisher:
@@ -120,7 +174,6 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
d["last_update"] = time.time()
d["modbus_ok"] = True
d["poll_count"] = d.get("poll_count", 0) + 1
# History: (timestamp, value) pro Sensor, maximal 5 Minuten
hist = d.setdefault("history", {})
now = time.time()
for sid, val in values.items():
@@ -134,6 +187,12 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
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()
@@ -170,6 +229,7 @@ def _restart_all():
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)
@@ -236,7 +296,7 @@ def api_get_data():
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 # letzte 5 Minuten
cutoff = time.time() - 300
raw_hist = d.get("history", {})
history = {
sid: [v for (t, v) in q if t >= cutoff]
@@ -257,7 +317,9 @@ def api_get_data():
"poll_count": d.get("poll_count", 0),
}
mqtt_ok = _publisher.connected if _publisher else False
return jsonify({"inverters": result, "mqtt_ok": mqtt_ok})
aggregates = _compute_aggregates()
return jsonify({"inverters": result, "mqtt_ok": mqtt_ok, "aggregates": aggregates})
@app.get("/api/inverter-models")
def api_get_models():
@@ -286,7 +348,6 @@ if __name__ == "__main__":
State.mqtt_cfg = {k: cfg[k] for k in
("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],