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:
+72
-11
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -225,6 +225,21 @@ function esc(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
Reference in New Issue
Block a user