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 name: ShineBridge
version: "1.1.5" version: "1.2.0"
slug: shinebridge slug: shinebridge
description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI 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 url: https://gitea.bitfire.work/retr0/Growatt-Wechselrichter-HAOS
+72 -11
View File
@@ -4,7 +4,7 @@ import os
import threading import threading
import time import time
import uuid import uuid
from collections import deque from collections import defaultdict, deque
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from flask import Flask, jsonify, request, send_from_directory 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) 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 ──────────────────────────────────────────────────── # ── State ────────────────────────────────────────────────────
class State: class State:
lock = threading.Lock() lock = threading.Lock()
mqtt_cfg: Dict[str, Any] = {} mqtt_cfg: Dict[str, Any] = {}
inverters_cfg: List[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]] = {} inv_data: Dict[str, Dict[str, Any]] = {}
_publisher: Optional[MqttPublisher] = None _publisher: Optional[MqttPublisher] = None
@@ -79,6 +112,31 @@ def save_config():
with open(CONFIG_PATH, "w") as f: with open(CONFIG_PATH, "w") as f:
json.dump(data, f, indent=2) 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 ───────────────────────────────────────────────── # ── Poll Loop ─────────────────────────────────────────────────
def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event): 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) log.error("[%s] Ungültige Konfiguration: %s", inv_id, e)
return return
reader = ModbusReader( reader = ModbusReader(host=inv_cfg["modbus_ip"], port=port, slave=slave)
host=inv_cfg["modbus_ip"],
port=port,
slave=slave,
)
with State.lock: with State.lock:
if _publisher: if _publisher:
@@ -120,7 +174,6 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
d["last_update"] = time.time() d["last_update"] = time.time()
d["modbus_ok"] = True d["modbus_ok"] = True
d["poll_count"] = d.get("poll_count", 0) + 1 d["poll_count"] = d.get("poll_count", 0) + 1
# History: (timestamp, value) pro Sensor, maximal 5 Minuten
hist = d.setdefault("history", {}) hist = d.setdefault("history", {})
now = time.time() now = time.time()
for sid, val in values.items(): for sid, val in values.items():
@@ -134,6 +187,12 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
if _publisher: if _publisher:
_publisher.publish_status("offline", prefix) _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))) stop.wait(max(0.0, interval - (time.time() - t0)))
reader.close() reader.close()
@@ -170,6 +229,7 @@ def _restart_all():
port=int(State.mqtt_cfg.get("mqtt_port", 1883)), port=int(State.mqtt_cfg.get("mqtt_port", 1883)),
user=State.mqtt_cfg.get("mqtt_user", ""), user=State.mqtt_cfg.get("mqtt_user", ""),
password=State.mqtt_cfg.get("mqtt_pass", ""), password=State.mqtt_cfg.get("mqtt_pass", ""),
agg_meta=AGGREGATE_META,
) )
_publisher.connect() _publisher.connect()
time.sleep(2) time.sleep(2)
@@ -236,7 +296,7 @@ def api_get_data():
model_id = inv_cfg.get("inverter_model", "MIC_1500_TL_X") model_id = inv_cfg.get("inverter_model", "MIC_1500_TL_X")
inverter = INVERTERS.get(model_id, INVERTERS["MIC_1500_TL_X"]) inverter = INVERTERS.get(model_id, INVERTERS["MIC_1500_TL_X"])
d = State.inv_data.get(inv_id, {}) d = State.inv_data.get(inv_id, {})
cutoff = time.time() - 300 # letzte 5 Minuten cutoff = time.time() - 300
raw_hist = d.get("history", {}) raw_hist = d.get("history", {})
history = { history = {
sid: [v for (t, v) in q if t >= cutoff] 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), "poll_count": d.get("poll_count", 0),
} }
mqtt_ok = _publisher.connected if _publisher else False 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") @app.get("/api/inverter-models")
def api_get_models(): def api_get_models():
@@ -286,7 +348,6 @@ if __name__ == "__main__":
State.mqtt_cfg = {k: cfg[k] for k in State.mqtt_cfg = {k: cfg[k] for k in
("mqtt_broker", "mqtt_port", "mqtt_user", "mqtt_pass")} ("mqtt_broker", "mqtt_port", "mqtt_user", "mqtt_pass")}
State.inverters_cfg = cfg.get("inverters", []) State.inverters_cfg = cfg.get("inverters", [])
# Migration: single-inverter config → list
if not State.inverters_cfg and cfg.get("modbus_ip"): if not State.inverters_cfg and cfg.get("modbus_ip"):
State.inverters_cfg = [{ State.inverters_cfg = [{
"id": uuid.uuid4().hex[:8], "id": uuid.uuid4().hex[:8],
+53 -7
View File
@@ -1,7 +1,6 @@
import json import json
import logging import logging
import time from typing import Dict, List, Optional, Tuple
from typing import List, Tuple
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
@@ -9,13 +8,18 @@ from inverters import Inverter
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
AGG_DEVICE_ID = "shinebridge_aggregate"
AGG_TOPIC = "shinebridge/aggregate"
class MqttPublisher: 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._broker = broker
self._port = port self._port = port
self._connected = False 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) self._client = mqtt.Client(client_id="shinebridge_hub", clean_session=True)
if user: if user:
@@ -29,6 +33,8 @@ class MqttPublisher:
log.info("MQTT verbunden: %s:%d", self._broker, self._port) log.info("MQTT verbunden: %s:%d", self._broker, self._port)
for entry in self._registered: for entry in self._registered:
self._publish_discovery(*entry) self._publish_discovery(*entry)
if self._agg_meta:
self._publish_aggregate_discovery()
else: else:
log.error("MQTT Verbindungsfehler rc=%d", rc) log.error("MQTT Verbindungsfehler rc=%d", rc)
@@ -51,7 +57,10 @@ class MqttPublisher:
def connected(self) -> bool: def connected(self) -> bool:
return self._connected 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) entry = (inverter, device_id, topic_prefix, display_name)
self._registered = [r for r in self._registered if r[1] != device_id] self._registered = [r for r in self._registered if r[1] != device_id]
self._registered.append(entry) self._registered.append(entry)
@@ -61,7 +70,8 @@ class MqttPublisher:
def unregister_inverter(self, device_id: str): def unregister_inverter(self, device_id: str):
self._registered = [r for r in self._registered if r[1] != device_id] 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 = { device_payload = {
"identifiers": [device_id], "identifiers": [device_id],
"name": display_name or inverter.name, "name": display_name or inverter.name,
@@ -85,10 +95,46 @@ class MqttPublisher:
self._client.publish(topic, json.dumps(config), retain=True, qos=1) 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) 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): def publish_data(self, values: dict, topic_prefix: str):
if not self._connected: if not self._connected:
return 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): def publish_status(self, status: str, topic_prefix: str):
self._client.publish(f"{topic_prefix}/status", status, retain=True, qos=1) 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;'); 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 = { const DC_COLORS = {
power: '#f0c040', voltage: '#58a6ff', current: '#ffa657', power: '#f0c040', voltage: '#58a6ff', current: '#ffa657',
energy: '#3fb950', temperature: '#f85149', battery: '#bc8cff', energy: '#3fb950', temperature: '#f85149', battery: '#bc8cff',
@@ -297,19 +312,42 @@ async function refreshData() {
const keys = Object.keys(d.inverters || {}); const keys = Object.keys(d.inverters || {});
document.getElementById("subtitle").textContent = document.getElementById("subtitle").textContent =
keys.length ? `${keys.length} Gerät${keys.length !== 1 ? "e" : ""}` : "Keine Geräte"; keys.length ? `${keys.length} Gerät${keys.length !== 1 ? "e" : ""}` : "Keine Geräte";
renderLive(d.inverters || {}); renderLive(d.inverters || {}, d.aggregates || {});
} catch(e) { } catch(e) {
document.getElementById("pill-mqtt").className = "pill err"; 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"); const el = document.getElementById("live-content");
if (!Object.keys(inverters).length) { if (!Object.keys(inverters).length) {
el.innerHTML = '<div class="no-data">Keine Geräte konfiguriert.<br>Bitte im Tab „Geräte" hinzufügen.</div>'; el.innerHTML = '<div class="no-data">Keine Geräte konfiguriert.<br>Bitte im Tab „Geräte" hinzufügen.</div>';
return; 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 ago = inv.last_update ? Math.round(Date.now()/1000 - inv.last_update) + "s" : "—";
const cards = (inv.sensors || []).map(s => { const cards = (inv.sensors || []).map(s => {
const val = inv.values[s.id]; const val = inv.values[s.id];