Files
bmspy/tests/test_backend_modules.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

212 lines
9.2 KiB
Python

"""Backend import boundaries and compatibility, using synthetic packets only."""
from dataclasses import asdict
import json
import struct
import subprocess
import sys
import pytest
from bmspy import ecoflow_proto, ecoflow_river2, ecoflow_river3
from bmspy.ecoflow_snapshot import EcoFlowSnapshot
PD_PACKET = (2, 0x20, 2, bytes(14) + struct.pack("<BHHi", 47, 0, 0, 5999))
EMS_PACKET = (3, 0x20, 2, bytes(26) + struct.pack("<f", 87.5))
CELL_TEMP_PACKET = (2, 0xFE, 0x15, b"\x98\x10\x1d") # field 259: 29 C
COMPOSITE_TEMP_PACKET = (2, 0xFE, 0x15, b"\xb0\x06\x14") # field 102: 20 C
@pytest.mark.parametrize("first", [
"ecoflow_proto", "ecoflow_river2", "ecoflow_river3", "ecoflow_snapshot",
"jbd_proto", "jbd_snapshot",
])
def test_decoders_import_in_any_order_without_transport_dependencies(first):
# A fresh interpreter catches import cycles hidden by pytest's import order.
script = """
import importlib
import importlib.abc
import sys
class NoTransportImports(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname.split('.')[0] in {'serial', 'bleak', 'ecdsa', 'Crypto'}:
raise AssertionError('Unexpected optional dependency: ' + fullname)
if fullname in {'bmspy.ecoflow_bt', 'bmspy.jbd_bms'}:
raise AssertionError('Unexpected transport import: ' + fullname)
sys.meta_path.insert(0, NoTransportImports())
importlib.import_module('bmspy.' + sys.argv[1])
from bmspy import ecoflow_proto, ecoflow_river2, ecoflow_river3, jbd_proto
from bmspy.ecoflow_snapshot import EcoFlowSnapshot
from bmspy.jbd_snapshot import JBDBMS
assert ecoflow_proto.format_payload(2, 0xFE, 0x15, b'\\x08\\x00') == [
'[DisplayPropertyUpload]', ' errcode: 0',
]
assert ecoflow_proto.format_payload(2, 0x20, 2, bytes(23))[0] == '[PD heartbeat]'
assert ecoflow_river2.decode_river2_payload(2, 0x20, 2, bytes(23))['soc'] == 0
assert ecoflow_river3.decode_river3_payload(2, 0xFE, 0x15, b'\\x08\\x00') == {}
assert jbd_proto.bytes_to_digits(1, 0) == 256
assert list(EcoFlowSnapshot().items()) == []
assert list(JBDBMS().items()) == []
"""
result = subprocess.run(
[sys.executable, "-c", script, first],
capture_output=True, text=True, timeout=10,
)
assert result.returncode == 0, result.stderr
def test_snapshot_and_jbd_function_imports_remain_compatible():
from bmspy import ecoflow_bt, jbd_bms, jbd_proto
from bmspy.jbd_snapshot import JBDBMS
assert ecoflow_bt.EcoFlowSnapshot is EcoFlowSnapshot
assert jbd_bms.JBDBMS is JBDBMS
for name in (
"bytes_to_digits", "bytes_to_date", "convert_to_signed",
"calculate_checksum", "verify_checksum", "parse_03_response", "parse_04_response",
):
assert getattr(jbd_bms, name) is getattr(jbd_proto, name)
assert ecoflow_bt.decode_river2_payload is ecoflow_river2.decode_river2_payload
assert ecoflow_bt.decode_river3_payload is ecoflow_river3.decode_river3_payload
def test_diagnostic_formatting_aliases_remain_compatible():
assert ecoflow_proto.decode_payload is ecoflow_proto.format_payload
assert ecoflow_proto.decode_protobuf is ecoflow_proto.format_protobuf
assert ecoflow_proto.decode_schema_protobuf is ecoflow_proto.format_schema_protobuf
assert ecoflow_proto.format_protobuf(b"\x08\x00") == ["field 1: 0"]
assert ecoflow_proto.format_schema_protobuf(b"\x08\x00", "DisplayPropertyUpload") == [
"errcode: 0",
]
assert ecoflow_proto.format_payload(2, 0xFE, 0x15, b"") is None
assert ecoflow_proto.format_payload(0x35, 0x35, 0x20, b"\x00") is None
assert ecoflow_proto.describe_message(2, 0x20, 2) == "PD heartbeat"
assert ecoflow_proto.describe_message(2, 0xFE, 0x15) == "DisplayPropertyUpload"
assert ecoflow_proto.describe_message(0x35, 0x35, 0x20) is None
@pytest.mark.parametrize("model,packets", [
(ecoflow_river2, [PD_PACKET, EMS_PACKET, CELL_TEMP_PACKET, (2, 0x20, 2, b"")]),
(ecoflow_river3, [CELL_TEMP_PACKET, COMPOSITE_TEMP_PACKET, PD_PACKET, (2, 0xFE, 0x15, b"\x80")]),
])
def test_legacy_numeric_decoder_entry_points(model, packets):
name = "decode_" + model.__name__.rsplit("_", 1)[1] + "_payload"
legacy = getattr(ecoflow_proto, name)
direct = getattr(model, name)
for packet in packets:
assert legacy(*packet) == direct(*packet)
@pytest.mark.parametrize("decoder_type,high_packet,low_packet,field,high,low", [
(ecoflow_river2.River2Decoder, EMS_PACKET, PD_PACKET, "bms_capacity_charge_ratio", 0.875, 0.47),
(ecoflow_river3.River3Decoder, CELL_TEMP_PACKET, COMPOSITE_TEMP_PACKET, "bms_temperature_celsius", 29, 20),
], ids=["river2_soc", "river3_temperature"])
def test_measurement_priorities_are_per_instance_and_reset_per_connection(
decoder_type, high_packet, low_packet, field, high, low,
):
def value(snapshot):
measurement = getattr(snapshot, field)
return measurement.raw_values[1] if hasattr(measurement, "raw_values") else measurement.raw_value
decoder = decoder_type()
snapshot = EcoFlowSnapshot()
decoder.update(snapshot, *high_packet)
decoder.update(snapshot, *low_packet)
assert value(snapshot) == high
other = decoder_type()
other_snapshot = EcoFlowSnapshot()
other.update(other_snapshot, *low_packet)
assert value(other_snapshot) == low
assert value(snapshot) == high
before = asdict(snapshot)
decoder.reset()
assert asdict(snapshot) == before
decoder.update(snapshot, *low_packet)
assert value(snapshot) == low
@pytest.mark.parametrize("decoder_type,packets", [
(ecoflow_river2.River2Decoder, [PD_PACKET, (3, 0x20, 0x32, bytes(20) + b"\x1c")]),
(ecoflow_river3.River3Decoder, [
CELL_TEMP_PACKET, (2, 0xFE, 0x15, b"\x1d" + struct.pack("<f", 244) + b"\x25" + struct.pack("<f", 39)),
]),
], ids=["river2", "river3"])
def test_shared_power_and_temperature_measurements_are_floats(decoder_type, packets):
# InfluxDB keeps one type per measurement. The JBD temperatures are floats,
# so an integer from either EcoFlow model would be rejected in a shared bucket.
snapshot = EcoFlowSnapshot()
decoder = decoder_type()
for packet in packets:
decoder.update(snapshot, *packet)
values = [
snapshot.ef_input_power_watts.raw_value,
snapshot.ef_output_power_watts.raw_value,
*snapshot.bms_temperature_celsius.raw_values.values(),
]
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
snapshot = EcoFlowSnapshot()
ecoflow_river2.River2Decoder().update(snapshot, *PD_PACKET)
snapshot.record_packet(*PD_PACKET)
items = dict(snapshot.items())
assert items["bms_capacity_charge_ratio"].raw_value == 0.47
assert items["ef_pkt_02_20_02"].info == PD_PACKET[3].hex()
assert "ef_remaining_time_minutes" not in items
wire_data = {name: asdict(field) for name, field in snapshot.items()}
restored = UPS.from_dict(json.loads(json.dumps(wire_data)))
assert dict(restored.items()) == items