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
+44
View File
@@ -152,6 +152,50 @@ def test_shared_power_and_temperature_measurements_are_floats(decoder_type, pack
assert [type(value) for value in values] == [float, float, float]
def test_river2_collection_is_complete_after_the_three_core_heartbeats():
decoder = ecoflow_river2.River2Decoder()
snapshot = EcoFlowSnapshot()
assert decoder.missing() == {"PD heartbeat", "EMS heartbeat", "BMS heartbeat"}
decoder.update(snapshot, *PD_PACKET)
decoder.update(snapshot, 4, 0x20, 2, bytes(40)) # INV is optional.
decoder.update(snapshot, 3, 0x20, 0x32, bytes(5)) # A short BMS heartbeat is rejected.
assert decoder.missing() == {"EMS heartbeat", "BMS heartbeat"}
# 5999 marks an unavailable estimate. Its heartbeat still counts as received.
decoder.update(snapshot, 3, 0x20, 2, bytes(17) + struct.pack("<II", 5999, 5999))
decoder.update(snapshot, 3, 0x20, 0x32, bytes(20) + b"\x1c")
assert snapshot.ef_charge_remaining_time_minutes is None
assert decoder.missing() == set()
decoder.reset()
assert len(decoder.missing()) == 3
def test_river3_collection_is_complete_across_partial_reports():
def field(number, value):
tag = number << 3 | (5 if isinstance(value, float) else 0)
encoded = bytearray()
for part in (tag, None if isinstance(value, float) else value):
while part is not None:
encoded.append(part & 0x7F | (0x80 if part > 0x7F else 0))
part = part >> 7 or None
return bytes(encoded) + (struct.pack("<f", value) if isinstance(value, float) else b"")
decoder = ecoflow_river3.River3Decoder()
snapshot = EcoFlowSnapshot()
assert len(decoder.missing()) == len(ecoflow_river3.REQUIRED_FIELDS)
first = field(262, 50.0) + field(3, 244.0) + field(4, 39.0) + field(158, -39.0)
decoder.update(snapshot, 2, 0xFE, 0x15, first)
assert decoder.missing() == {
"bms_max_cell_temp or cms_batt_temp", "cms_chg_rem_time", "cms_dsg_rem_time",
}
# Either temperature will do, and an unavailable estimate still counts as received.
second = field(102, 20) + field(268, 0xFFFFFFFF) + field(269, 0xFFFFFFFF)
decoder.update(snapshot, 2, 0xFE, 0x15, second)
assert snapshot.ef_charge_remaining_time_minutes is None
assert decoder.missing() == set()
decoder.reset()
assert len(decoder.missing()) == len(ecoflow_river3.REQUIRED_FIELDS)
def test_snapshot_keeps_diagnostic_packets_and_json_round_trip():
from bmspy.classes import UPS
+99
View File
@@ -494,6 +494,7 @@ def test_collection_always_disconnects(monkeypatch, connect_ok, authenticated, p
fake = Mock(
connect=AsyncMock(return_value=connect_ok),
wait_authenticated=AsyncMock(return_value=authenticated),
wait_for_measurements=AsyncMock(return_value=set()),
disconnect=AsyncMock(), _pkt_count=packets,
)
monkeypatch.setattr("bmspy.ecoflow_bt.EcoFlowBTDevice", Mock(return_value=fake))
@@ -502,6 +503,104 @@ def test_collection_always_disconnects(monkeypatch, connect_ok, authenticated, p
fake.disconnect.assert_awaited_once()
def test_wait_ends_when_the_required_heartbeats_arrive(monkeypatch):
monkeypatch.setattr("bmspy.ecoflow_bt._COMPLETION_POLL_SECONDS", 0.01)
async def run():
dev = device()
dev._record(heartbeat(2, pd_payload()))
dev._record(heartbeat(3, ems_payload()))
async def late_bms():
await asyncio.sleep(0.03)
dev._record(heartbeat(3, bms_payload(), 0x32))
task = asyncio.create_task(late_bms())
loop = asyncio.get_running_loop()
start = loop.time()
# The late heartbeat extends the wait past the minimum, far below the limit.
missing = await dev.wait_for_measurements(0, 30)
await task
return missing, loop.time() - start
missing, elapsed = asyncio.run(run())
assert missing == set()
assert 0.03 <= elapsed < 5
def test_wait_keeps_the_minimum_for_optional_reports(monkeypatch):
monkeypatch.setattr("bmspy.ecoflow_bt._COMPLETION_POLL_SECONDS", 0.01)
async def run():
dev = device()
for packet in (heartbeat(2, pd_payload()), heartbeat(3, ems_payload()), heartbeat(3, bms_payload(), 0x32)):
dev._record(packet)
loop = asyncio.get_running_loop()
start = loop.time()
missing = await dev.wait_for_measurements(0.05, 30)
return missing, loop.time() - start
missing, elapsed = asyncio.run(run())
assert missing == set()
assert elapsed >= 0.05
@pytest.mark.parametrize("disconnected", [False, True])
def test_wait_stops_at_the_limit_or_a_lost_connection(monkeypatch, disconnected):
monkeypatch.setattr("bmspy.ecoflow_bt._COMPLETION_POLL_SECONDS", 0.01)
async def run():
dev = device()
dev._record(heartbeat(2, pd_payload()))
if disconnected:
dev._on_ble_disconnect(None)
loop = asyncio.get_running_loop()
start = loop.time()
missing = await dev.wait_for_measurements(0, 30 if disconnected else 0.05)
return missing, loop.time() - start
missing, elapsed = asyncio.run(run())
assert missing == {"EMS heartbeat", "BMS heartbeat"}
assert elapsed < 5
def test_wait_uses_only_the_minimum_for_a_model_without_a_decoder():
async def run():
return await device(serial="XXXXTEST00000001").wait_for_measurements(0, 30)
assert asyncio.run(run()) == set()
def test_collection_reports_missing_reports_and_logs_changes(monkeypatch, capsys):
from bmspy import ecoflow_bt
monkeypatch.setattr(ecoflow_bt, "_last_missing", {})
results = [{"EMS heartbeat", "BMS heartbeat"}, {"EMS heartbeat", "BMS heartbeat"}, set()]
snapshots = []
def fake_device(**kwargs):
snapshots.append(ecoflow_bt.EcoFlowSnapshot())
return Mock(
connect=AsyncMock(return_value=True), wait_authenticated=AsyncMock(return_value=True),
wait_for_measurements=AsyncMock(return_value=results[len(snapshots) - 1]),
disconnect=AsyncMock(), _pkt_count=1, snapshot=snapshots[-1],
)
monkeypatch.setattr(ecoflow_bt, "EcoFlowBTDevice", fake_device)
config = initialise_device(ADDRESS, SERIAL, "test-user", listen_duration=7, listen_timeout=20)
logs = []
for _ in results:
assert collect_data(config) is snapshots[-1]
logs.append(capsys.readouterr().out)
assert [snap.ef_missing_measurements.info for snap in snapshots] == [
"BMS heartbeat, EMS heartbeat", "BMS heartbeat, EMS heartbeat", "",
]
# One line for each change, not one for each collection.
assert "collection incomplete; missing BMS heartbeat, EMS heartbeat" in logs[0]
assert logs[1] == ""
assert "collection complete again" in logs[2]
@pytest.fixture
def diagnostic_script():
path = Path(__file__).resolve().parents[1] / "tools" / "ecoflow-bt.py"