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],
|
||||
|
||||
Reference in New Issue
Block a user