diff --git a/haos-addon/config.yaml b/haos-addon/config.yaml index 0eb132e..62a9a26 100644 --- a/haos-addon/config.yaml +++ b/haos-addon/config.yaml @@ -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 diff --git a/haos-addon/src/main.py b/haos-addon/src/main.py index b9637e1..ab454d1 100644 --- a/haos-addon/src/main.py +++ b/haos-addon/src/main.py @@ -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], diff --git a/haos-addon/src/mqtt_publisher.py b/haos-addon/src/mqtt_publisher.py index bba4065..5e26736 100644 --- a/haos-addon/src/mqtt_publisher.py +++ b/haos-addon/src/mqtt_publisher.py @@ -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) diff --git a/haos-addon/src/web/index.html b/haos-addon/src/web/index.html index a778b65..9b268a0 100644 --- a/haos-addon/src/web/index.html +++ b/haos-addon/src/web/index.html @@ -225,6 +225,21 @@ function esc(s) { return String(s).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 `
+
${ICON_MAP[meta.icon]||"📊"}
+
${esc(meta.name)}
+
${fmtVal(val)}${esc(meta.unit)}
+
`; + }).filter(Boolean).join(''); + if (!cards) return ''; + return `
+
+
Gesamt
+
aggregiert
+
+
${cards}
+
`; +} + +function renderLive(inverters, aggregates) { const el = document.getElementById("live-content"); if (!Object.keys(inverters).length) { el.innerHTML = '
Keine Geräte konfiguriert.
Bitte im Tab „Geräte" hinzufügen.
'; 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];