Skip to content

Commit 4b1ef76

Browse files
committed
Add gap reconciliation for MQTT disconnects
1 parent 8bb0efb commit 4b1ef76

6 files changed

Lines changed: 125 additions & 3 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Inoffizielle Home Assistant Integration für die **EcoFlow PowerOcean Plus** Pho
1818
- **Energie-Dashboard** — kWh-Zähler direkt integriert, kein YAML nötig
1919
- **Verbindungsstatus** — MQTT-Verbindung als Sensor für Automationen
2020
- **Options Flow** — Anzahl Batterie-Packs jederzeit änderbar ohne Neueinrichtung
21+
- **Gap-Reconciliation** — bei kurzer Internet-Unterbrechung wird die Energielücke beim Reconnect transparent geschätzt
2122

2223
---
2324

@@ -182,6 +183,9 @@ Die kWh-Sensoren sind direkt einsatzbereit. Navigiere zu *Einstellungen → Dash
182183
**Hinweise:**
183184
- Zähler starten mit der ersten MQTT-Nachricht — historische Werte werden nicht rückwirkend berechnet
184185
- Werte bleiben über HA-Neustarts erhalten
186+
- Bei MQTT-/Internet-Lücken wird beim Reconnect eine Schätzung angewendet
187+
(Trapezregel aus letzter Leistung vor Disconnect und erster Leistung nach Reconnect)
188+
- Sehr lange Unterbrechungen werden aus Sicherheitsgründen nicht automatisch nachgerechnet
185189
- Kleine Messschwankungen (±5 W) können gleichzeitig minimale Bezugs- und Einspeisungswerte erzeugen — physikalisch normal, Einfluss auf Monatssummen vernachlässigbar
186190

187191
---
@@ -194,6 +198,7 @@ Die kWh-Sensoren sind direkt einsatzbereit. Navigiere zu *Einstellungen → Dash
194198
2. HA-Netzwerkverbindung prüfen
195199
3. Logs prüfen: *Einstellungen → System → Logs → „ecoflow"*
196200
4. Verbindungsstatus-Sensor prüfen: zeigt er `disconnected`?
201+
5. Im Verbindungsstatus-Sensor die Attribute `last_gap_*` prüfen (Start/Ende/Dauer der letzten Lücke)
197202

198203
### Login schlägt fehl
199204

custom_components/ecoflow_powerocean/const.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@
6666
MQTT_FIRST_DATA_TIMEOUT = 20
6767
"""Maximale Wartezeit in Sekunden auf erste Nutzdaten nach dem Verbindungsaufbau."""
6868

69+
GAP_RECONCILIATION_MIN_SECONDS = 60
70+
"""Mindestdauer einer MQTT-Unterbrechung, ab der eine Lücken-Korrektur berechnet wird."""
71+
72+
GAP_RECONCILIATION_MAX_SECONDS = 6 * 60 * 60
73+
"""Maximale Dauer (in Sekunden), die für eine automatische Lücken-Korrektur berücksichtigt wird.
74+
Längere Unterbrechungen werden aus Sicherheitsgründen nicht automatisch nachgerechnet."""
75+
6976
TOPIC_DEVICE_PROPERTY = "/app/device/property/{sn}"
7077
"""MQTT-Topic, auf dem das Gerät regelmäßig seinen Zustand veröffentlicht."""
7178

custom_components/ecoflow_powerocean/coordinator.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
import ssl
3434
import threading
3535
import uuid
36-
from datetime import timedelta
36+
from datetime import datetime, timedelta
3737
from typing import Any
3838

3939
import aiohttp
@@ -51,6 +51,7 @@
5151
from homeassistant.core import HomeAssistant
5252
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
5353
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
54+
from homeassistant.util import dt as dt_util
5455

