Wait for the required EcoFlow reports in each collection

A Bluetooth collection listened for a fixed 10 seconds and returned the data that arrived. If a required report was late, the snapshot missed shared measurements without notice.

Each decoder now tracks the reports behind the shared measurements. A collection listens for 10 seconds, then until those reports arrive, for at most 30 seconds in total. The new ef_missing_measurements field names the reports that did not arrive. The server log shows one line each time that list changes.

The diagnostic tool now uses the River 2 heartbeat list from the decoder.
This commit is contained in:
tim
2026-10-10 09:03:35 +02:00
parent b569bcf27b
commit 161ae7ee65
9 changed files with 249 additions and 11 deletions
+54 -5
View File
@@ -36,6 +36,11 @@ _LOGGER = logging.getLogger(__name__)
MANUFACTURER_KEY = 0xB5B5
# How often a collection checks for the required reports.
_COMPLETION_POLL_SECONDS = 0.5
# Missing reports of the last collection per address, to log changes only.
_last_missing: dict[str, set[str]] = {}
# Packet version and encryption type are independent. R601/R603 use V2
# packets even when the BLE advertisement selects Type 7 encryption.
def _default_packet_version(serial_number: str) -> int:
@@ -502,6 +507,26 @@ class EcoFlowBTDevice:
except asyncio.TimeoutError:
return False
async def wait_for_measurements(self, minimum: float, timeout: float) -> set[str]:
"""Listen for *minimum* seconds, then until the required reports arrive.
Stop after *timeout* seconds in total, or when the connection ends, and
return the names of the reports still missing. A model without a
decoder has no known requirements, so only *minimum* applies.
"""
loop = asyncio.get_running_loop()
start = loop.time()
await asyncio.sleep(minimum)
decoder = self._measurement_decoder
if decoder is None:
return set()
while decoder.missing() and self._state != _State.DISCONNECTED:
remaining = start + timeout - loop.time()
if remaining <= 0:
break
await asyncio.sleep(min(_COMPLETION_POLL_SECONDS, remaining))
return decoder.missing()
async def disconnect(self) -> None:
"""Stop notifications and close the BLE connection."""
if self._auth_fallback_task is not None:
@@ -978,7 +1003,8 @@ class EcoFlowBTConfig:
user_id: str
encrypt_type: int = 7
packet_version: int | None = None
listen_duration: float = 10.0
listen_duration: float = 10.0 # Minimum listening time, for optional reports
listen_timeout: float = 30.0 # Limit for the required reports
def initialise_device(
@@ -988,6 +1014,7 @@ def initialise_device(
encrypt_type: int = 7,
packet_version: int | None = None,
listen_duration: float = 10.0,
listen_timeout: float = 30.0,
) -> EcoFlowBTConfig:
"""Return a device config for use with :func:`collect_data`.
@@ -1002,17 +1029,36 @@ def initialise_device(
encrypt_type=encrypt_type,
packet_version=packet_version,
listen_duration=listen_duration,
listen_timeout=listen_timeout,
)
def _record_missing(address: str, snapshot: EcoFlowSnapshot, missing: set[str]) -> None:
"""Expose the reports that a collection did not receive, and log changes."""
snapshot.ef_missing_measurements = BMSInfoField(
help="Required reports not received in this collection",
info=", ".join(sorted(missing)),
)
if missing != _last_missing.get(address, set()):
_last_missing[address] = missing
debugger(f"ecoflow_bt [{address}]: " + (
f"collection incomplete; missing {', '.join(sorted(missing))}" if missing
else "collection complete again"
))
def collect_data(cfg: EcoFlowBTConfig, debug: int = 0) -> EcoFlowSnapshot | bool:
"""Connect to an EcoFlow device, collect push data, disconnect, return snapshot.
Analogous to :func:`bmspy.jbd_bms.collect_data`. Each call opens a fresh
BLE connection, authenticates, waits *listen_duration* seconds for the
device to push telemetry via notifications, then disconnects.
BLE connection, authenticates, and listens to the telemetry that the device
pushes via notifications. It listens for *listen_duration* seconds, then
until the reports behind the shared measurements arrive, for at most
*listen_timeout* seconds in total. Then it disconnects.
Returns the populated :class:`EcoFlowSnapshot`, or ``False`` on failure.
A snapshot from a collection that reached the limit is partial, and its
``ef_missing_measurements`` field names the reports that did not arrive.
"""
async def _run() -> EcoFlowSnapshot | bool:
device = EcoFlowBTDevice(
@@ -1028,9 +1074,12 @@ def collect_data(cfg: EcoFlowBTConfig, debug: int = 0) -> EcoFlowSnapshot | bool
return False
if not await device.wait_authenticated(timeout=30.0):
return False
await asyncio.sleep(cfg.listen_duration)
missing = await device.wait_for_measurements(cfg.listen_duration, cfg.listen_timeout)
# Metadata alone is not a successful telemetry collection.
return device.snapshot if device._pkt_count else False
if not device._pkt_count:
return False
_record_missing(cfg.address, device.snapshot, missing)
return device.snapshot
finally:
await device.disconnect()
+17
View File
@@ -217,6 +217,13 @@ _CORE_MINIMUM_LENGTHS = {
(0x03, 0x20, 0x02): 25, # EMS through dsg_remain_time
(0x03, 0x20, 0x32): 21, # BMS through temp
}
# Heartbeats behind the shared measurements, by raw packet key.
# PD: total power; EMS: time estimates; BMS: battery temperature and direction.
REQUIRED_HEARTBEATS = {
"ef_pkt_02_20_02": "PD",
"ef_pkt_03_20_02": "EMS",
"ef_pkt_03_20_32": "BMS",
}
_PROTOCOL_NAMESPACES = {
(0x02, 0x20, 0x02): "ef_river2_pd",
(0x03, 0x20, 0x02): "ef_river2_ems",
@@ -304,6 +311,14 @@ class River2Decoder:
def reset(self) -> None:
"""Reset source priorities before a new connection, not the snapshot."""
self._soc_priority = 0
self._received: set[str] = set()
def missing(self) -> set[str]:
"""Name the required heartbeats that this connection has not received."""
return {
f"{label} heartbeat" for key, label in REQUIRED_HEARTBEATS.items()
if key not in self._received
}
def update(
self, snapshot: EcoFlowSnapshot, src: int, cmd_set: int, cmd_id: int, payload: bytes,
@@ -312,6 +327,8 @@ class River2Decoder:
values = decode_river2_payload(src, cmd_set, cmd_id, payload)
if values is None:
return
# Receipt counts, not the values: an unavailable estimate is still a report.
self._received.add(f"ef_pkt_{src:02x}_{cmd_set:02x}_{cmd_id:02x}")
def scalar(name: str, help: str, value: int | float | bool, units: str | None = None):
setattr(snapshot, name, BMSScalarField(
+18
View File
@@ -191,6 +191,14 @@ _RIVER3_TELEMETRY_WIRE_TYPES = {
}
# Reported fields behind the shared measurements. Reports are partial, so a
# collection is complete when each group has one field. Either temperature will do.
REQUIRED_FIELDS = (
("cms_batt_soc",), ("pow_in_sum_w",), ("pow_out_sum_w",), ("pow_get_bms",),
("bms_max_cell_temp", "cms_batt_temp"), ("cms_chg_rem_time",), ("cms_dsg_rem_time",),
)
def decode_river3_payload(
src: int,
cmd_set: int,
@@ -274,6 +282,14 @@ class River3Decoder:
def reset(self) -> None:
"""Reset source priorities before a new connection, not the snapshot."""
self._river3_temp_priority = 0
self._received: set[str] = set()
def missing(self) -> set[str]:
"""Name the required fields that this connection has not received."""
return {
" or ".join(names) for names in REQUIRED_FIELDS
if not self._received.intersection(names)
}
def update(
self, snapshot: EcoFlowSnapshot, src: int, cmd_set: int, cmd_id: int, payload: bytes,
@@ -283,6 +299,8 @@ class River3Decoder:
values = decode_river3_payload(src, cmd_set, cmd_id, payload)
if values is None:
return
# Receipt counts, not the values: an unavailable estimate is still a report.
self._received.update(values)
def scalar(name: str, help: str, value: int | float | bool, units: str | None = None):
setattr(snapshot, name, BMSScalarField(
+2
View File
@@ -21,6 +21,8 @@ class EcoFlowSnapshot(UPS):
ef_bt_address: BMSInfoField | None = None
ef_connection_state: BMSInfoField | None = None
ef_packets_received: BMSScalarField | None = None
# Empty text when the collection received every required report.
ef_missing_measurements: BMSInfoField | None = None
bms_capacity_charge_ratio: BMSScalarField | None = None
bms_temperature_celsius: BMSMultiField | None = None