Files
bmspy/bmspy/ecoflow_snapshot.py
T
tim 161ae7ee65 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.
2026-10-10 09:03:35 +02:00

74 lines
3.3 KiB
Python

"""EcoFlow measurements and diagnostic packets, independent of Bluetooth I/O."""
from collections.abc import Iterator
from dataclasses import dataclass, field, fields as dataclass_fields
from bmspy.classes import BMSField, BMSInfoField, BMSMultiField, BMSScalarField, UPS
@dataclass
class EcoFlowSnapshot(UPS):
"""Data container for a single EcoFlow device telemetry snapshot.
Named scalar/info fields appear as standard BMSFields. Raw packet
payloads received from the device are stored in *_packets* and are
also exposed via *items()*, keyed as ``ef_pkt_<src>_<cmdset>_<cmdid>``.
Decoded protocol fields retain the latest reported value independently,
so partial reports do not erase fields received earlier in the collection.
"""
ef_serial_number: BMSInfoField | None = None
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
bms_charge_is_charging: BMSScalarField | None = None
bms_charge_is_discharging: BMSScalarField | None = None
ef_input_power_watts: BMSScalarField | None = None
ef_output_power_watts: BMSScalarField | None = None
ef_remaining_time_minutes: BMSScalarField | None = None
ef_charge_remaining_time_minutes: BMSScalarField | None = None
ef_discharge_remaining_time_minutes: BMSScalarField | None = None
ef_charge_state: BMSScalarField | None = None
_packets: dict[str, BMSInfoField] = field(
default_factory=dict, init=False, repr=False
)
_protocol_fields: dict[str, BMSField] = field(
default_factory=dict, init=False, repr=False
)
def record_protocol_fields(self, namespace: str, fields: dict[str, BMSField]) -> None:
"""Replace reported fields, not the whole message or snapshot."""
self._protocol_fields.update({f"{namespace}_{name}": value for name, value in fields.items()})
self._protocol_fields.pop(f"{namespace}_decode_error", None)
def record_protocol_error(self, namespace: str, error: str) -> None:
"""Retain the last valid fields and expose why this report was rejected."""
self._protocol_fields[f"{namespace}_decode_error"] = BMSInfoField(
help="Protocol report rejected; previous decoded fields retained", info=error,
)
def record_packet(self, src: int, cmd_set: int, cmd_id: int, payload: bytes) -> None:
"""Keep heartbeats from different modules separate, even with the same command."""
key = f"ef_pkt_{src:02x}_{cmd_set:02x}_{cmd_id:02x}"
self._packets[key] = BMSInfoField(
help=f"Packet src=0x{src:02X} cmd_set=0x{cmd_set:02X} cmd_id=0x{cmd_id:02X}",
info=payload.hex(),
)
def items(self) -> Iterator[tuple[str, BMSField]]:
for f in dataclass_fields(self):
if f.name.startswith("_"):
continue
v = getattr(self, f.name)
if v is not None:
yield f.name, v
yield from self._protocol_fields.items()
yield from self._packets.items()