5556
from .const import (
5657
API_CERT_URL,
@@ -64,6 +65,8 @@
6465
DATA_EMS_HEARTBEAT,
6566
DATA_ENERGY_STREAM,
6667
DOMAIN,
68+
GAP_RECONCILIATION_MAX_SECONDS,
69+
GAP_RECONCILIATION_MIN_SECONDS,
6770
MQTT_HOST,
6871
MQTT_KEEPALIVE,
6972
MQTT_PORT,
@@ -119,6 +122,17 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
119122
self._mqtt_client: mqtt.Client | None = None
120123
self._mqtt_connected: bool = False
121124
self._mqtt_lock = threading.Lock()
125+
self._last_message_at: datetime | None = None
126+
127+
# Gap-Reconciliation:
128+
# Erfasst Verbindungsunterbrechungen und stellt Metadaten für die
129+
# Energie-Akkumulatoren bereit, damit diese eine kontrollierte
130+
# Schätzung für Offline-Phasen anwenden können.
131+
self._disconnect_started_at: datetime | None = None
132+
self._last_gap_started_at: datetime | None = None
133+
self._last_gap_ended_at: datetime | None = None
134+
self._last_gap_seconds: float = 0.0
135+
self._gap_event_id: int = 0
122136

123137
# Initialer Datensatz
124138
self.data: dict[str, Any] = {
@@ -292,7 +306,35 @@ def _on_mqtt_connect(self, client, userdata, flags, reason_code, properties=None
292306
# reason_code: 0 (v1) oder ReasonCode.SUCCESS (v2)
293307
rc = reason_code if isinstance(reason_code, int) else reason_code.value
294308
if rc == 0:
309+
now = dt_util.utcnow()
295310
self._mqtt_connected = True
311+
if self._disconnect_started_at is not None:
312+
gap_seconds = max(
313+
0.0,
314+
(now - self._disconnect_started_at).total_seconds(),
315+
)
316+
self._disconnect_started_at = None
317+
318+
if (
319+
GAP_RECONCILIATION_MIN_SECONDS
320+
<= gap_seconds
321+
<= GAP_RECONCILIATION_MAX_SECONDS
322+
):
323+
self._last_gap_seconds = gap_seconds
324+
self._last_gap_started_at = now - timedelta(seconds=gap_seconds)
325+
self._last_gap_ended_at = now
326+
self._gap_event_id += 1
327+
_LOGGER.info(
328+
"MQTT-Lücke erkannt (%.0f s) — Energie-Akkumulatoren wenden Gap-Schätzung an",
329+
gap_seconds,
330+
)
331+
elif gap_seconds > GAP_RECONCILIATION_MAX_SECONDS:
332+
_LOGGER.warning(
333+
"MQTT-Lücke zu lang für automatische Schätzung (%.0f s > %.0f s), "
334+
"Gap-Korrektur wird übersprungen",
335+
gap_seconds,
336+
GAP_RECONCILIATION_MAX_SECONDS,
337+
)
296338
topic = TOPIC_DEVICE_PROPERTY.format(sn=self.serial_number)
297339
client.subscribe(topic, qos=1)
298340
_LOGGER.info("MQTT verbunden, abonniere Topic: %s", topic)
@@ -306,6 +348,8 @@ def _on_mqtt_disconnect(self, client, userdata, disconnect_flags=None, reason_co
306348
307349
paho-mqtt versucht automatisch erneut zu verbinden (loop_start() + reconnect_delay_set).
308350
"""
351+
if self._mqtt_connected and self._disconnect_started_at is None:
352+
self._disconnect_started_at = dt_util.utcnow()
309353
self._mqtt_connected = False
310354
_LOGGER.warning("MQTT-Verbindung getrennt (code=%s), Reconnect läuft…", reason_code)
311355

@@ -329,6 +373,7 @@ def _on_mqtt_message(self, client, userdata, msg):
329373

330374
# Vorhandene Daten aktualisieren (nicht überschreiben)
331375
with self._mqtt_lock:
376+
self._last_message_at = dt_util.utcnow()
332377
new_batteries = dict(self.data.get(DATA_BATTERIES, {}))
333378
for pack in battery_packs:
334379
new_batteries[pack.pack_index] = pack
@@ -416,3 +461,24 @@ async def async_shutdown(self) -> None:
416461
await self.hass.async_add_executor_job(self._mqtt_client.disconnect)
417462
self._mqtt_client = None
418463
self._mqtt_connected = False
464+
self._disconnect_started_at = None
465+
466+
@property
467+
def gap_event_id(self) -> int:
468+
"""Monotoner Zähler: erhöht sich bei jeder relevanten Disconnect-Lücke."""
469+
return self._gap_event_id
470+
471+
@property
472+
def last_gap_seconds(self) -> float:
473+
"""Dauer der zuletzt erkannten MQTT-Lücke in Sekunden."""
474+
return self._last_gap_seconds
475+
476+
@property
477+
def last_gap_started_at(self) -> datetime | None:
478+
"""Startzeitpunkt der zuletzt erkannten MQTT-Lücke (UTC)."""
479+
return self._last_gap_started_at
480+
481+
@property
482+
def last_gap_ended_at(self) -> datetime | None:
483+
"""Endzeitpunkt der zuletzt erkannten MQTT-Lücke (UTC)."""
484+
return self._last_gap_ended_at

custom_components/ecoflow_powerocean/diagnostics.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ async def async_get_config_entry_diagnostics(
7777
getattr(coordinator, "_mqtt_user", None)
7878
and getattr(coordinator, "_mqtt_password", None)
7979
),
80+
"gap_event_id": getattr(coordinator, "gap_event_id", None),
81+
"last_gap_seconds": getattr(coordinator, "last_gap_seconds", None),
82+
"last_gap_started_at": _to_jsonable(getattr(coordinator, "last_gap_started_at", None)),
83+
"last_gap_ended_at": _to_jsonable(getattr(coordinator, "last_gap_ended_at", None)),
8084
"data": _to_jsonable(getattr(coordinator, "data", None)),
8185
}
8286

custom_components/ecoflow_powerocean/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"domain": "ecoflow_powerocean",
33
"name": "EcoFlow PowerOcean",
4-
"version": "0.3.5",
4+
"version": "0.3.6",
55
"codeowners": [],
66
"config_flow": true,
77
"documentation": "https://github.com/Feberdin/ecoflow-powerocean-ha",

custom_components/ecoflow_powerocean/sensor.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -950,6 +950,7 @@ def __init__(self, coordinator, description, device_info, serial):
950950
self._accumulated_kwh: float = 0.0
951951
self._last_update: datetime | None = None
952952
self._last_power_w: float = 0.0
953+
self._seen_gap_event_id: int = 0
953954

954955
async def async_added_to_hass(self) -> None:
955956
await super().async_added_to_hass()
@@ -968,7 +969,32 @@ def _handle_coordinator_update(self) -> None:
968969
power_w = self._get_power_w()
969970
if self._last_update is not None:
970971
dt_hours = (now - self._last_update).total_seconds() / 3600.0
971-
self._accumulated_kwh += (self._last_power_w / 1000.0) * dt_hours
972+
gap_hours = 0.0
973+
if self.coordinator.gap_event_id != self._seen_gap_event_id:
974+
# Für die erste Aktualisierung nach Reconnect:
975+
# - Nicht mit "letzter Leistung über gesamte Offline-Zeit" integrieren.
976+
# - Stattdessen Trapez-Schätzung zwischen letzter und erster Leistung.
977+
gap_hours = max(self.coordinator.last_gap_seconds, 0.0) / 3600.0
978+
self._seen_gap_event_id = self.coordinator.gap_event_id
979+
980+
active_hours = max(dt_hours - gap_hours, 0.0)
981+
self._accumulated_kwh += (self._last_power_w / 1000.0) * active_hours
982+
983+
if gap_hours > 0.0:
984+
estimated_gap_power_w = max(
985+
(self._last_power_w + power_w) / 2.0,
986+
0.0,
987+
)
988+
gap_kwh = (estimated_gap_power_w / 1000.0) * gap_hours
989+
self._accumulated_kwh += gap_kwh
990+
_LOGGER.info(
991+
"Gap-Reconciliation %s: +%.4f kWh (Lücke %.1f min, P_alt=%.1f W, P_neu=%.1f W)",
992+
self.entity_description.key,
993+
gap_kwh,
994+
gap_hours * 60.0,
995+
self._last_power_w,
996+
power_w,
997+
)
972998
self._last_update = now
973999
self._last_power_w = power_w
9741000
super()._handle_coordinator_update()
@@ -1026,6 +1052,20 @@ def icon(self) -> str:
10261052
def available(self) -> bool:
10271053
return True # Immer verfügbar — zeigt connected/disconnected
10281054

1055+
@property
1056+
def extra_state_attributes(self) -> dict[str, Any]:
1057+
"""Zeigt zuletzt erkannte Verbindungs-Lücke für transparentes Debugging."""
1058+
attrs: dict[str, Any] = {}
1059+
if self.coordinator.last_gap_started_at is not None:
1060+
attrs["last_gap_started_at"] = self.coordinator.last_gap_started_at.isoformat()
1061+
if self.coordinator.last_gap_ended_at is not None:
1062+
attrs["last_gap_ended_at"] = self.coordinator.last_gap_ended_at.isoformat()
1063+
if self.coordinator.last_gap_seconds > 0:
1064+
attrs["last_gap_seconds"] = round(self.coordinator.last_gap_seconds, 1)
1065+
attrs["last_gap_minutes"] = round(self.coordinator.last_gap_seconds / 60.0, 2)
1066+
attrs["gap_event_id"] = self.coordinator.gap_event_id
1067+
return attrs
1068+
10291069

10301070
# ── Hilfsfunktionen ───────────────────────────────────────────────────────────
10311071

0 commit comments

Comments
 (0)