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.
74 lines
3.3 KiB
Python
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()
|