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.
212 lines
9.2 KiB
Python
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
|