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
+1 -1
View File
@@ -1,5 +1,5 @@
name: ShineBridge
version: "1.1.5"
version: "1.2.0"
slug: shinebridge
description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI
url: https://gitea.bitfire.work/retr0/Growatt-Wechselrichter-HAOS
+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],
+53 -7
View File
@@ -1,7 +1,6 @@
import json
import logging
import time
from typing import List, Tuple
from typing import Dict, List, Optional, Tuple
import paho.mqtt.client as mqtt
@@ -9,13 +8,18 @@ from inverters import Inverter
log = logging.getLogger(__name__)
AGG_DEVICE_ID = "shinebridge_aggregate"
AGG_TOPIC = "shinebridge/aggregate"
class MqttPublisher:
def __init__(self, broker: str, port: int, user: str, password: str):
def __init__(self, broker: str, port: int, user: str, password: str,
agg_meta: Optional[Dict] = None):
self._broker = broker
self._port = port
self._connected = False
self._registered: List[Tuple[Inverter, str, str]] = [] # (inverter, device_id, topic_prefix)
self._registered: List[Tuple] = []
self._agg_meta: Dict = agg_meta or {}
self._client = mqtt.Client(client_id="shinebridge_hub", clean_session=True)
if user:
@@ -29,6 +33,8 @@ class MqttPublisher:
log.info("MQTT verbunden: %s:%d", self._broker, self._port)
for entry in self._registered:
self._publish_discovery(*entry)
if self._agg_meta:
self._publish_aggregate_discovery()
else:
log.error("MQTT Verbindungsfehler rc=%d", rc)
@@ -51,7 +57,10 @@ class MqttPublisher:
def connected(self) -> bool:
return self._connected
def register_inverter(self, inverter: Inverter, device_id: str, topic_prefix: str, display_name: str = None):
# ── Gerät-Discovery ──────────────────────────────────────
def register_inverter(self, inverter: Inverter, device_id: str,
topic_prefix: str, display_name: str = None):
entry = (inverter, device_id, topic_prefix, display_name)
self._registered = [r for r in self._registered if r[1] != device_id]
self._registered.append(entry)
@@ -61,7 +70,8 @@ class MqttPublisher:
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, display_name: str = None):
def _publish_discovery(self, inverter: Inverter, device_id: str,
topic_prefix: str, display_name: str = None):
device_payload = {
"identifiers": [device_id],
"name": display_name or inverter.name,
@@ -85,10 +95,46 @@ class MqttPublisher:
self._client.publish(topic, json.dumps(config), retain=True, qos=1)
log.info("MQTT Discovery: %d Sensoren für %s", len(inverter.sensors), device_id)
# ── Aggregat-Discovery ────────────────────────────────────
def _publish_aggregate_discovery(self):
device_payload = {
"identifiers": [AGG_DEVICE_ID],
"name": "ShineBridge Gesamt",
"manufacturer": "ShineBridge",
"model": "Aggregat",
}
for sensor_id, meta in self._agg_meta.items():
config = {
"name": meta["name"],
"unique_id": f"{AGG_DEVICE_ID}_{sensor_id}",
"state_topic": f"{AGG_TOPIC}/state",
"value_template": f"{{{{ value_json.{sensor_id} }}}}",
"unit_of_measurement": meta["unit"],
"state_class": meta["state_class"],
"icon": meta["icon"],
"device": device_payload,
}
if meta.get("device_class"):
config["device_class"] = meta["device_class"]
topic = f"homeassistant/sensor/{AGG_DEVICE_ID}/{sensor_id}/config"
self._client.publish(topic, json.dumps(config), retain=True, qos=1)
log.info("MQTT Discovery: %d Aggregat-Sensoren", len(self._agg_meta))
# ── Daten publizieren ─────────────────────────────────────
def publish_data(self, values: dict, topic_prefix: str):
if not self._connected:
return
self._client.publish(f"{topic_prefix}/state", json.dumps(values), retain=True, qos=0)
self._client.publish(f"{topic_prefix}/state", json.dumps(values),
retain=True, qos=0)
def publish_status(self, status: str, topic_prefix: str):
self._client.publish(f"{topic_prefix}/status", status, retain=True, qos=1)
def publish_aggregates(self, values: dict):
if not self._connected or not values:
return
self._client.publish(f"{AGG_TOPIC}/state", json.dumps(values),
retain=True, qos=0)
self._client.publish(f"{AGG_TOPIC}/status", "online", retain=True, qos=1)
+41 -3
View File
@@ -225,6 +225,21 @@ function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
const AGG_META = {
total_pv_power: {name:"PV Gesamtleistung", unit:"W", device_class:"power", icon:"mdi:solar-power"},
total_ac_power: {name:"AC Gesamtleistung", unit:"W", device_class:"power", icon:"mdi:flash"},
total_energy_today: {name:"Energie Heute Gesamt", unit:"kWh", device_class:"energy", icon:"mdi:solar-power"},
total_energy_total: {name:"Energie Gesamt", unit:"kWh", device_class:"energy", icon:"mdi:solar-power"},
grid_power: {name:"Netzleistung", unit:"W", device_class:"power", icon:"mdi:transmission-tower"},
grid_import_kwh: {name:"Netzbezug Gesamt", unit:"kWh", device_class:"energy", icon:"mdi:transmission-tower-import"},
grid_export_kwh: {name:"Einspeisung Gesamt", unit:"kWh", device_class:"energy", icon:"mdi:transmission-tower-export"},
bat_charge_power: {name:"Batterie Ladeleistung Ges.", unit:"W", device_class:"power", icon:"mdi:battery-plus"},
bat_discharge_power: {name:"Batterie Entladeleist. Ges.", unit:"W", device_class:"power", icon:"mdi:battery-minus"},
bat_charge_total: {name:"Batterie Ladung Gesamt", unit:"kWh", device_class:"energy", icon:"mdi:battery-plus"},
bat_discharge_total: {name:"Batterie Entladung Gesamt", unit:"kWh", device_class:"energy", icon:"mdi:battery-minus"},
bat_soc: {name:"Batterie Ladezustand Ø", unit:"%", device_class:"battery", icon:"mdi:battery"},
};
const DC_COLORS = {
power: '#f0c040', voltage: '#58a6ff', current: '#ffa657',
energy: '#3fb950', temperature: '#f85149', battery: '#bc8cff',
@@ -297,19 +312,42 @@ async function refreshData() {
const keys = Object.keys(d.inverters || {});
document.getElementById("subtitle").textContent =
keys.length ? `${keys.length} Gerät${keys.length !== 1 ? "e" : ""}` : "Keine Geräte";
renderLive(d.inverters || {});
renderLive(d.inverters || {}, d.aggregates || {});
} catch(e) {
document.getElementById("pill-mqtt").className = "pill err";
}
}
function renderLive(inverters) {
function renderAggregates(aggregates) {
if (!aggregates || !Object.keys(aggregates).length) return '';
const cards = Object.entries(AGG_META).map(([id, meta]) => {
const val = aggregates[id];
if (val === undefined) return '';
const dcClass = meta.device_class ? `dc-${meta.device_class}` : '';
return `<div class="sensor-card ${dcClass}">
<div class="sensor-icon">${ICON_MAP[meta.icon]||"📊"}</div>
<div class="sensor-name">${esc(meta.name)}</div>
<div class="sensor-value">${fmtVal(val)}<span class="sensor-unit">${esc(meta.unit)}</span></div>
</div>`;
}).filter(Boolean).join('');
if (!cards) return '';
return `<div class="inv-section">
<div class="inv-header">
<div class="inv-title">Gesamt</div>
<div class="inv-badge ok">aggregiert</div>
</div>
<div class="sensor-grid">${cards}</div>
</div>`;
}
function renderLive(inverters, aggregates) {
const el = document.getElementById("live-content");
if (!Object.keys(inverters).length) {
el.innerHTML = '<div class="no-data">Keine Geräte konfiguriert.<br>Bitte im Tab „Geräte" hinzufügen.</div>';
return;
}
el.innerHTML = Object.values(inverters).map(inv => {
const aggHtml = renderAggregates(aggregates);
el.innerHTML = aggHtml + Object.values(inverters).map(inv => {
const ago = inv.last_update ? Math.round(Date.now()/1000 - inv.last_update) + "s" : "—";
const cards = (inv.sensors || []).map(s => {
const val = inv.values[s.id];